Merge "Show icons for apps bypassing DND" into main

This commit is contained in:
Matías Hernández
2024-07-18 11:54:51 +00:00
committed by Android (Google) Code Review
15 changed files with 832 additions and 59 deletions

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<!-- Based off preference_two_target.xml, with the added LinearLayout for the icons. -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:gravity="center_vertical"
android:background="?android:attr/selectableItemBackground"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:clipToPadding="false">
<include layout="@layout/settingslib_icon_frame"/>
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:hyphenationFrequency="normalFast"
android:lineBreakWordStyle="phrase"
android:textAppearance="?android:attr/textAppearanceListItem"
android:ellipsize="marquee"/>
<TextView
android:id="@android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
android:layout_alignStart="@android:id/title"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary"
android:hyphenationFrequency="normalFast"
android:lineBreakWordStyle="phrase"
android:maxLines="10"/>
<!-- Circular icons (32dp) will be ImageViews under this LinearLayout -->
<LinearLayout
android:id="@+id/circles_container"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_below="@android:id/summary"
android:layout_alignStart="@android:id/title" />
</RelativeLayout>
<include layout="@layout/preference_two_target_divider" />
<!-- Preference should place its actual preference widget here. -->
<LinearLayout
android:id="@android:id/widget_frame"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:minWidth="@dimen/two_target_min_width"
android:gravity="center"
android:orientation="vertical" />
</LinearLayout>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<ImageView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="@dimen/zen_mode_circular_icon_size"
android:layout_height="@dimen/zen_mode_circular_icon_size"
android:layout_marginTop="@dimen/zen_mode_circular_icon_margin_vertical"
android:layout_marginBottom="@dimen/zen_mode_circular_icon_margin_vertical"
android:layout_marginEnd="@dimen/zen_mode_circular_icon_margin_between" />

View File

@@ -508,4 +508,8 @@
<dimen name="zen_mode_icon_list_item_size">96dp</dimen> <dimen name="zen_mode_icon_list_item_size">96dp</dimen>
<dimen name="zen_mode_icon_list_item_circle_diameter">56dp</dimen> <dimen name="zen_mode_icon_list_item_circle_diameter">56dp</dimen>
<dimen name="zen_mode_icon_list_item_icon_size">32dp</dimen> <dimen name="zen_mode_icon_list_item_icon_size">32dp</dimen>
<!-- For the items in the CircularIconsPreference (contacts, apps, sound channels). -->
<dimen name="zen_mode_circular_icon_size">32dp</dimen>
<dimen name="zen_mode_circular_icon_margin_between">4dp</dimen>
<dimen name="zen_mode_circular_icon_margin_vertical">8dp</dimen>
</resources> </resources>

View File

@@ -36,15 +36,15 @@
android:key="allow_filtering" android:key="allow_filtering"
android:title="@string/mode_notification_filter_title"/> android:title="@string/mode_notification_filter_title"/>
<Preference <com.android.settings.notification.modes.CircularIconsPreference
android:key="zen_mode_people" android:key="zen_mode_people"
android:title="@string/zen_category_people" /> android:title="@string/zen_category_people" />
<Preference <com.android.settings.notification.modes.CircularIconsPreference
android:key="zen_mode_apps" android:key="zen_mode_apps"
android:title="@string/zen_category_apps" /> android:title="@string/zen_category_apps" />
<Preference <com.android.settings.notification.modes.CircularIconsPreference
android:key="zen_other_settings" android:key="zen_other_settings"
android:title="@string/zen_category_exceptions" /> android:title="@string/zen_category_exceptions" />
</PreferenceCategory> </PreferenceCategory>

View File

@@ -0,0 +1,98 @@
/*
* 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.settings.notification.modes;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import androidx.annotation.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Function;
/**
* A set of icons to be displayed in a {@link CircularIconsPreference}
*
* @param <T> The type of the items in the set. Can be an arbitrary type, the only requirement
* being that the {@code drawableLoader} supplied to the constructor is able to produce
* a {@link Drawable} from it (for example a resource id, a Content Uri, etc).
*/
class CircularIconSet<T> {
@VisibleForTesting // Can be set by tests, before creating instances.
static ExecutorService sExecutorService = Executors.newCachedThreadPool();
static final CircularIconSet<?> EMPTY = new CircularIconSet<>(ImmutableList.of(),
unused -> new ColorDrawable(Color.BLACK));
private final ImmutableList<T> mItems;
private final Function<T, Drawable> mDrawableLoader;
private final ListeningExecutorService mBackgroundExecutor;
private final ConcurrentHashMap<T, Drawable> mCachedIcons;
CircularIconSet(List<T> items, Function<T, Drawable> drawableLoader) {
mItems = ImmutableList.copyOf(items);
mDrawableLoader = drawableLoader;
mBackgroundExecutor = MoreExecutors.listeningDecorator(sExecutorService);
mCachedIcons = new ConcurrentHashMap<>();
}
int size() {
return mItems.size();
}
/**
* Loads all icons from the set, using the supplied {@code drawableLoader}, in a background
* thread.
*/
List<ListenableFuture<Drawable>> getIcons() {
return getIcons(Integer.MAX_VALUE);
}
/**
* Loads up to {@code maxSize} icons from the set, using the supplied {@code drawableLoader}, in
* a background thread.
*/
List<ListenableFuture<Drawable>> getIcons(int maxNumber) {
return mItems.stream().limit(maxNumber)
.map(this::loadIcon)
.toList();
}
private ListenableFuture<Drawable> loadIcon(T item) {
return mBackgroundExecutor.submit(() -> {
if (mCachedIcons.containsKey(item)) {
return mCachedIcons.get(item);
}
Drawable drawable = mDrawableLoader.apply(item);
if (drawable != null) {
mCachedIcons.put(item, drawable);
}
return drawable;
});
}
}

View File

@@ -0,0 +1,208 @@
/*
* 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.settings.notification.modes;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.ImageView;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.preference.PreferenceViewHolder;
import com.android.settings.R;
import com.android.settingslib.RestrictedPreference;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.List;
import java.util.concurrent.Executor;
public class CircularIconsPreference extends RestrictedPreference {
private Executor mUiExecutor;
@Nullable private LinearLayout mIconContainer;
@Nullable private CircularIconSet<?> mPendingIconSet;
@Nullable private ListenableFuture<?> mPendingLoadIconsFuture;
public CircularIconsPreference(Context context) {
super(context);
init(context);
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
public CircularIconsPreference(Context context, Executor uiExecutor) {
this(context);
mUiExecutor = uiExecutor;
}
public CircularIconsPreference(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public CircularIconsPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
public CircularIconsPreference(Context context, AttributeSet attrs, int defStyleAttr,
int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context);
}
private void init(Context context) {
mUiExecutor = context.getMainExecutor();
setLayoutResource(R.layout.preference_circular_icons);
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
mIconContainer = checkNotNull((LinearLayout) holder.findViewById(R.id.circles_container));
displayIconsIfPending();
}
private void displayIconsIfPending() {
CircularIconSet<?> pendingIconSet = mPendingIconSet;
if (pendingIconSet != null) {
mPendingIconSet = null;
displayIcons(pendingIconSet);
}
}
void displayIcons(CircularIconSet<?> iconSet) {
if (mIconContainer == null) {
// Too soon, wait for bind.
mPendingIconSet = iconSet;
return;
}
mIconContainer.setVisibility(iconSet.size() != 0 ? View.VISIBLE : View.GONE);
if (iconSet.size() == 0) {
return;
}
if (mIconContainer.getMeasuredWidth() == 0) {
// Too soon, wait for first measure to know width.
mPendingIconSet = iconSet;
ViewTreeObserver vto = mIconContainer.getViewTreeObserver();
vto.addOnGlobalLayoutListener(() ->
new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
vto.removeOnGlobalLayoutListener(this);
displayIconsIfPending();
}
});
return;
}
mIconContainer.setVisibility(View.VISIBLE);
Resources res = getContext().getResources();
int availableSpace = mIconContainer.getMeasuredWidth();
int iconHorizontalSpace = res.getDimensionPixelSize(R.dimen.zen_mode_circular_icon_size)
+ res.getDimensionPixelSize(R.dimen.zen_mode_circular_icon_margin_between);
int numIconsThatFit = availableSpace / iconHorizontalSpace;
List<ListenableFuture<Drawable>> iconFutures;
int extraItems = 0;
if (iconSet.size() > numIconsThatFit) {
// Reserve one space for the (+xx) circle.
int numIconsToShow = numIconsThatFit - 1;
if (numIconsToShow < 0) {
numIconsToShow = 0;
}
iconFutures = iconSet.getIcons(numIconsToShow);
extraItems = iconSet.size() - numIconsToShow;
} else {
// Fit exactly or with remaining space.
iconFutures = iconSet.getIcons();
}
displayIconsWhenReady(iconFutures, extraItems);
}
private void displayIconsWhenReady(List<ListenableFuture<Drawable>> iconFutures,
int extraItems) {
checkState(mIconContainer != null);
if (mPendingLoadIconsFuture != null) {
mPendingLoadIconsFuture.cancel(true);
}
int numCircles = iconFutures.size() + (extraItems > 0 ? 1 : 0);
if (mIconContainer.getChildCount() > numCircles) {
mIconContainer.removeViews(numCircles, mIconContainer.getChildCount() - numCircles);
}
for (int i = mIconContainer.getChildCount(); i < numCircles; i++) {
ImageView imageView = (ImageView) LayoutInflater.from(getContext()).inflate(
R.layout.preference_circular_icons_item, mIconContainer, false);
mIconContainer.addView(imageView);
}
// Set up placeholders and extra items indicator.
for (int i = 0; i < iconFutures.size(); i++) {
ImageView imageView = (ImageView) mIconContainer.getChildAt(i);
// TODO: b/346551087 - proper color and shape, should be a gray circle.
imageView.setImageDrawable(new ColorDrawable(Color.RED));
}
if (extraItems > 0) {
ImageView imageView = (ImageView) mIconContainer.getChildAt(
mIconContainer.getChildCount() - 1);
// TODO: b/346551087 - proper color and shape and number.
imageView.setImageDrawable(new ColorDrawable(Color.BLUE));
}
// Display icons when all are ready (more consistent than randomly loading).
mPendingLoadIconsFuture = Futures.allAsList(iconFutures);
FutureUtil.whenDone(
Futures.allAsList(iconFutures),
icons -> {
checkState(mIconContainer != null);
for (int i = 0; i < icons.size(); i++) {
((ImageView) mIconContainer.getChildAt(i)).setImageDrawable(icons.get(i));
}
},
mUiExecutor);
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
ImmutableList<ImageView> getIconViews() {
if (mIconContainer == null) {
return ImmutableList.of();
}
ImmutableList.Builder<ImageView> imageViews = new ImmutableList.Builder<>();
for (int i = 0; i < mIconContainer.getChildCount(); i++) {
imageViews.add((ImageView) mIconContainer.getChildAt(i));
}
return imageViews.build();
}
}

View File

@@ -18,10 +18,13 @@ package com.android.settings.notification.modes;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull;
import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import java.util.concurrent.CancellationException;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -42,9 +45,11 @@ class FutureUtil {
} }
@Override @Override
public void onFailure(Throwable throwable) { public void onFailure(@NonNull Throwable throwable) {
if (!(throwable instanceof CancellationException)) {
Log.e(TAG, String.format(errorLogMessage, errorLogMessageArgs), throwable); Log.e(TAG, String.format(errorLogMessage, errorLogMessageArgs), throwable);
} }
}
}, executor); }, executor);
} }
} }

View File

@@ -24,6 +24,7 @@ import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.os.UserHandle; import android.os.UserHandle;
import android.os.UserManager; import android.os.UserManager;
import android.service.notification.ZenPolicy;
import android.text.TextUtils; import android.text.TextUtils;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@@ -32,6 +33,7 @@ import androidx.fragment.app.Fragment;
import androidx.preference.Preference; import androidx.preference.Preference;
import com.android.settings.R; import com.android.settings.R;
import com.android.settings.Utils;
import com.android.settings.core.SubSettingLauncher; import com.android.settings.core.SubSettingLauncher;
import com.android.settingslib.applications.ApplicationsState; import com.android.settingslib.applications.ApplicationsState;
import com.android.settingslib.applications.ApplicationsState.AppEntry; import com.android.settingslib.applications.ApplicationsState.AppEntry;
@@ -59,7 +61,7 @@ class ZenModeAppsLinkPreferenceController extends AbstractZenModePreferenceContr
private ApplicationsState.Session mAppSession; private ApplicationsState.Session mAppSession;
private final ZenHelperBackend mHelperBackend; private final ZenHelperBackend mHelperBackend;
private ZenMode mZenMode; private ZenMode mZenMode;
private Preference mPreference; private CircularIconsPreference mPreference;
private final Fragment mHost; private final Fragment mHost;
ZenModeAppsLinkPreferenceController(Context context, String key, Fragment host, ZenModeAppsLinkPreferenceController(Context context, String key, Fragment host,
@@ -97,15 +99,22 @@ class ZenModeAppsLinkPreferenceController extends AbstractZenModePreferenceContr
.setArguments(bundle) .setArguments(bundle)
.toIntent()); .toIntent());
mZenMode = zenMode; mZenMode = zenMode;
mPreference = preference; mPreference = (CircularIconsPreference) preference;
if (zenMode.getPolicy().getAllowedChannels() == ZenPolicy.CHANNEL_POLICY_NONE) {
mPreference.setSummary(R.string.zen_mode_apps_none_apps);
mPreference.displayIcons(CircularIconSet.EMPTY);
} else {
if (TextUtils.isEmpty(mPreference.getSummary())) { if (TextUtils.isEmpty(mPreference.getSummary())) {
mPreference.setSummary(R.string.zen_mode_apps_calculating); mPreference.setSummary(R.string.zen_mode_apps_calculating);
} }
if (mApplicationsState != null && mHost != null) { if (mApplicationsState != null && mHost != null) {
mAppSession = mApplicationsState.newSession(mAppSessionCallbacks, mHost.getLifecycle()); mAppSession = mApplicationsState.newSession(mAppSessionCallbacks,
mHost.getLifecycle());
} }
triggerUpdateAppsBypassingDnd(); triggerUpdateAppsBypassingDnd();
} }
}
private void triggerUpdateAppsBypassingDnd() { private void triggerUpdateAppsBypassingDnd() {
if (mAppSession == null) { if (mAppSession == null) {
@@ -126,6 +135,9 @@ class ZenModeAppsLinkPreferenceController extends AbstractZenModePreferenceContr
ImmutableList<AppEntry> apps = getAppsBypassingDndSortedByName(allApps); ImmutableList<AppEntry> apps = getAppsBypassingDndSortedByName(allApps);
mPreference.setSummary(mSummaryHelper.getAppsSummary(mZenMode, apps)); mPreference.setSummary(mSummaryHelper.getAppsSummary(mZenMode, apps));
mPreference.displayIcons(new CircularIconSet<>(apps,
app -> Utils.getBadgedIcon(mContext, app.info)));
} }
@VisibleForTesting @VisibleForTesting

View File

@@ -17,15 +17,12 @@
package com.android.settings.notification.modes; package com.android.settings.notification.modes;
import static android.app.NotificationManager.INTERRUPTION_FILTER_ALL; import static android.app.NotificationManager.INTERRUPTION_FILTER_ALL;
import static android.provider.Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID;
import android.content.Context; import android.content.Context;
import android.os.Bundle;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.preference.Preference; import androidx.preference.Preference;
import com.android.settings.core.SubSettingLauncher;
import com.android.settingslib.notification.modes.ZenMode; import com.android.settingslib.notification.modes.ZenMode;
/** /**
@@ -48,13 +45,13 @@ class ZenModeOtherLinkPreferenceController extends AbstractZenModePreferenceCont
@Override @Override
public void updateState(Preference preference, @NonNull ZenMode zenMode) { public void updateState(Preference preference, @NonNull ZenMode zenMode) {
Bundle bundle = new Bundle(); // TODO: b/332937635 - Update metrics category
bundle.putString(EXTRA_AUTOMATIC_ZEN_RULE_ID, zenMode.getId()); preference.setIntent(
preference.setIntent(new SubSettingLauncher(mContext) ZenSubSettingLauncher.forModeFragment(mContext, ZenModeOtherFragment.class,
.setDestination(ZenModeOtherFragment.class.getName()) zenMode.getId(), 0).toIntent());
.setSourceMetricsCategory(0)
.setArguments(bundle)
.toIntent());
preference.setSummary(mSummaryHelper.getOtherSoundCategoriesSummary(zenMode)); preference.setSummary(mSummaryHelper.getOtherSoundCategoriesSummary(zenMode));
// TODO: b/346551087 - Show media icons
((CircularIconsPreference) preference).displayIcons(CircularIconSet.EMPTY);
} }
} }

View File

@@ -17,15 +17,12 @@
package com.android.settings.notification.modes; package com.android.settings.notification.modes;
import static android.app.NotificationManager.INTERRUPTION_FILTER_ALL; import static android.app.NotificationManager.INTERRUPTION_FILTER_ALL;
import static android.provider.Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID;
import android.content.Context; import android.content.Context;
import android.os.Bundle;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.preference.Preference; import androidx.preference.Preference;
import com.android.settings.core.SubSettingLauncher;
import com.android.settingslib.notification.modes.ZenMode; import com.android.settingslib.notification.modes.ZenMode;
/** /**
@@ -48,14 +45,13 @@ class ZenModePeopleLinkPreferenceController extends AbstractZenModePreferenceCon
@Override @Override
public void updateState(Preference preference, @NonNull ZenMode zenMode) { public void updateState(Preference preference, @NonNull ZenMode zenMode) {
Bundle bundle = new Bundle();
bundle.putString(EXTRA_AUTOMATIC_ZEN_RULE_ID, zenMode.getId());
// TODO(b/332937635): Update metrics category // TODO(b/332937635): Update metrics category
preference.setIntent(new SubSettingLauncher(mContext) preference.setIntent(
.setDestination(ZenModePeopleFragment.class.getName()) ZenSubSettingLauncher.forModeFragment(mContext, ZenModePeopleFragment.class,
.setSourceMetricsCategory(0) zenMode.getId(), 0).toIntent());
.setArguments(bundle)
.toIntent());
preference.setSummary(mSummaryHelper.getPeopleSummary(zenMode)); preference.setSummary(mSummaryHelper.getPeopleSummary(zenMode));
// TODO: b/346551087 - Show people icons
((CircularIconsPreference) preference).displayIcons(CircularIconSet.EMPTY);
} }
} }

View File

@@ -0,0 +1,98 @@
/*
* 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.settings.notification.modes;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
import java.util.List;
import java.util.function.Function;
@RunWith(RobolectricTestRunner.class)
public class CircularIconSetTest {
@Mock private Function<Integer, Drawable> mDrawableLoader;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
CircularIconSet.sExecutorService = MoreExecutors.newDirectExecutorService();
when(mDrawableLoader.apply(anyInt())).thenReturn(new ColorDrawable(Color.BLACK));
}
@Test
public void getIcons_loadsAllIcons() {
CircularIconSet<Integer> set = new CircularIconSet<>(ImmutableList.of(1, 2, 3),
mDrawableLoader);
List<ListenableFuture<Drawable>> iconFutures = set.getIcons();
assertThat(iconFutures).hasSize(3);
verify(mDrawableLoader).apply(1);
verify(mDrawableLoader).apply(2);
verify(mDrawableLoader).apply(3);
}
@Test
public void getIcons_loadsRequestedIcons() {
CircularIconSet<Integer> set = new CircularIconSet<>(ImmutableList.of(1, 2, 3, 4, 5),
mDrawableLoader);
List<ListenableFuture<Drawable>> iconFutures = set.getIcons(2);
assertThat(iconFutures).hasSize(2);
verify(mDrawableLoader).apply(1);
verify(mDrawableLoader).apply(2);
verifyNoMoreInteractions(mDrawableLoader);
}
@Test
public void getIcons_cachesIcons() {
CircularIconSet<Integer> set = new CircularIconSet<>(ImmutableList.of(1, 2, 3, 4, 5),
mDrawableLoader);
List<ListenableFuture<Drawable>> iconFutures = set.getIcons(2);
assertThat(iconFutures).hasSize(2);
verify(mDrawableLoader).apply(1);
verify(mDrawableLoader).apply(2);
verifyNoMoreInteractions(mDrawableLoader);
List<ListenableFuture<Drawable>> iconFuturesAgain = set.getIcons(3);
assertThat(iconFuturesAgain).hasSize(3);
verify(mDrawableLoader).apply(3);
verifyNoMoreInteractions(mDrawableLoader);
}
}

View File

@@ -0,0 +1,201 @@
/*
* 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.settings.notification.modes;
import static android.view.View.MeasureSpec.makeMeasureSpec;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.truth.Truth.assertThat;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageView;
import androidx.preference.PreferenceViewHolder;
import com.android.settings.R;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import java.util.List;
import java.util.stream.IntStream;
@RunWith(RobolectricTestRunner.class)
public class CircularIconsPreferenceTest {
private static final int VIEW_WIDTH = 800;
private Context mContext;
private CircularIconsPreference mPreference;
private View mIconContainer;
private int mOneIconWidth;
@Before
public void setup() {
MockitoAnnotations.initMocks(this);
mContext = RuntimeEnvironment.application;
CircularIconSet.sExecutorService = MoreExecutors.newDirectExecutorService();
mPreference = new CircularIconsPreference(mContext, MoreExecutors.directExecutor());
// Tests should call bindAndMeasureViewHolder() so that icons can be added.
Resources res = mContext.getResources();
mOneIconWidth = res.getDimensionPixelSize(R.dimen.zen_mode_circular_icon_size)
+ res.getDimensionPixelSize(R.dimen.zen_mode_circular_icon_margin_between);
}
private void bindAndMeasureViewHolder(int viewWidth) {
View preferenceView = LayoutInflater.from(mContext).inflate(mPreference.getLayoutResource(),
null);
mIconContainer = checkNotNull(preferenceView.findViewById(R.id.circles_container));
mIconContainer.measure(makeMeasureSpec(viewWidth, View.MeasureSpec.EXACTLY),
makeMeasureSpec(1000, View.MeasureSpec.EXACTLY));
PreferenceViewHolder holder = PreferenceViewHolder.createInstanceForTests(preferenceView);
mPreference.onBindViewHolder(holder);
}
@Test
public void displayIcons_loadsIcons() {
CircularIconSet<Integer> iconSet = new CircularIconSet<>(ImmutableList.of(1, 2),
ColorDrawable::new);
bindAndMeasureViewHolder(VIEW_WIDTH);
mPreference.displayIcons(iconSet);
assertThat(mPreference.getIconViews()).hasSize(2);
assertThat(mPreference.getIconViews().get(0).getDrawable())
.isInstanceOf(ColorDrawable.class);
assertThat(((ColorDrawable) mPreference.getIconViews().get(0).getDrawable()).getColor())
.isEqualTo(1);
assertThat(((ColorDrawable) mPreference.getIconViews().get(1).getDrawable()).getColor())
.isEqualTo(2);
assertThat(mIconContainer.getVisibility()).isEqualTo(View.VISIBLE);
}
@Test
public void displayIcons_noIcons_hidesRow() {
CircularIconSet<Integer> iconSet = new CircularIconSet<>(ImmutableList.of(),
ColorDrawable::new);
bindAndMeasureViewHolder(VIEW_WIDTH);
mPreference.displayIcons(iconSet);
assertThat(mIconContainer.getVisibility()).isEqualTo(View.GONE);
}
@Test
public void displayIcons_exactlyMaxIcons_loadsAllIcons() throws Exception {
int width = 300;
int fittingIcons = width / mOneIconWidth;
CircularIconSet<Integer> iconSet = new CircularIconSet<>(
IntStream.range(0, fittingIcons).boxed().toList(),
ColorDrawable::new);
bindAndMeasureViewHolder(width);
mPreference.displayIcons(iconSet);
List<Drawable> displayedDrawables = mPreference.getIconViews().stream()
.map(ImageView::getDrawable).toList();
assertThat(displayedDrawables).hasSize(fittingIcons);
assertThat(displayedDrawables).containsExactlyElementsIn(
Futures.allAsList(iconSet.getIcons()).get()).inOrder();
}
@Test
public void displayIcons_tooManyIcons_loadsFirstNAndPlusIcon() throws Exception {
int width = 300;
int fittingIcons = width / mOneIconWidth;
CircularIconSet<Integer> iconSet = new CircularIconSet<>(
IntStream.range(0, fittingIcons + 5).boxed().toList(),
ColorDrawable::new);
bindAndMeasureViewHolder(width);
mPreference.displayIcons(iconSet);
List<Drawable> displayedDrawables = mPreference.getIconViews().stream()
.map(ImageView::getDrawable).toList();
assertThat(displayedDrawables).hasSize(fittingIcons);
// N-1 are actual icons, Nth icon is (+xx).
assertThat(displayedDrawables.stream().limit(fittingIcons - 1).toList())
.containsExactlyElementsIn(
Futures.allAsList(iconSet.getIcons(fittingIcons - 1)).get())
.inOrder();
// TODO: b/346551087 - Correctly verify the plus-6 icon, once we generate it properly.
assertThat(((ColorDrawable) displayedDrawables.get(
displayedDrawables.size() - 1)).getColor()).isEqualTo(Color.BLUE);
}
@Test
public void displayIcons_teenyTinySpace_showsPlusIcon_noCrash() {
int width = 1;
CircularIconSet<Integer> iconSet = new CircularIconSet<>(ImmutableList.of(1, 2),
ColorDrawable::new);
bindAndMeasureViewHolder(width);
mPreference.displayIcons(iconSet);
assertThat(mPreference.getIconViews()).hasSize(1);
// TODO: b/346551087 - Correctly verify the plus-2 icon, once we generate it properly.
assertThat(((ColorDrawable) mPreference.getIconViews().get(0).getDrawable()).getColor())
.isEqualTo(Color.BLUE);
}
@Test
public void displayIcons_beforeBind_loadsIconsOnBind() {
CircularIconSet<Integer> iconSet = new CircularIconSet<>(ImmutableList.of(1, 2, 3),
ColorDrawable::new);
mPreference.displayIcons(iconSet);
assertThat(mPreference.getIconViews()).isEmpty();
bindAndMeasureViewHolder(VIEW_WIDTH);
assertThat(mPreference.getIconViews()).hasSize(3);
}
@Test
public void displayIcons_calledAgain_reloadsIcons() {
CircularIconSet<Integer> threeIcons = new CircularIconSet<>(ImmutableList.of(1, 2, 3),
ColorDrawable::new);
CircularIconSet<Integer> twoIcons = new CircularIconSet<>(ImmutableList.of(1, 2),
ColorDrawable::new);
CircularIconSet<Integer> fourIcons = new CircularIconSet<>(ImmutableList.of(1, 2, 3, 4),
ColorDrawable::new);
bindAndMeasureViewHolder(VIEW_WIDTH);
mPreference.displayIcons(threeIcons);
assertThat(mPreference.getIconViews()).hasSize(3);
mPreference.displayIcons(twoIcons);
assertThat(mPreference.getIconViews()).hasSize(2);
mPreference.displayIcons(fourIcons);
assertThat(mPreference.getIconViews()).hasSize(4);
}
}

View File

@@ -23,10 +23,13 @@ import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.robolectric.Shadows.shadowOf; import static org.robolectric.Shadows.shadowOf;
@@ -41,8 +44,11 @@ import android.os.UserManager;
import android.platform.test.annotations.EnableFlags; import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule; import android.platform.test.flag.junit.SetFlagsRule;
import android.service.notification.ZenPolicy; import android.service.notification.ZenPolicy;
import android.view.LayoutInflater;
import android.view.View;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.preference.PreferenceViewHolder;
import com.android.settings.SettingsActivity; import com.android.settings.SettingsActivity;
import com.android.settingslib.applications.ApplicationsState; import com.android.settingslib.applications.ApplicationsState;
@@ -51,7 +57,9 @@ import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
import com.android.settingslib.notification.modes.TestModeBuilder; import com.android.settingslib.notification.modes.TestModeBuilder;
import com.android.settingslib.notification.modes.ZenMode; import com.android.settingslib.notification.modes.ZenMode;
import com.android.settingslib.notification.modes.ZenModesBackend; import com.android.settingslib.notification.modes.ZenModesBackend;
import com.android.settingslib.widget.SelectorWithWidgetPreference;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.MoreExecutors;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
@@ -71,6 +79,7 @@ import java.util.Random;
public final class ZenModeAppsLinkPreferenceControllerTest { public final class ZenModeAppsLinkPreferenceControllerTest {
private ZenModeAppsLinkPreferenceController mController; private ZenModeAppsLinkPreferenceController mController;
private CircularIconsPreference mPreference;
private Context mContext; private Context mContext;
@Mock @Mock
@@ -91,10 +100,21 @@ public final class ZenModeAppsLinkPreferenceControllerTest {
public void setup() { public void setup() {
MockitoAnnotations.initMocks(this); MockitoAnnotations.initMocks(this);
mContext = RuntimeEnvironment.application; mContext = RuntimeEnvironment.application;
CircularIconSet.sExecutorService = MoreExecutors.newDirectExecutorService();
mPreference = new CircularIconsPreference(mContext, MoreExecutors.directExecutor());
when(mApplicationsState.newSession(any(), any())).thenReturn(mSession); when(mApplicationsState.newSession(any(), any())).thenReturn(mSession);
mController = new ZenModeAppsLinkPreferenceController( mController = new ZenModeAppsLinkPreferenceController(
mContext, "controller_key", mock(Fragment.class), mApplicationsState, mContext, "controller_key", mock(Fragment.class), mApplicationsState,
mZenModesBackend, mHelperBackend); mZenModesBackend, mHelperBackend);
// Ensure the preference view is bound & measured (needed to add child ImageViews).
View preferenceView = LayoutInflater.from(mContext).inflate(mPreference.getLayoutResource(),
null);
preferenceView.measure(View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY));
PreferenceViewHolder holder = PreferenceViewHolder.createInstanceForTests(preferenceView);
mPreference.onBindViewHolder(holder);
} }
private AppEntry createAppEntry(String packageName, int userId) { private AppEntry createAppEntry(String packageName, int userId) {
@@ -123,13 +143,11 @@ public final class ZenModeAppsLinkPreferenceControllerTest {
@Test @Test
public void testUpdateSetsIntent() { public void testUpdateSetsIntent() {
// Creates the preference
SelectorWithWidgetPreference preference = new SelectorWithWidgetPreference(mContext);
// Create a zen mode that allows priority channels to breakthrough. // Create a zen mode that allows priority channels to breakthrough.
ZenMode zenMode = createPriorityChannelsZenMode(); ZenMode zenMode = createPriorityChannelsZenMode();
mController.updateState(preference, zenMode); mController.updateState(mPreference, zenMode);
Intent launcherIntent = preference.getIntent(); Intent launcherIntent = mPreference.getIntent();
assertThat(launcherIntent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)) assertThat(launcherIntent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT))
.isEqualTo("com.android.settings.notification.modes.ZenModeAppsFragment"); .isEqualTo("com.android.settings.notification.modes.ZenModeAppsFragment");
@@ -193,9 +211,20 @@ public final class ZenModeAppsLinkPreferenceControllerTest {
} }
@Test @Test
public void testUpdateTriggersRebuild() { public void updateState_withPolicyAllowingNoChannels_doesNotLoadPriorityApps() {
// Creates the preference ZenMode zenMode = new TestModeBuilder()
SelectorWithWidgetPreference preference = new SelectorWithWidgetPreference(mContext); .setZenPolicy(new ZenPolicy.Builder().allowPriorityChannels(false).build())
.build();
mController.updateState(mPreference, zenMode);
verifyNoMoreInteractions(mSession);
verify(mHelperBackend, never()).getPackagesBypassingDnd(anyInt(), anyBoolean());
assertThat(String.valueOf(mPreference.getSummary())).isEqualTo("None");
}
@Test
public void updateState_withPolicyAllowingPriorityChannels_triggersRebuild() {
// Create a zen mode that allows priority channels to breakthrough. // Create a zen mode that allows priority channels to breakthrough.
ZenMode zenMode = createPriorityChannelsZenMode(); ZenMode zenMode = createPriorityChannelsZenMode();
@@ -209,21 +238,35 @@ public final class ZenModeAppsLinkPreferenceControllerTest {
// Updates the preference with the zen mode. We expect that this causes the app session // Updates the preference with the zen mode. We expect that this causes the app session
// to trigger a rebuild (and display a temporary text in the meantime). // to trigger a rebuild (and display a temporary text in the meantime).
mController.updateZenMode(preference, zenMode); mController.updateZenMode(mPreference, zenMode);
verify(mSession).rebuild(any(), any(), eq(false)); verify(mSession).rebuild(any(), any(), eq(false));
assertThat(String.valueOf(preference.getSummary())).isEqualTo("Calculating…"); assertThat(String.valueOf(mPreference.getSummary())).isEqualTo("Calculating…");
// Manually triggers the callback that will happen on rebuild. // Manually triggers the callback that will happen on rebuild.
mController.mAppSessionCallbacks.onRebuildComplete(appEntries); mController.mAppSessionCallbacks.onRebuildComplete(appEntries);
assertThat(String.valueOf(preference.getSummary())).isEqualTo("test can interrupt"); assertThat(String.valueOf(mPreference.getSummary())).isEqualTo("test can interrupt");
}
@Test
public void updateState_withPolicyAllowingPriorityChannels_loadsIcons() {
ZenMode zenMode = createPriorityChannelsZenMode();
mController.updateState(mPreference, zenMode);
when(mHelperBackend.getPackagesBypassingDnd(anyInt(), anyBoolean()))
.thenReturn(ImmutableList.of("test1", "test2"));
ArrayList<ApplicationsState.AppEntry> appEntries = new ArrayList<>();
appEntries.add(createAppEntry("test1", mContext.getUserId()));
appEntries.add(createAppEntry("test2", mContext.getUserId()));
mController.mAppSessionCallbacks.onRebuildComplete(appEntries);
assertThat(mPreference.getIconViews()).hasSize(2);
} }
@Test @Test
public void testOnPackageListChangedTriggersRebuild() { public void testOnPackageListChangedTriggersRebuild() {
SelectorWithWidgetPreference preference = new SelectorWithWidgetPreference(mContext);
// Create a zen mode that allows priority channels to breakthrough. // Create a zen mode that allows priority channels to breakthrough.
ZenMode zenMode = createPriorityChannelsZenMode(); ZenMode zenMode = createPriorityChannelsZenMode();
mController.updateState(preference, zenMode); mController.updateState(mPreference, zenMode);
verify(mSession).rebuild(any(), any(), eq(false)); verify(mSession).rebuild(any(), any(), eq(false));
mController.mAppSessionCallbacks.onPackageListChanged(); mController.mAppSessionCallbacks.onPackageListChanged();
@@ -232,10 +275,9 @@ public final class ZenModeAppsLinkPreferenceControllerTest {
@Test @Test
public void testOnLoadEntriesCompletedTriggersRebuild() { public void testOnLoadEntriesCompletedTriggersRebuild() {
SelectorWithWidgetPreference preference = new SelectorWithWidgetPreference(mContext);
// Create a zen mode that allows priority channels to breakthrough. // Create a zen mode that allows priority channels to breakthrough.
ZenMode zenMode = createPriorityChannelsZenMode(); ZenMode zenMode = createPriorityChannelsZenMode();
mController.updateState(preference, zenMode); mController.updateState(mPreference, zenMode);
verify(mSession).rebuild(any(), any(), eq(false)); verify(mSession).rebuild(any(), any(), eq(false));
mController.mAppSessionCallbacks.onLoadEntriesCompleted(); mController.mAppSessionCallbacks.onLoadEntriesCompleted();

View File

@@ -17,6 +17,7 @@
package com.android.settings.notification.modes; package com.android.settings.notification.modes;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -25,8 +26,6 @@ import android.content.Context;
import android.platform.test.annotations.EnableFlags; import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule; import android.platform.test.flag.junit.SetFlagsRule;
import androidx.preference.Preference;
import com.android.settingslib.notification.modes.TestModeBuilder; import com.android.settingslib.notification.modes.TestModeBuilder;
import org.junit.Before; import org.junit.Before;
@@ -63,8 +62,11 @@ public final class ZenModeOtherLinkPreferenceControllerTest {
@Test @Test
@EnableFlags(Flags.FLAG_MODES_UI) @EnableFlags(Flags.FLAG_MODES_UI)
public void testHasSummary() { public void testHasSummary() {
Preference pref = mock(Preference.class); CircularIconsPreference pref = mock(CircularIconsPreference.class);
mController.updateZenMode(pref, TestModeBuilder.EXAMPLE); mController.updateZenMode(pref, TestModeBuilder.EXAMPLE);
verify(pref).setSummary(any()); verify(pref).setSummary(any());
verify(pref).displayIcons(eq(CircularIconSet.EMPTY));
} }
} }

View File

@@ -17,6 +17,7 @@
package com.android.settings.notification.modes; package com.android.settings.notification.modes;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -25,8 +26,6 @@ import android.content.Context;
import android.platform.test.annotations.EnableFlags; import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule; import android.platform.test.flag.junit.SetFlagsRule;
import androidx.preference.Preference;
import com.android.settingslib.notification.modes.TestModeBuilder; import com.android.settingslib.notification.modes.TestModeBuilder;
import org.junit.Before; import org.junit.Before;
@@ -63,8 +62,11 @@ public final class ZenModePeopleLinkPreferenceControllerTest {
@Test @Test
@EnableFlags(Flags.FLAG_MODES_UI) @EnableFlags(Flags.FLAG_MODES_UI)
public void testHasSummary() { public void testHasSummary() {
Preference pref = mock(Preference.class); CircularIconsPreference pref = mock(CircularIconsPreference.class);
mController.updateZenMode(pref, TestModeBuilder.EXAMPLE); mController.updateZenMode(pref, TestModeBuilder.EXAMPLE);
verify(pref).setSummary(any()); verify(pref).setSummary(any());
verify(pref).displayIcons(eq(CircularIconSet.EMPTY));
} }
} }