diff --git a/src/com/android/settings/notification/modes/FutureUtil.java b/src/com/android/settings/notification/modes/FutureUtil.java new file mode 100644 index 00000000000..e7bf8b9a75f --- /dev/null +++ b/src/com/android/settings/notification/modes/FutureUtil.java @@ -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 void whenDone(ListenableFuture future, Consumer consumer, Executor executor) { + whenDone(future, consumer, executor, "Error in future"); + } + + static void whenDone(ListenableFuture future, Consumer consumer, Executor executor, + String errorLogMessage, Object... errorLogMessageArgs) { + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(V v) { + consumer.accept(v); + } + + @Override + public void onFailure(Throwable throwable) { + Log.e(TAG, String.format(errorLogMessage, errorLogMessageArgs), throwable); + } + }, executor); + } +} diff --git a/src/com/android/settings/notification/modes/IconLoader.java b/src/com/android/settings/notification/modes/IconLoader.java new file mode 100644 index 00000000000..b7a6c9526ac --- /dev/null +++ b/src/com/android/settings/notification/modes/IconLoader.java @@ -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 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 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 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; + } +} diff --git a/src/com/android/settings/notification/modes/ZenMode.java b/src/com/android/settings/notification/modes/ZenMode.java index 51c92e6eae5..ca9bec5b7c0 100644 --- a/src/com/android/settings/notification/modes/ZenMode.java +++ b/src/com/android/settings/notification/modes/ZenMode.java @@ -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 getIcon(@NonNull Context context) { - // TODO: b/333528586 - Load the icons asynchronously, and cache them + public ListenableFuture 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 diff --git a/src/com/android/settings/notification/modes/ZenModeHeaderController.java b/src/com/android/settings/notification/modes/ZenModeHeaderController.java index 246eee85d77..f55c02d7daa 100644 --- a/src/com/android/settings/notification/modes/ZenModeHeaderController.java +++ b/src/com/android/settings/notification/modes/ZenModeHeaderController.java @@ -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()); } } diff --git a/src/com/android/settings/notification/modes/ZenModeListPreference.java b/src/com/android/settings/notification/modes/ZenModeListPreference.java index 2a95dffc5e0..0f4728f05de 100644 --- a/src/com/android/settings/notification/modes/ZenModeListPreference.java +++ b/src/com/android/settings/notification/modes/ZenModeListPreference.java @@ -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()); } } diff --git a/src/com/android/settings/notification/modes/ZenModesBackend.java b/src/com/android/settings/notification/modes/ZenModesBackend.java index 355adb46198..29e4b837769 100644 --- a/src/com/android/settings/notification/modes/ZenModesBackend.java +++ b/src/com/android/settings/notification/modes/ZenModesBackend.java @@ -33,7 +33,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; @@ -94,7 +93,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); @@ -119,8 +117,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) { diff --git a/tests/robotests/src/com/android/settings/notification/modes/IconLoaderTest.java b/tests/robotests/src/com/android/settings/notification/modes/IconLoaderTest.java new file mode 100644 index 00000000000..a92e6187aef --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/modes/IconLoaderTest.java @@ -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 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 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 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()); + } +}