Changes to icon picker for reusability in "add mode" flow

* Don't finish the fragment from the controller (ugh!) instead just report the selected icon via a listener.
* Highlight the selected icon in the list.
* Cache the icon drawables (since we're using selectors for the colors, we don't need to swap them, one per icon resource id is enough).
* Improved the tests a bit too.

Bug: 333901673
Bug: 326442408
Test: ates
Flag: android.app.modes_ui
Change-Id: Ib2ec7a7e3ed99b13f9264aa6f7c209ee3f6967a0
This commit is contained in:
Matías Hernández
2024-06-25 20:29:14 +02:00
parent 67d977b72e
commit c2d2de085d
9 changed files with 190 additions and 67 deletions

View File

@@ -0,0 +1,25 @@
<!--
~ 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.
-->
<!-- Color list for the background in each item in the icon picker list. -->
<selector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android">
<item android:state_pressed="true" android:color="?androidprv:attr/materialColorPrimary" />
<item android:state_selected="true" android:color="?androidprv:attr/materialColorPrimary" />
<item android:color="?androidprv:attr/materialColorSecondaryContainer" />
</selector>

View File

@@ -0,0 +1,25 @@
<!--
~ 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.
-->
<!-- Color list for the icon in each item in the icon picker list. -->
<selector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android">
<item android:state_pressed="true" android:color="?androidprv:attr/materialColorOnPrimary" />
<item android:state_selected="true" android:color="?androidprv:attr/materialColorOnPrimary" />
<item android:color="?androidprv:attr/materialColorOnSecondaryContainer" />
</selector>

View File

@@ -50,14 +50,16 @@ class IconUtil {
/** /**
* Returns a variant of the supplied {@code icon} to be used in the icon picker. The inner icon * Returns a variant of the supplied {@code icon} to be used in the icon picker. The inner icon
* is 36x36dp and it's contained into a circle of diameter 54dp. * is 36x36dp and it's contained into a circle of diameter 54dp. It's also set up so that
* selection and pressed states are represented in the color.
*/ */
static Drawable makeIconCircle(@NonNull Context context, @NonNull Drawable icon) { static Drawable makeIconCircle(@NonNull Context context, @NonNull Drawable icon) {
ShapeDrawable background = new ShapeDrawable(new OvalShape()); ShapeDrawable background = new ShapeDrawable(new OvalShape());
background.getPaint().setColor(Utils.getColorAttrDefaultColor(context, background.setTintList(
com.android.internal.R.attr.materialColorSecondaryContainer)); context.getColorStateList(R.color.modes_icon_picker_item_background));
icon.setTint(Utils.getColorAttrDefaultColor(context, icon = icon.mutate();
com.android.internal.R.attr.materialColorOnSecondaryContainer)); icon.setTintList(
context.getColorStateList(R.color.modes_icon_picker_item_icon));
LayerDrawable layerDrawable = new LayerDrawable(new Drawable[] { background, icon }); LayerDrawable layerDrawable = new LayerDrawable(new Drawable[] { background, icon });

View File

@@ -18,13 +18,14 @@ package com.android.settings.notification.modes;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.app.Application; import android.app.Application;
import android.app.AutomaticZenRule;
import android.app.settings.SettingsEnums; import android.app.settings.SettingsEnums;
import android.content.Context; import android.content.Context;
import android.view.Menu; import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import androidx.annotation.NonNull;
import com.android.settings.R; import com.android.settings.R;
import com.android.settingslib.applications.ApplicationsState; import com.android.settingslib.applications.ApplicationsState;
import com.android.settingslib.core.AbstractPreferenceController; import com.android.settingslib.core.AbstractPreferenceController;
@@ -72,11 +73,9 @@ public class ZenModeFragment extends ZenModeFragmentBase {
// Set title for the entire screen // Set title for the entire screen
ZenMode mode = getMode(); ZenMode mode = getMode();
AutomaticZenRule azr = getAZR(); if (mode != null) {
if (mode == null || azr == null) { requireActivity().setTitle(mode.getName());
return;
} }
getActivity().setTitle(azr.getName());
} }
@Override @Override
@@ -92,7 +91,7 @@ public class ZenModeFragment extends ZenModeFragmentBase {
} }
@Override @Override
protected boolean onOptionsItemSelected(MenuItem item, ZenMode zenMode) { protected boolean onOptionsItemSelected(MenuItem item, @NonNull ZenMode zenMode) {
switch (item.getItemId()) { switch (item.getItemId()) {
case DELETE_MODE: case DELETE_MODE:
new AlertDialog.Builder(mContext) new AlertDialog.Builder(mContext)

View File

@@ -18,7 +18,6 @@ package com.android.settings.notification.modes;
import static android.provider.Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID; import static android.provider.Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID;
import android.app.AutomaticZenRule;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
@@ -34,7 +33,10 @@ import com.android.settings.R;
import com.android.settingslib.core.AbstractPreferenceController; import com.android.settingslib.core.AbstractPreferenceController;
import com.android.settingslib.notification.modes.ZenMode; import com.android.settingslib.notification.modes.ZenMode;
import com.google.common.base.Preconditions;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
/** /**
* Base class for Settings pages used to configure individual modes. * Base class for Settings pages used to configure individual modes.
@@ -175,14 +177,15 @@ abstract class ZenModeFragmentBase extends ZenModesFragmentBase {
return mZenMode; return mZenMode;
} }
/** protected final boolean saveMode(Consumer<ZenMode> updater) {
* Get AutomaticZenRule associated with current mode data, or null if it doesn't exist. Preconditions.checkState(mBackend != null);
*/ ZenMode mode = mZenMode;
@Nullable if (mode == null) {
public AutomaticZenRule getAZR() { Log.wtf(TAG, "Cannot save mode, it hasn't been loaded (" + getClass() + ")");
if (mZenMode == null) { return false;
return null;
} }
return mZenMode.getRule(); updater.accept(mode);
mBackend.updateMode(mode);
return true;
} }
} }

View File

@@ -43,7 +43,16 @@ public class ZenModeIconPickerFragment extends ZenModeFragmentBase {
return ImmutableList.of( return ImmutableList.of(
new ZenModeIconPickerIconPreferenceController(context, "current_icon", this, new ZenModeIconPickerIconPreferenceController(context, "current_icon", this,
mBackend), mBackend),
new ZenModeIconPickerListPreferenceController(context, "icon_list", this, new ZenModeIconPickerListPreferenceController(context, "icon_list",
new IconOptionsProviderImpl(mContext), mBackend)); mIconPickerListener, new IconOptionsProviderImpl(mContext), mBackend));
} }
private final ZenModeIconPickerListPreferenceController.IconPickerListener mIconPickerListener =
new ZenModeIconPickerListPreferenceController.IconPickerListener() {
@Override
public void onIconSelected(int iconResId) {
saveMode(mode -> mode.getRule().setIconResId(iconResId));
finish();
}
};
} }

View File

@@ -17,6 +17,7 @@
package com.android.settings.notification.modes; package com.android.settings.notification.modes;
import android.content.Context; import android.content.Context;
import android.graphics.drawable.Drawable;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@@ -25,31 +26,35 @@ import android.widget.ImageView;
import androidx.annotation.DrawableRes; import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.preference.Preference; import androidx.preference.Preference;
import androidx.preference.PreferenceScreen; import androidx.preference.PreferenceScreen;
import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import com.android.settings.R; import com.android.settings.R;
import com.android.settings.dashboard.DashboardFragment;
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.LayoutPreference; import com.android.settingslib.widget.LayoutPreference;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import java.util.HashMap;
import java.util.Map;
class ZenModeIconPickerListPreferenceController extends AbstractZenModePreferenceController { class ZenModeIconPickerListPreferenceController extends AbstractZenModePreferenceController {
private final DashboardFragment mFragment;
private final IconOptionsProvider mIconOptionsProvider; private final IconOptionsProvider mIconOptionsProvider;
private final IconPickerListener mListener;
@Nullable private IconAdapter mAdapter; @Nullable private IconAdapter mAdapter;
private @DrawableRes int mCurrentIconResId;
ZenModeIconPickerListPreferenceController(@NonNull Context context, @NonNull String key, ZenModeIconPickerListPreferenceController(@NonNull Context context, @NonNull String key,
@NonNull DashboardFragment fragment, @NonNull IconOptionsProvider iconOptionsProvider, @NonNull IconPickerListener listener, @NonNull IconOptionsProvider iconOptionsProvider,
@Nullable ZenModesBackend backend) { @Nullable ZenModesBackend backend) {
super(context, key, backend); super(context, key, backend);
mFragment = fragment; mListener = listener;
mIconOptionsProvider = iconOptionsProvider; mIconOptionsProvider = iconOptionsProvider;
} }
@@ -68,20 +73,34 @@ class ZenModeIconPickerListPreferenceController extends AbstractZenModePreferenc
recyclerView.setLayoutManager(new AutoFitGridLayoutManager(mContext)); recyclerView.setLayoutManager(new AutoFitGridLayoutManager(mContext));
recyclerView.setAdapter(mAdapter); recyclerView.setAdapter(mAdapter);
recyclerView.setHasFixedSize(true); recyclerView.setHasFixedSize(true);
} if (recyclerView.getItemAnimator() instanceof SimpleItemAnimator animator) {
animator.setSupportsChangeAnimations(true);
@VisibleForTesting }
void onIconSelected(@DrawableRes int resId) {
saveMode(mode -> {
mode.getRule().setIconResId(resId);
return mode;
});
mFragment.finish();
} }
@Override @Override
void updateState(Preference preference, @NonNull ZenMode zenMode) { void updateState(Preference preference, @NonNull ZenMode zenMode) {
// Nothing to do, the current icon is shown in a different preference. updateIconSelection(zenMode.getRule().getIconResId());
}
private void updateIconSelection(@DrawableRes int iconResId) {
if (iconResId != mCurrentIconResId) {
int oldIconResId = mCurrentIconResId;
mCurrentIconResId = iconResId;
if (mAdapter != null) {
mAdapter.notifyIconChanged(oldIconResId);
mAdapter.notifyIconChanged(mCurrentIconResId);
}
}
}
private void onIconSelected(@DrawableRes int iconResId) {
updateIconSelection(iconResId);
mListener.onIconSelected(iconResId);
}
interface IconPickerListener {
void onIconSelected(@DrawableRes int iconResId);
} }
private class IconHolder extends RecyclerView.ViewHolder { private class IconHolder extends RecyclerView.ViewHolder {
@@ -93,20 +112,25 @@ class ZenModeIconPickerListPreferenceController extends AbstractZenModePreferenc
mImageView = itemView.findViewById(R.id.icon_image_view); mImageView = itemView.findViewById(R.id.icon_image_view);
} }
void bindIcon(IconOptionsProvider.IconInfo icon) { void bindIcon(IconOptionsProvider.IconInfo icon, Drawable iconDrawable) {
mImageView.setImageDrawable( mImageView.setImageDrawable(iconDrawable);
IconUtil.makeIconCircle(itemView.getContext(), icon.resId()));
itemView.setContentDescription(icon.description()); itemView.setContentDescription(icon.description());
itemView.setOnClickListener(v -> onIconSelected(icon.resId())); itemView.setOnClickListener(v -> {
itemView.setSelected(true); // Immediately, to avoid flicker until we rebind.
onIconSelected(icon.resId());
});
itemView.setSelected(icon.resId() == mCurrentIconResId);
} }
} }
private class IconAdapter extends RecyclerView.Adapter<IconHolder> { private class IconAdapter extends RecyclerView.Adapter<IconHolder> {
private final ImmutableList<IconOptionsProvider.IconInfo> mIconResources; private final ImmutableList<IconOptionsProvider.IconInfo> mIconResources;
private final Map<IconOptionsProvider.IconInfo, Drawable> mIconCache;
private IconAdapter(IconOptionsProvider iconOptionsProvider) { private IconAdapter(IconOptionsProvider iconOptionsProvider) {
mIconResources = iconOptionsProvider.getIcons(); mIconResources = iconOptionsProvider.getIcons();
mIconCache = new HashMap<>();
} }
@NonNull @NonNull
@@ -119,13 +143,24 @@ class ZenModeIconPickerListPreferenceController extends AbstractZenModePreferenc
@Override @Override
public void onBindViewHolder(@NonNull IconHolder holder, int position) { public void onBindViewHolder(@NonNull IconHolder holder, int position) {
holder.bindIcon(mIconResources.get(position)); IconOptionsProvider.IconInfo iconInfo = mIconResources.get(position);
Drawable iconDrawable = mIconCache.computeIfAbsent(iconInfo,
info -> IconUtil.makeIconCircle(mContext, info.resId()));
holder.bindIcon(iconInfo, iconDrawable);
} }
@Override @Override
public int getItemCount() { public int getItemCount() {
return mIconResources.size(); return mIconResources.size();
} }
private void notifyIconChanged(@DrawableRes int iconResId) {
int position = Iterables.indexOf(mIconResources,
iconInfo -> iconInfo.resId() == iconResId);
if (position != -1) {
notifyItemChanged(position);
}
}
} }
private static class AutoFitGridLayoutManager extends GridLayoutManager { private static class AutoFitGridLayoutManager extends GridLayoutManager {

View File

@@ -24,6 +24,7 @@ import android.service.notification.ZenDeviceEffects;
import android.service.notification.ZenModeConfig; import android.service.notification.ZenModeConfig;
import android.service.notification.ZenPolicy; import android.service.notification.ZenPolicy;
import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.android.settingslib.notification.modes.ZenMode; import com.android.settingslib.notification.modes.ZenMode;
@@ -70,13 +71,13 @@ class TestModeBuilder {
return this; return this;
} }
public TestModeBuilder setName(String name) { TestModeBuilder setName(String name) {
mRule.setName(name); mRule.setName(name);
mConfigZenRule.name = name; mConfigZenRule.name = name;
return this; return this;
} }
public TestModeBuilder setPackage(String pkg) { TestModeBuilder setPackage(String pkg) {
mRule.setPackageName(pkg); mRule.setPackageName(pkg);
mConfigZenRule.pkg = pkg; mConfigZenRule.pkg = pkg;
return this; return this;
@@ -114,7 +115,7 @@ class TestModeBuilder {
return this; return this;
} }
public TestModeBuilder setEnabled(boolean enabled) { TestModeBuilder setEnabled(boolean enabled) {
mRule.setEnabled(enabled); mRule.setEnabled(enabled);
mConfigZenRule.enabled = enabled; mConfigZenRule.enabled = enabled;
return this; return this;
@@ -126,12 +127,17 @@ class TestModeBuilder {
return this; return this;
} }
public TestModeBuilder setTriggerDescription(@Nullable String triggerDescription) { TestModeBuilder setTriggerDescription(@Nullable String triggerDescription) {
mRule.setTriggerDescription(triggerDescription); mRule.setTriggerDescription(triggerDescription);
mConfigZenRule.triggerDescription = triggerDescription; mConfigZenRule.triggerDescription = triggerDescription;
return this; return this;
} }
TestModeBuilder setIconResId(@DrawableRes int iconResId) {
mRule.setIconResId(iconResId);
return this;
}
TestModeBuilder setActive(boolean active) { TestModeBuilder setActive(boolean active) {
if (active) { if (active) {
mConfigZenRule.enabled = true; mConfigZenRule.enabled = true;

View File

@@ -24,13 +24,15 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import android.content.Context; import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.preference.PreferenceScreen; import androidx.preference.PreferenceScreen;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.android.settings.R; import com.android.settings.R;
import com.android.settings.dashboard.DashboardFragment;
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.LayoutPreference; import com.android.settingslib.widget.LayoutPreference;
@@ -40,35 +42,34 @@ import com.google.common.collect.ImmutableList;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor; import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment; import org.robolectric.RuntimeEnvironment;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
public class ZenModeIconPickerListPreferenceControllerTest { public class ZenModeIconPickerListPreferenceControllerTest {
private static final ZenMode ZEN_MODE = TestModeBuilder.EXAMPLE; private Context mContext;
private ZenModesBackend mBackend;
private ZenModeIconPickerListPreferenceController mController; private ZenModeIconPickerListPreferenceController mController;
private PreferenceScreen mPreferenceScreen; @Mock private PreferenceScreen mPreferenceScreen;
private LayoutPreference mLayoutPreference;
private RecyclerView mRecyclerView; private RecyclerView mRecyclerView;
@Mock private ZenModeIconPickerListPreferenceController.IconPickerListener mListener;
@Before @Before
public void setUp() { public void setUp() {
Context context = RuntimeEnvironment.getApplication(); MockitoAnnotations.initMocks(this);
mBackend = mock(ZenModesBackend.class); mContext = RuntimeEnvironment.getApplication();
DashboardFragment fragment = mock(DashboardFragment.class);
mController = new ZenModeIconPickerListPreferenceController( mController = new ZenModeIconPickerListPreferenceController(
RuntimeEnvironment.getApplication(), "icon_list", fragment, RuntimeEnvironment.getApplication(), "icon_list", mListener,
new TestIconOptionsProvider(), mBackend); new TestIconOptionsProvider(), mock(ZenModesBackend.class));
mRecyclerView = new RecyclerView(context); mRecyclerView = new RecyclerView(mContext);
mRecyclerView.setId(R.id.icon_list); mRecyclerView.setId(R.id.icon_list);
LayoutPreference layoutPreference = new LayoutPreference(context, mRecyclerView); mLayoutPreference = new LayoutPreference(mContext, mRecyclerView);
mPreferenceScreen = mock(PreferenceScreen.class); when(mPreferenceScreen.findPreference(eq("icon_list"))).thenReturn(mLayoutPreference);
when(mPreferenceScreen.findPreference(eq("icon_list"))).thenReturn(layoutPreference);
} }
@Test @Test
@@ -80,14 +81,32 @@ public class ZenModeIconPickerListPreferenceControllerTest {
} }
@Test @Test
public void selectIcon_updatesMode() { public void updateState_highlightsCurrentIcon() {
mController.setZenMode(ZEN_MODE); ZenMode mode = new TestModeBuilder().setIconResId(R.drawable.ic_hearing).build();
mController.displayPreference(mPreferenceScreen);
mController.onIconSelected(R.drawable.ic_android); mController.updateZenMode(mLayoutPreference, mode);
ArgumentCaptor<ZenMode> captor = ArgumentCaptor.forClass(ZenMode.class); assertThat(getItemViewAt(0).isSelected()).isFalse();
verify(mBackend).updateMode(captor.capture()); assertThat(getItemViewAt(1).isSelected()).isFalse();
assertThat(captor.getValue().getRule().getIconResId()).isEqualTo(R.drawable.ic_android); assertThat(getItemViewAt(2).isSelected()).isTrue();
}
@Test
public void performClick_onIconItem_notifiesListener() {
mController.displayPreference(mPreferenceScreen);
getItemViewAt(1).performClick();
verify(mListener).onIconSelected(R.drawable.ic_info);
}
private View getItemViewAt(int position) {
ViewGroup fakeParent = new FrameLayout(mContext);
RecyclerView.ViewHolder viewHolder = mRecyclerView.getAdapter().onCreateViewHolder(
fakeParent, 0);
mRecyclerView.getAdapter().bindViewHolder(viewHolder, position);
return viewHolder.itemView;
} }
private static class TestIconOptionsProvider implements IconOptionsProvider { private static class TestIconOptionsProvider implements IconOptionsProvider {