diff --git a/res/layout/preference_circular_icons.xml b/res/layout/preference_circular_icons.xml new file mode 100644 index 00000000000..ae981b2c562 --- /dev/null +++ b/res/layout/preference_circular_icons.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/preference_circular_icons_item.xml b/res/layout/preference_circular_icons_item.xml new file mode 100644 index 00000000000..3e8d7fa1f2b --- /dev/null +++ b/res/layout/preference_circular_icons_item.xml @@ -0,0 +1,24 @@ + + + + \ No newline at end of file diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 8a96727d0a2..c76fff5965e 100755 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -508,4 +508,8 @@ 96dp 56dp 32dp + + 32dp + 4dp + 8dp diff --git a/res/xml/modes_rule_settings.xml b/res/xml/modes_rule_settings.xml index 0c687b2aa50..a8ba5530e8b 100644 --- a/res/xml/modes_rule_settings.xml +++ b/res/xml/modes_rule_settings.xml @@ -36,17 +36,17 @@ android:key="allow_filtering" android:title="@string/mode_notification_filter_title"/> - + - + android:title="@string/zen_category_apps" /> - + diff --git a/src/com/android/settings/notification/modes/CircularIconSet.java b/src/com/android/settings/notification/modes/CircularIconSet.java new file mode 100644 index 00000000000..55a92fd749c --- /dev/null +++ b/src/com/android/settings/notification/modes/CircularIconSet.java @@ -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 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 { + + @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 mItems; + private final Function mDrawableLoader; + private final ListeningExecutorService mBackgroundExecutor; + + private final ConcurrentHashMap mCachedIcons; + + CircularIconSet(List items, Function 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> 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> getIcons(int maxNumber) { + return mItems.stream().limit(maxNumber) + .map(this::loadIcon) + .toList(); + } + + private ListenableFuture 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; + }); + } +} diff --git a/src/com/android/settings/notification/modes/CircularIconsPreference.java b/src/com/android/settings/notification/modes/CircularIconsPreference.java new file mode 100644 index 00000000000..1f6e0b0ef34 --- /dev/null +++ b/src/com/android/settings/notification/modes/CircularIconsPreference.java @@ -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> 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> 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 getIconViews() { + if (mIconContainer == null) { + return ImmutableList.of(); + } + ImmutableList.Builder imageViews = new ImmutableList.Builder<>(); + for (int i = 0; i < mIconContainer.getChildCount(); i++) { + imageViews.add((ImageView) mIconContainer.getChildAt(i)); + } + return imageViews.build(); + } +} diff --git a/src/com/android/settings/notification/modes/FutureUtil.java b/src/com/android/settings/notification/modes/FutureUtil.java index e7bf8b9a75f..b9a4300dd8c 100644 --- a/src/com/android/settings/notification/modes/FutureUtil.java +++ b/src/com/android/settings/notification/modes/FutureUtil.java @@ -18,10 +18,13 @@ package com.android.settings.notification.modes; import android.util.Log; +import androidx.annotation.NonNull; + import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import java.util.concurrent.CancellationException; import java.util.concurrent.Executor; import java.util.function.Consumer; @@ -42,8 +45,10 @@ class FutureUtil { } @Override - public void onFailure(Throwable throwable) { - Log.e(TAG, String.format(errorLogMessage, errorLogMessageArgs), throwable); + public void onFailure(@NonNull Throwable throwable) { + if (!(throwable instanceof CancellationException)) { + Log.e(TAG, String.format(errorLogMessage, errorLogMessageArgs), throwable); + } } }, executor); } diff --git a/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceController.java index 9c3f267e188..9bff2bbb558 100644 --- a/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceController.java +++ b/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceController.java @@ -24,6 +24,7 @@ import android.content.Context; import android.os.Bundle; import android.os.UserHandle; import android.os.UserManager; +import android.service.notification.ZenPolicy; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -32,6 +33,7 @@ import androidx.fragment.app.Fragment; import androidx.preference.Preference; import com.android.settings.R; +import com.android.settings.Utils; import com.android.settings.core.SubSettingLauncher; import com.android.settingslib.applications.ApplicationsState; import com.android.settingslib.applications.ApplicationsState.AppEntry; @@ -59,7 +61,7 @@ class ZenModeAppsLinkPreferenceController extends AbstractZenModePreferenceContr private ApplicationsState.Session mAppSession; private final ZenHelperBackend mHelperBackend; private ZenMode mZenMode; - private Preference mPreference; + private CircularIconsPreference mPreference; private final Fragment mHost; ZenModeAppsLinkPreferenceController(Context context, String key, Fragment host, @@ -97,14 +99,21 @@ class ZenModeAppsLinkPreferenceController extends AbstractZenModePreferenceContr .setArguments(bundle) .toIntent()); mZenMode = zenMode; - mPreference = preference; - if (TextUtils.isEmpty(mPreference.getSummary())) { - mPreference.setSummary(R.string.zen_mode_apps_calculating); + 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())) { + mPreference.setSummary(R.string.zen_mode_apps_calculating); + } + if (mApplicationsState != null && mHost != null) { + mAppSession = mApplicationsState.newSession(mAppSessionCallbacks, + mHost.getLifecycle()); + } + triggerUpdateAppsBypassingDnd(); } - if (mApplicationsState != null && mHost != null) { - mAppSession = mApplicationsState.newSession(mAppSessionCallbacks, mHost.getLifecycle()); - } - triggerUpdateAppsBypassingDnd(); } private void triggerUpdateAppsBypassingDnd() { @@ -126,6 +135,9 @@ class ZenModeAppsLinkPreferenceController extends AbstractZenModePreferenceContr ImmutableList apps = getAppsBypassingDndSortedByName(allApps); mPreference.setSummary(mSummaryHelper.getAppsSummary(mZenMode, apps)); + + mPreference.displayIcons(new CircularIconSet<>(apps, + app -> Utils.getBadgedIcon(mContext, app.info))); } @VisibleForTesting diff --git a/src/com/android/settings/notification/modes/ZenModeOtherLinkPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeOtherLinkPreferenceController.java index 248ef1dd585..452faed3f8d 100644 --- a/src/com/android/settings/notification/modes/ZenModeOtherLinkPreferenceController.java +++ b/src/com/android/settings/notification/modes/ZenModeOtherLinkPreferenceController.java @@ -17,15 +17,12 @@ package com.android.settings.notification.modes; import static android.app.NotificationManager.INTERRUPTION_FILTER_ALL; -import static android.provider.Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID; import android.content.Context; -import android.os.Bundle; import androidx.annotation.NonNull; import androidx.preference.Preference; -import com.android.settings.core.SubSettingLauncher; import com.android.settingslib.notification.modes.ZenMode; /** @@ -48,13 +45,13 @@ class ZenModeOtherLinkPreferenceController extends AbstractZenModePreferenceCont @Override public void updateState(Preference preference, @NonNull ZenMode zenMode) { - Bundle bundle = new Bundle(); - bundle.putString(EXTRA_AUTOMATIC_ZEN_RULE_ID, zenMode.getId()); - preference.setIntent(new SubSettingLauncher(mContext) - .setDestination(ZenModeOtherFragment.class.getName()) - .setSourceMetricsCategory(0) - .setArguments(bundle) - .toIntent()); + // TODO: b/332937635 - Update metrics category + preference.setIntent( + ZenSubSettingLauncher.forModeFragment(mContext, ZenModeOtherFragment.class, + zenMode.getId(), 0).toIntent()); + preference.setSummary(mSummaryHelper.getOtherSoundCategoriesSummary(zenMode)); + // TODO: b/346551087 - Show media icons + ((CircularIconsPreference) preference).displayIcons(CircularIconSet.EMPTY); } } diff --git a/src/com/android/settings/notification/modes/ZenModePeopleLinkPreferenceController.java b/src/com/android/settings/notification/modes/ZenModePeopleLinkPreferenceController.java index 936cea6ce5c..2a614188801 100644 --- a/src/com/android/settings/notification/modes/ZenModePeopleLinkPreferenceController.java +++ b/src/com/android/settings/notification/modes/ZenModePeopleLinkPreferenceController.java @@ -17,15 +17,12 @@ package com.android.settings.notification.modes; import static android.app.NotificationManager.INTERRUPTION_FILTER_ALL; -import static android.provider.Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID; import android.content.Context; -import android.os.Bundle; import androidx.annotation.NonNull; import androidx.preference.Preference; -import com.android.settings.core.SubSettingLauncher; import com.android.settingslib.notification.modes.ZenMode; /** @@ -48,14 +45,13 @@ class ZenModePeopleLinkPreferenceController extends AbstractZenModePreferenceCon @Override 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 - preference.setIntent(new SubSettingLauncher(mContext) - .setDestination(ZenModePeopleFragment.class.getName()) - .setSourceMetricsCategory(0) - .setArguments(bundle) - .toIntent()); + preference.setIntent( + ZenSubSettingLauncher.forModeFragment(mContext, ZenModePeopleFragment.class, + zenMode.getId(), 0).toIntent()); + preference.setSummary(mSummaryHelper.getPeopleSummary(zenMode)); + // TODO: b/346551087 - Show people icons + ((CircularIconsPreference) preference).displayIcons(CircularIconSet.EMPTY); } } diff --git a/tests/robotests/src/com/android/settings/notification/modes/CircularIconSetTest.java b/tests/robotests/src/com/android/settings/notification/modes/CircularIconSetTest.java new file mode 100644 index 00000000000..22dc7547959 --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/modes/CircularIconSetTest.java @@ -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 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 set = new CircularIconSet<>(ImmutableList.of(1, 2, 3), + mDrawableLoader); + + List> 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 set = new CircularIconSet<>(ImmutableList.of(1, 2, 3, 4, 5), + mDrawableLoader); + + List> iconFutures = set.getIcons(2); + + assertThat(iconFutures).hasSize(2); + verify(mDrawableLoader).apply(1); + verify(mDrawableLoader).apply(2); + verifyNoMoreInteractions(mDrawableLoader); + } + + @Test + public void getIcons_cachesIcons() { + CircularIconSet set = new CircularIconSet<>(ImmutableList.of(1, 2, 3, 4, 5), + mDrawableLoader); + + List> iconFutures = set.getIcons(2); + assertThat(iconFutures).hasSize(2); + verify(mDrawableLoader).apply(1); + verify(mDrawableLoader).apply(2); + verifyNoMoreInteractions(mDrawableLoader); + + List> iconFuturesAgain = set.getIcons(3); + assertThat(iconFuturesAgain).hasSize(3); + verify(mDrawableLoader).apply(3); + verifyNoMoreInteractions(mDrawableLoader); + } +} diff --git a/tests/robotests/src/com/android/settings/notification/modes/CircularIconsPreferenceTest.java b/tests/robotests/src/com/android/settings/notification/modes/CircularIconsPreferenceTest.java new file mode 100644 index 00000000000..2ef62d0d29c --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/modes/CircularIconsPreferenceTest.java @@ -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 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 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 iconSet = new CircularIconSet<>( + IntStream.range(0, fittingIcons).boxed().toList(), + ColorDrawable::new); + + bindAndMeasureViewHolder(width); + mPreference.displayIcons(iconSet); + + List 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 iconSet = new CircularIconSet<>( + IntStream.range(0, fittingIcons + 5).boxed().toList(), + ColorDrawable::new); + + bindAndMeasureViewHolder(width); + mPreference.displayIcons(iconSet); + + List 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 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 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 threeIcons = new CircularIconSet<>(ImmutableList.of(1, 2, 3), + ColorDrawable::new); + CircularIconSet twoIcons = new CircularIconSet<>(ImmutableList.of(1, 2), + ColorDrawable::new); + CircularIconSet 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); + } +} diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceControllerTest.java index 6d12594d1c1..4a6c59627bd 100644 --- a/tests/robotests/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceControllerTest.java @@ -23,10 +23,13 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import static org.robolectric.Shadows.shadowOf; @@ -41,8 +44,11 @@ import android.os.UserManager; import android.platform.test.annotations.EnableFlags; import android.platform.test.flag.junit.SetFlagsRule; import android.service.notification.ZenPolicy; +import android.view.LayoutInflater; +import android.view.View; import androidx.fragment.app.Fragment; +import androidx.preference.PreferenceViewHolder; import com.android.settings.SettingsActivity; 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.ZenMode; 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.Rule; @@ -71,6 +79,7 @@ import java.util.Random; public final class ZenModeAppsLinkPreferenceControllerTest { private ZenModeAppsLinkPreferenceController mController; + private CircularIconsPreference mPreference; private Context mContext; @Mock @@ -91,10 +100,21 @@ public final class ZenModeAppsLinkPreferenceControllerTest { public void setup() { MockitoAnnotations.initMocks(this); mContext = RuntimeEnvironment.application; + CircularIconSet.sExecutorService = MoreExecutors.newDirectExecutorService(); + mPreference = new CircularIconsPreference(mContext, MoreExecutors.directExecutor()); + when(mApplicationsState.newSession(any(), any())).thenReturn(mSession); mController = new ZenModeAppsLinkPreferenceController( mContext, "controller_key", mock(Fragment.class), mApplicationsState, 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) { @@ -123,13 +143,11 @@ public final class ZenModeAppsLinkPreferenceControllerTest { @Test public void testUpdateSetsIntent() { - // Creates the preference - SelectorWithWidgetPreference preference = new SelectorWithWidgetPreference(mContext); // Create a zen mode that allows priority channels to breakthrough. ZenMode zenMode = createPriorityChannelsZenMode(); - mController.updateState(preference, zenMode); - Intent launcherIntent = preference.getIntent(); + mController.updateState(mPreference, zenMode); + Intent launcherIntent = mPreference.getIntent(); assertThat(launcherIntent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)) .isEqualTo("com.android.settings.notification.modes.ZenModeAppsFragment"); @@ -193,9 +211,20 @@ public final class ZenModeAppsLinkPreferenceControllerTest { } @Test - public void testUpdateTriggersRebuild() { - // Creates the preference - SelectorWithWidgetPreference preference = new SelectorWithWidgetPreference(mContext); + public void updateState_withPolicyAllowingNoChannels_doesNotLoadPriorityApps() { + ZenMode zenMode = new TestModeBuilder() + .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. 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 // 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)); - assertThat(String.valueOf(preference.getSummary())).isEqualTo("Calculating…"); + assertThat(String.valueOf(mPreference.getSummary())).isEqualTo("Calculating…"); // Manually triggers the callback that will happen on rebuild. 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 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 public void testOnPackageListChangedTriggersRebuild() { - SelectorWithWidgetPreference preference = new SelectorWithWidgetPreference(mContext); // Create a zen mode that allows priority channels to breakthrough. ZenMode zenMode = createPriorityChannelsZenMode(); - mController.updateState(preference, zenMode); + mController.updateState(mPreference, zenMode); verify(mSession).rebuild(any(), any(), eq(false)); mController.mAppSessionCallbacks.onPackageListChanged(); @@ -232,10 +275,9 @@ public final class ZenModeAppsLinkPreferenceControllerTest { @Test public void testOnLoadEntriesCompletedTriggersRebuild() { - SelectorWithWidgetPreference preference = new SelectorWithWidgetPreference(mContext); // Create a zen mode that allows priority channels to breakthrough. ZenMode zenMode = createPriorityChannelsZenMode(); - mController.updateState(preference, zenMode); + mController.updateState(mPreference, zenMode); verify(mSession).rebuild(any(), any(), eq(false)); mController.mAppSessionCallbacks.onLoadEntriesCompleted(); diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModeOtherLinkPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModeOtherLinkPreferenceControllerTest.java index 772bd1d8fad..c9ea6d4ac69 100644 --- a/tests/robotests/src/com/android/settings/notification/modes/ZenModeOtherLinkPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModeOtherLinkPreferenceControllerTest.java @@ -17,6 +17,7 @@ package com.android.settings.notification.modes; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -25,8 +26,6 @@ import android.content.Context; import android.platform.test.annotations.EnableFlags; import android.platform.test.flag.junit.SetFlagsRule; -import androidx.preference.Preference; - import com.android.settingslib.notification.modes.TestModeBuilder; import org.junit.Before; @@ -63,8 +62,11 @@ public final class ZenModeOtherLinkPreferenceControllerTest { @Test @EnableFlags(Flags.FLAG_MODES_UI) public void testHasSummary() { - Preference pref = mock(Preference.class); + CircularIconsPreference pref = mock(CircularIconsPreference.class); + mController.updateZenMode(pref, TestModeBuilder.EXAMPLE); + verify(pref).setSummary(any()); + verify(pref).displayIcons(eq(CircularIconSet.EMPTY)); } } \ No newline at end of file diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModePeopleLinkPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModePeopleLinkPreferenceControllerTest.java index dd97d6e690d..0db26c3cae4 100644 --- a/tests/robotests/src/com/android/settings/notification/modes/ZenModePeopleLinkPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModePeopleLinkPreferenceControllerTest.java @@ -17,6 +17,7 @@ package com.android.settings.notification.modes; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -25,8 +26,6 @@ import android.content.Context; import android.platform.test.annotations.EnableFlags; import android.platform.test.flag.junit.SetFlagsRule; -import androidx.preference.Preference; - import com.android.settingslib.notification.modes.TestModeBuilder; import org.junit.Before; @@ -63,8 +62,11 @@ public final class ZenModePeopleLinkPreferenceControllerTest { @Test @EnableFlags(Flags.FLAG_MODES_UI) public void testHasSummary() { - Preference pref = mock(Preference.class); + CircularIconsPreference pref = mock(CircularIconsPreference.class); + mController.updateZenMode(pref, TestModeBuilder.EXAMPLE); + verify(pref).setSummary(any()); + verify(pref).displayIcons(eq(CircularIconSet.EMPTY)); } } \ No newline at end of file