Merge "Load app-provided mode icons asynchronously, and cache them" into main

This commit is contained in:
Matías Hernández
2024-05-22 13:57:13 +00:00
committed by Android (Google) Code Review
7 changed files with 321 additions and 72 deletions

View File

@@ -0,0 +1,50 @@
/*
* 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.util.Log;
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.Executor;
import java.util.function.Consumer;
class FutureUtil {
private static final String TAG = "ZenFutureUtil";
static <V> void whenDone(ListenableFuture<V> future, Consumer<V> consumer, Executor executor) {
whenDone(future, consumer, executor, "Error in future");
}
static <V> void whenDone(ListenableFuture<V> future, Consumer<V> consumer, Executor executor,
String errorLogMessage, Object... errorLogMessageArgs) {
Futures.addCallback(future, new FutureCallback<V>() {
@Override
public void onSuccess(V v) {
consumer.accept(v);
}
@Override
public void onFailure(Throwable throwable) {
Log.e(TAG, String.format(errorLogMessage, errorLogMessageArgs), throwable);
}
}, executor);
}
}

View File

@@ -0,0 +1,159 @@
/*
* 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.util.concurrent.Futures.immediateFuture;
import static java.util.Objects.requireNonNull;
import android.annotation.Nullable;
import android.app.AutomaticZenRule;
import android.content.Context;
import android.graphics.drawable.AdaptiveIconDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.InsetDrawable;
import android.service.notification.SystemZenRules;
import android.text.TextUtils;
import android.util.Log;
import android.util.LruCache;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.content.res.AppCompatResources;
import com.android.settings.R;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
class IconLoader {
private static final String TAG = "ZenIconLoader";
private static final Drawable MISSING = new ColorDrawable();
@Nullable // Until first usage
private static IconLoader sInstance;
private final Context mContext;
private final LruCache<String, Drawable> mCache;
private final ListeningExecutorService mBackgroundExecutor;
static IconLoader getInstance(Context context) {
if (sInstance == null) {
sInstance = new IconLoader(context);
}
return sInstance;
}
private IconLoader(Context context) {
this(context, Executors.newFixedThreadPool(4));
}
@VisibleForTesting
IconLoader(Context context, ExecutorService backgroundExecutor) {
mContext = context.getApplicationContext();
mCache = new LruCache<>(50);
mBackgroundExecutor =
MoreExecutors.listeningDecorator(backgroundExecutor);
}
Context getContext() {
return mContext;
}
@NonNull
ListenableFuture<Drawable> getIcon(@NonNull AutomaticZenRule rule) {
if (rule.getIconResId() == 0) {
return Futures.immediateFuture(getFallbackIcon(rule.getType()));
}
return FluentFuture.from(loadIcon(rule.getPackageName(), rule.getIconResId()))
.transform(icon ->
icon != null ? icon : getFallbackIcon(rule.getType()),
MoreExecutors.directExecutor());
}
@NonNull
private ListenableFuture</* @Nullable */ Drawable> loadIcon(String pkg, int iconResId) {
String cacheKey = pkg + ":" + iconResId;
synchronized (mCache) {
Drawable cachedValue = mCache.get(cacheKey);
if (cachedValue != null) {
return immediateFuture(cachedValue != MISSING ? cachedValue : null);
}
}
return FluentFuture.from(mBackgroundExecutor.submit(() -> {
if (TextUtils.isEmpty(pkg) || SystemZenRules.PACKAGE_ANDROID.equals(pkg)) {
return mContext.getDrawable(iconResId);
} else {
Context appContext = mContext.createPackageContext(pkg, 0);
Drawable appDrawable = AppCompatResources.getDrawable(appContext, iconResId);
return getMonochromeIconIfPresent(appDrawable);
}
})).catching(Exception.class, ex -> {
// If we cannot resolve the icon, then store MISSING in the cache below, so
// we don't try again.
Log.e(TAG, "Error while loading icon " + cacheKey, ex);
return null;
}, MoreExecutors.directExecutor()).transform(drawable -> {
synchronized (mCache) {
mCache.put(cacheKey, drawable != null ? drawable : MISSING);
}
return drawable;
}, MoreExecutors.directExecutor());
}
private Drawable getFallbackIcon(int ruleType) {
int iconResIdFromType = switch (ruleType) {
// TODO: b/333528437 - continue replacing with proper default icons
case AutomaticZenRule.TYPE_UNKNOWN -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_OTHER -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_SCHEDULE_TIME -> R.drawable.ic_modes_time;
case AutomaticZenRule.TYPE_SCHEDULE_CALENDAR -> R.drawable.ic_modes_event;
case AutomaticZenRule.TYPE_BEDTIME -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_DRIVING -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_IMMERSIVE -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_THEATER -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_MANAGED -> R.drawable.ic_do_not_disturb_on_24dp;
default -> R.drawable.ic_do_not_disturb_on_24dp;
};
return requireNonNull(mContext.getDrawable(iconResIdFromType));
}
private static Drawable getMonochromeIconIfPresent(Drawable icon) {
// For created rules, the app should've provided a monochrome Drawable. However, implicit
// rules have the app's icon, which is not -- but might have a monochrome layer. Thus
// we choose it, if present.
if (icon instanceof AdaptiveIconDrawable adaptiveIcon) {
if (adaptiveIcon.getMonochrome() != null) {
// Wrap with negative inset => scale icon (inspired from BaseIconFactory)
return new InsetDrawable(adaptiveIcon.getMonochrome(),
-2.0f * AdaptiveIconDrawable.getExtraInsetFraction());
}
}
return icon;
}
}

View File

@@ -25,15 +25,12 @@ import android.annotation.SuppressLint;
import android.app.AutomaticZenRule;
import android.app.NotificationManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;
import android.service.notification.SystemZenRules;
import android.service.notification.ZenPolicy;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import com.android.settings.R;
@@ -94,8 +91,6 @@ class ZenMode {
private final boolean mIsActive;
private final boolean mIsManualDnd;
// private ZenPolicy mPreviousPolicy;
ZenMode(String id, AutomaticZenRule rule, boolean isActive) {
this(id, rule, isActive, false);
}
@@ -122,49 +117,14 @@ class ZenMode {
}
@NonNull
public ListenableFuture<Drawable> getIcon(@NonNull Context context) {
// TODO: b/333528586 - Load the icons asynchronously, and cache them
public ListenableFuture<Drawable> getIcon(@NonNull IconLoader iconLoader) {
Context context = iconLoader.getContext();
if (mIsManualDnd) {
return Futures.immediateFuture(
requireNonNull(context.getDrawable(R.drawable.ic_do_not_disturb_on_24dp)));
return Futures.immediateFuture(requireNonNull(
context.getDrawable(R.drawable.ic_do_not_disturb_on_24dp)));
}
int iconResId = mRule.getIconResId();
Drawable customIcon = null;
if (iconResId != 0) {
if (SystemZenRules.PACKAGE_ANDROID.equals(mRule.getPackageName())) {
customIcon = context.getDrawable(mRule.getIconResId());
} else {
try {
Context appContext = context.createPackageContext(mRule.getPackageName(), 0);
customIcon = AppCompatResources.getDrawable(appContext, mRule.getIconResId());
} catch (PackageManager.NameNotFoundException e) {
Log.wtf(TAG,
"Package " + mRule.getPackageName() + " used in rule " + mId
+ " not found?", e);
// Continue down to use a default icon.
}
}
}
if (customIcon != null) {
return Futures.immediateFuture(customIcon);
}
// Derive a default icon from the rule type.
// TODO: b/333528437 - Use correct icons
int iconResIdFromType = switch (mRule.getType()) {
case AutomaticZenRule.TYPE_UNKNOWN -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_OTHER -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_SCHEDULE_TIME -> R.drawable.ic_modes_time;
case AutomaticZenRule.TYPE_SCHEDULE_CALENDAR -> R.drawable.ic_modes_event;
case AutomaticZenRule.TYPE_BEDTIME -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_DRIVING -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_IMMERSIVE -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_THEATER -> R.drawable.ic_do_not_disturb_on_24dp;
case AutomaticZenRule.TYPE_MANAGED -> R.drawable.ic_do_not_disturb_on_24dp;
default -> R.drawable.ic_do_not_disturb_on_24dp;
};
return Futures.immediateFuture(requireNonNull(context.getDrawable(iconResIdFromType)));
return iconLoader.getIcon(mRule);
}
@NonNull

View File

@@ -17,7 +17,6 @@ package com.android.settings.notification.modes;
import android.app.Flags;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -28,9 +27,7 @@ import com.android.settings.dashboard.DashboardFragment;
import com.android.settings.widget.EntityHeaderController;
import com.android.settingslib.widget.LayoutPreference;
import java.util.concurrent.TimeUnit;
public class ZenModeHeaderController extends AbstractZenModePreferenceController {
class ZenModeHeaderController extends AbstractZenModePreferenceController {
private final DashboardFragment mFragment;
private EntityHeaderController mHeaderController;
@@ -51,7 +48,8 @@ public class ZenModeHeaderController extends AbstractZenModePreferenceController
@Override
public void updateState(Preference preference) {
if (getAZR() == null || mFragment == null) {
ZenMode mode = getMode();
if (mode == null || mFragment == null) {
return;
}
@@ -62,14 +60,12 @@ public class ZenModeHeaderController extends AbstractZenModePreferenceController
mFragment,
pref.findViewById(R.id.entity_header));
}
Drawable icon = null;
try {
icon = getMode().getIcon(mContext).get(200, TimeUnit.MILLISECONDS);
} catch (Exception e) {
// no icon
}
mHeaderController.setIcon(icon)
.setLabel(getAZR().getName())
.done(false /* rebindActions */);
FutureUtil.whenDone(
mode.getIcon(IconLoader.getInstance(mContext)),
icon -> mHeaderController.setIcon(icon)
.setLabel(mode.getRule().getName())
.done(false /* rebindActions */),
mContext.getMainExecutor());
}
}

View File

@@ -25,15 +25,11 @@ import com.android.settings.core.SubSettingLauncher;
import com.android.settings.notification.zen.ZenModeSettings;
import com.android.settingslib.RestrictedPreference;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Preference representing a single mode item on the modes aggregator page. Clicking on this
* preference leads to an individual mode's configuration page.
*/
public class ZenModeListPreference extends RestrictedPreference {
class ZenModeListPreference extends RestrictedPreference {
final Context mContext;
ZenMode mZenMode;
@@ -68,10 +64,10 @@ public class ZenModeListPreference extends RestrictedPreference {
mZenMode = zenMode;
setTitle(mZenMode.getRule().getName());
setSummary(mZenMode.getRule().getTriggerDescription());
try {
setIcon(mZenMode.getIcon(mContext).get(200, TimeUnit.MILLISECONDS));
} catch (Exception e) {
// no icon
}
FutureUtil.whenDone(
mZenMode.getIcon(IconLoader.getInstance(mContext)),
icon -> setIcon(icon),
mContext.getMainExecutor());
}
}

View File

@@ -41,7 +41,6 @@ import com.android.settings.R;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@@ -104,7 +103,6 @@ class ZenModesBackend {
ZenMode getMode(String id) {
ZenModeConfig currentConfig = mNotificationManager.getZenModeConfig();
if (ZenMode.MANUAL_DND_MODE_ID.equals(id)) {
// Regardless of its contents, non-null manualRule means that manual rule is active.
return getManualDndMode(currentConfig);
} else {
AutomaticZenRule rule = mNotificationManager.getAutomaticZenRule(id);
@@ -177,8 +175,9 @@ class ZenModesBackend {
.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY)
.build();
// Regardless of its contents, non-null manualRule means that manual rule is active.
return ZenMode.manualDndMode(manualDndRule,
config != null && config.manualRule != null); // isActive
config != null && config.manualRule != null);
}
private static boolean isRuleActive(String id, ZenModeConfig config) {

View File

@@ -0,0 +1,89 @@
/*
* 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.app.NotificationManager.INTERRUPTION_FILTER_PRIORITY;
import static com.google.common.truth.Truth.assertThat;
import android.app.AutomaticZenRule;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.service.notification.ZenPolicy;
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.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
@RunWith(RobolectricTestRunner.class)
public class IconLoaderTest {
private IconLoader mLoader;
@Before
public void setUp() {
mLoader = new IconLoader(RuntimeEnvironment.application,
MoreExecutors.newDirectExecutorService());
}
@Test
public void getIcon_systemOwnedRuleWithIcon_loads() throws Exception {
AutomaticZenRule systemRule = newRuleBuilder()
.setPackage("android")
.setIconResId(android.R.drawable.ic_media_play)
.build();
ListenableFuture<Drawable> loadFuture = mLoader.getIcon(systemRule);
assertThat(loadFuture.isDone()).isTrue();
assertThat(loadFuture.get()).isNotNull();
}
@Test
public void getIcon_ruleWithoutSpecificIcon_loadsFallback() throws Exception {
AutomaticZenRule rule = newRuleBuilder()
.setType(AutomaticZenRule.TYPE_DRIVING)
.setPackage("com.blah")
.build();
ListenableFuture<Drawable> loadFuture = mLoader.getIcon(rule);
assertThat(loadFuture.isDone()).isTrue();
assertThat(loadFuture.get()).isNotNull();
}
@Test
public void getIcon_ruleWithAppIconWithLoadFailure_loadsFallback() throws Exception {
AutomaticZenRule rule = newRuleBuilder()
.setType(AutomaticZenRule.TYPE_DRIVING)
.setPackage("com.blah")
.setIconResId(-123456)
.build();
ListenableFuture<Drawable> loadFuture = mLoader.getIcon(rule);
assertThat(loadFuture.get()).isNotNull();
}
private static AutomaticZenRule.Builder newRuleBuilder() {
return new AutomaticZenRule.Builder("Driving", Uri.parse("drive"))
.setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY)
.setZenPolicy(new ZenPolicy.Builder().build());
}
}