Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 828654cc authored by TreeHugger Robot's avatar TreeHugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Create a new icon cache mechanism for memory improvement"

parents 07cfb556 eefcdeef
Loading
Loading
Loading
Loading
+112 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.settingslib.applications;

import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.UserHandle;
import android.util.Log;
import android.util.LruCache;

/**
 * Cache app icon for management.
 */
public class AppIconCacheManager {
    private static final String TAG = "AppIconCacheManager";
    private static final float CACHE_RATIO = 0.1f;
    private static final int MAX_CACHE_SIZE_IN_KB = getMaxCacheInKb();
    private static final String DELIMITER = ":";
    private static AppIconCacheManager sAppIconCacheManager;
    private final LruCache<String, Drawable> mDrawableCache;

    private AppIconCacheManager() {
        mDrawableCache = new LruCache<String, Drawable>(MAX_CACHE_SIZE_IN_KB) {
            @Override
            protected int sizeOf(String key, Drawable drawable) {
                if (drawable instanceof BitmapDrawable) {
                    return ((BitmapDrawable) drawable).getBitmap().getByteCount() / 1024;
                }
                // Rough estimate each pixel will use 4 bytes by default.
                return drawable.getIntrinsicHeight() * drawable.getIntrinsicWidth() * 4 / 1024;
            }
        };
    }

    /**
     * Get an {@link AppIconCacheManager} instance.
     */
    public static synchronized AppIconCacheManager getInstance() {
        if (sAppIconCacheManager == null) {
            sAppIconCacheManager = new AppIconCacheManager();
        }
        return sAppIconCacheManager;
    }

    /**
     * Put app icon to cache
     *
     * @param packageName of icon
     * @param uid         of packageName
     * @param drawable    app icon
     */
    public void put(String packageName, int uid, Drawable drawable) {
        final String key = getKey(packageName, uid);
        if (key == null || drawable == null || drawable.getIntrinsicHeight() < 0
                || drawable.getIntrinsicWidth() < 0) {
            Log.w(TAG, "Invalid key or drawable.");
            return;
        }
        mDrawableCache.put(key, drawable);
    }

    /**
     * Get app icon from cache.
     *
     * @param packageName of icon
     * @param uid         of packageName
     * @return app icon
     */
    public Drawable get(String packageName, int uid) {
        final String key = getKey(packageName, uid);
        if (key == null) {
            Log.w(TAG, "Invalid key with package or uid.");
            return null;
        }
        final Drawable cachedDrawable = mDrawableCache.get(key);
        return cachedDrawable != null ? cachedDrawable.mutate() : null;
    }

    /**
     * Release cache.
     */
    public static void release() {
        if (sAppIconCacheManager != null) {
            sAppIconCacheManager.mDrawableCache.evictAll();
        }
    }

    private static String getKey(String packageName, int uid) {
        if (packageName == null || uid < 0) {
            return null;
        }
        return packageName + DELIMITER + UserHandle.getUserId(uid);
    }

    private static int getMaxCacheInKb() {
        return Math.round(CACHE_RATIO * Runtime.getRuntime().maxMemory() / 1024);
    }
}
+81 −0
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.drawable.Drawable;
import android.hardware.usb.IUsbManager;
import android.net.Uri;
import android.os.Environment;
@@ -35,7 +36,9 @@ import android.text.TextUtils;
import android.util.Log;

import com.android.settingslib.R;
import com.android.settingslib.Utils;
import com.android.settingslib.applications.instantapps.InstantAppDataProvider;
import com.android.settingslib.utils.ThreadUtils;

import java.util.ArrayList;
import java.util.List;
@@ -212,4 +215,82 @@ public class AppUtils {
                        UserHandle.myUserId());
        return TextUtils.equals(packageName, defaultBrowserPackage);
    }

    /**
     * Get the app icon by app entry.
     *
     * @param context  caller's context
     * @param appEntry AppEntry of ApplicationsState
     * @return app icon of the app entry
     */
    public static Drawable getIcon(Context context, ApplicationsState.AppEntry appEntry) {
        if (appEntry == null || appEntry.info == null) {
            return null;
        }

        final AppIconCacheManager appIconCacheManager = AppIconCacheManager.getInstance();
        final String packageName = appEntry.info.packageName;
        final int uid = appEntry.info.uid;

        Drawable icon = appIconCacheManager.get(packageName, uid);
        if (icon == null) {
            if (appEntry.apkFile != null && appEntry.apkFile.exists()) {
                icon = Utils.getBadgedIcon(context, appEntry.info);
                appIconCacheManager.put(packageName, uid, icon);
            } else {
                setAppEntryMounted(appEntry, /* mounted= */ false);
                icon = context.getDrawable(
                        com.android.internal.R.drawable.sym_app_on_sd_unavailable_icon);
            }
        } else if (!appEntry.mounted && appEntry.apkFile != null && appEntry.apkFile.exists()) {
            // If the app wasn't mounted but is now mounted, reload its icon.
            setAppEntryMounted(appEntry, /* mounted= */ true);
            icon = Utils.getBadgedIcon(context, appEntry.info);
            appIconCacheManager.put(packageName, uid, icon);
        }

        return icon;
    }

    /**
     * Get the app icon from cache by app entry.
     *
     * @param appEntry AppEntry of ApplicationsState
     * @return app icon of the app entry
     */
    public static Drawable getIconFromCache(ApplicationsState.AppEntry appEntry) {
        return appEntry == null || appEntry.info == null ? null
                : AppIconCacheManager.getInstance().get(
                        appEntry.info.packageName,
                        appEntry.info.uid);
    }

    /**
     * Preload the top N icons of app entry list.
     *
     * @param context caller's context
     * @param appEntries AppEntry list of ApplicationsState
     * @param number the number of Top N icons of the appEntries
     */
    public static void preloadTopIcons(Context context,
            ArrayList<ApplicationsState.AppEntry> appEntries, int number) {
        if (appEntries == null || appEntries.isEmpty() || number <= 0) {
            return;
        }

        for (int i = 0; i < Math.min(appEntries.size(), number); i++) {
            final ApplicationsState.AppEntry entry = appEntries.get(i);
            ThreadUtils.postOnBackgroundThread(() -> {
                getIcon(context, entry);
            });
        }
    }

    private static void setAppEntryMounted(ApplicationsState.AppEntry appEntry, boolean mounted) {
        if (appEntry.mounted != mounted) {
            synchronized (appEntry) {
                appEntry.mounted = mounted;
            }
        }
    }
}
+36 −10
Original line number Diff line number Diff line
@@ -95,6 +95,7 @@ public class ApplicationsState {
    private static final Object sLock = new Object();
    private static final Pattern REMOVE_DIACRITICALS_PATTERN
            = Pattern.compile("\\p{InCombiningDiacriticalMarks}+");
    private static final String SETTING_PKG = "com.android.settings";

    @VisibleForTesting
    static ApplicationsState sInstance;
@@ -492,6 +493,9 @@ public class ApplicationsState {
        return null;
    }

    /**
     * Starting Android T, this method will not be used if {@link AppIconCacheManager} is applied.
     */
    public void ensureIcon(AppEntry entry) {
        if (entry.icon != null) {
            return;
@@ -758,6 +762,10 @@ public class ApplicationsState {
        return null;
    }

    private static boolean isAppIconCacheEnabled(Context context) {
        return SETTING_PKG.equals(context.getPackageName());
    }

    void rebuildActiveSessions() {
        synchronized (mEntriesMap) {
            if (!mSessionsChanged) {
@@ -806,6 +814,11 @@ public class ApplicationsState {
            } else {
                mHasLifecycle = false;
            }

            if (isAppIconCacheEnabled(mContext)) {
                // Skip the preloading all icons step to save memory usage.
                mFlags = mFlags & ~FLAG_SESSION_REQUEST_ICONS;
            }
        }

        @SessionFlags
@@ -814,8 +827,13 @@ public class ApplicationsState {
        }

        public void setSessionFlags(@SessionFlags int flags) {
            if (isAppIconCacheEnabled(mContext)) {
                // Skip the preloading all icons step to save memory usage.
                mFlags = flags & ~FLAG_SESSION_REQUEST_ICONS;
            } else {
                mFlags = flags;
            }
        }

        @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
        public void onResume() {
@@ -1576,6 +1594,10 @@ public class ApplicationsState {

        // Need to synchronize on 'this' for the following.
        public ApplicationInfo info;
        /**
         * Starting Android T, this field will not be used if {@link AppIconCacheManager} is
         * applied.
         */
        public Drawable icon;
        public String sizeStr;
        public String internalSizeStr;
@@ -1596,15 +1618,11 @@ public class ApplicationsState {
            this.size = SIZE_UNKNOWN;
            this.sizeStale = true;
            ensureLabel(context);
            // Speed up the cache of the icon and label description if they haven't been created.
            ThreadUtils.postOnBackgroundThread(() -> {
                if (this.icon == null) {
                    this.ensureIconLocked(context);
                }
            // Speed up the cache of the label description if they haven't been created.
            if (this.labelDescription == null) {
                    this.ensureLabelDescriptionLocked(context);
                ThreadUtils.postOnBackgroundThread(
                        () -> this.ensureLabelDescriptionLocked(context));
            }
            });
        }

        public void ensureLabel(Context context) {
@@ -1620,7 +1638,15 @@ public class ApplicationsState {
            }
        }

        /**
         * Starting Android T, this method will not be used if {@link AppIconCacheManager} is
         * applied.
         */
        boolean ensureIconLocked(Context context) {
            if (isAppIconCacheEnabled(context)) {
                return false;
            }

            if (this.icon == null) {
                if (this.apkFile.exists()) {
                    this.icon = Utils.getBadgedIcon(context, info);
+109 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.settingslib.applications;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.Mockito.doReturn;

import android.graphics.drawable.Drawable;

import org.junit.After;
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;

@RunWith(RobolectricTestRunner.class)
public class AppIconCacheManagerTest {

    private static final String APP_PACKAGE_NAME = "com.test.app";
    private static final int APP_UID = 9999;

    @Mock
    private Drawable mIcon;

    private AppIconCacheManager mAppIconCacheManager;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        mAppIconCacheManager = AppIconCacheManager.getInstance();
        doReturn(10).when(mIcon).getIntrinsicHeight();
        doReturn(10).when(mIcon).getIntrinsicWidth();
        doReturn(mIcon).when(mIcon).mutate();
    }

    @After
    public void tearDown() {
        AppIconCacheManager.release();
    }

    @Test
    public void get_invalidPackageOrUid_shouldReturnNull() {
        assertThat(mAppIconCacheManager.get(/* packageName= */ null, /* uid= */ -1)).isNull();
    }

    @Test
    public void put_invalidPackageOrUid_shouldNotCrash() {
        mAppIconCacheManager.put(/* packageName= */ null, /* uid= */ 0, mIcon);
        // no crash
    }

    @Test
    public void put_invalidIcon_shouldNotCacheIcon() {
        mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, /* drawable= */ null);

        assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isNull();
    }

    @Test
    public void put_invalidIconSize_shouldNotCacheIcon() {
        doReturn(-1).when(mIcon).getIntrinsicHeight();
        doReturn(-1).when(mIcon).getIntrinsicWidth();

        mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, mIcon);

        assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isNull();
    }

    @Test
    public void put_shouldCacheIcon() {
        mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, mIcon);

        assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isEqualTo(mIcon);
    }

    @Test
    public void release_noInstance_shouldNotCrash() {
        mAppIconCacheManager = null;

        AppIconCacheManager.release();
        // no crash
    }

    @Test
    public void release_existInstance_shouldClearCache() {
        mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, mIcon);

        AppIconCacheManager.release();

        assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isNull();
    }
}
+167 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.settingslib.applications;

import static com.google.common.truth.Truth.assertThat;

import static org.junit.Assert.fail;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;

import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.graphics.drawable.Drawable;

import com.android.settingslib.Utils;

import org.junit.After;
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 org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;

import java.io.File;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;

@RunWith(RobolectricTestRunner.class)
public class AppUtilsTest {

    private static final String APP_PACKAGE_NAME = "com.test.app";
    private static final int APP_UID = 9999;

    @Mock
    private Drawable mIcon;

    private Context mContext;
    private AppIconCacheManager mAppIconCacheManager;
    private ApplicationInfo mAppInfo;
    private ApplicationsState.AppEntry mAppEntry;
    private ArrayList<ApplicationsState.AppEntry> mAppEntries;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        mContext = RuntimeEnvironment.application;
        mAppIconCacheManager = AppIconCacheManager.getInstance();
        mAppInfo = createApplicationInfo(APP_PACKAGE_NAME, APP_UID);
        mAppEntry = createAppEntry(mAppInfo, /* id= */ 1);
        mAppEntries = new ArrayList<>(Arrays.asList(mAppEntry));
        doReturn(mIcon).when(mIcon).mutate();
    }

    @After
    public void tearDown() {
        AppIconCacheManager.release();
    }

    @Test
    public void getIcon_nullAppEntry_shouldReturnNull() {
        assertThat(AppUtils.getIcon(mContext, /* appEntry= */ null)).isNull();
    }

    @Test
    @Config(shadows = ShadowUtils.class)
    public void getIcon_noCachedIcon_shouldNotReturnNull() {
        assertThat(AppUtils.getIcon(mContext, mAppEntry)).isNotNull();
    }

    @Test
    public void getIcon_existCachedIcon_shouldReturnCachedIcon() {
        mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, mIcon);

        assertThat(AppUtils.getIcon(mContext, mAppEntry)).isEqualTo(mIcon);
    }

    @Test
    public void getIconFromCache_nullAppEntry_shouldReturnNull() {
        assertThat(AppUtils.getIconFromCache(/* appEntry= */ null)).isNull();
    }

    @Test
    public void getIconFromCache_shouldReturnCachedIcon() {
        mAppIconCacheManager.put(APP_PACKAGE_NAME, APP_UID, mIcon);

        assertThat(AppUtils.getIconFromCache(mAppEntry)).isEqualTo(mIcon);
    }

    @Test
    public void preloadTopIcons_nullAppEntries_shouldNotCrash() {
        AppUtils.preloadTopIcons(mContext, /* appEntries= */ null, /* number= */ 1);
        // no crash
    }

    @Test
    public void preloadTopIcons_zeroPreloadIcons_shouldNotCacheIcons() {
        AppUtils.preloadTopIcons(mContext, mAppEntries, /* number= */ 0);

        assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isNull();
    }

    @Test
    @Config(shadows = ShadowUtils.class)
    public void preloadTopIcons_shouldCheckIconFromCache() throws InterruptedException {
        AppUtils.preloadTopIcons(mContext, mAppEntries, /* number= */ 1);

        TimeUnit.SECONDS.sleep(1);
        assertThat(mAppIconCacheManager.get(APP_PACKAGE_NAME, APP_UID)).isNotNull();
    }

    private ApplicationsState.AppEntry createAppEntry(ApplicationInfo appInfo, int id) {
        ApplicationsState.AppEntry appEntry = new ApplicationsState.AppEntry(mContext, appInfo, id);
        appEntry.label = "label";
        appEntry.mounted = true;
        final File apkFile = mock(File.class);
        doReturn(true).when(apkFile).exists();
        try {
            Field field = ApplicationsState.AppEntry.class.getDeclaredField("apkFile");
            field.setAccessible(true);
            field.set(appEntry, apkFile);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            fail("Not able to mock apkFile: " + e);
        }
        return appEntry;
    }

    private ApplicationInfo createApplicationInfo(String packageName, int uid) {
        ApplicationInfo appInfo = new ApplicationInfo();
        appInfo.sourceDir = "appPath";
        appInfo.packageName = packageName;
        appInfo.uid = uid;
        return appInfo;
    }

    @Implements(Utils.class)
    private static class ShadowUtils {
        @Implementation
        public static Drawable getBadgedIcon(Context context, ApplicationInfo appInfo) {
            final Drawable icon = mock(Drawable.class);
            doReturn(10).when(icon).getIntrinsicHeight();
            doReturn(10).when(icon).getIntrinsicWidth();
            doReturn(icon).when(icon).mutate();
            return icon;
        }
    }
}
Loading