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

Commit bd8b8171 authored by James Cook's avatar James Cook
Browse files

Persist navbar app icon list to disk

The icon list is stored in SharedPreferences under
com.android.systemui.navbarapps.

* Introduced a separate data model class for the navbar app list
* All icons are now loaded from PackageManager, not from LauncherApps
* Added test for NavigationBarAppsModel

Bug: 20024603
Change-Id: I0eb3b9e927d3311818096cfd484d6f5a81acbbc7
parent 4a067fb9
Loading
Loading
Loading
Loading
+64 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2015 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.systemui.statusbar.phone;

import android.content.ComponentName;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.util.Slog;
import android.view.View;
import android.widget.ImageView;

/**
 * Retrieves the icon for an activity and sets it as the Drawable on an ImageView. The ImageView
 * is hidden if the activity isn't recognized or if there is no icon.
 */
class GetActivityIconTask extends AsyncTask<ComponentName, Void, Drawable> {
    private final static String TAG = "GetActivityIconTask";

    private final PackageManager mPackageManager;

    // The ImageView that will receive the icon.
    private final ImageView mImageView;

    public GetActivityIconTask(PackageManager packageManager, ImageView imageView) {
        mPackageManager = packageManager;
        mImageView = imageView;
    }

    @Override
    protected Drawable doInBackground(ComponentName... params) {
        if (params.length != 1) {
            throw new IllegalArgumentException("Expected one parameter");
        }
        ComponentName activityName = params[0];
        try {
            return mPackageManager.getActivityIcon(activityName);
        } catch (NameNotFoundException e) {
            Slog.w(TAG, "Icon not found for " + activityName);
            return null;
        }
    }

    @Override
    protected void onPostExecute(Drawable icon) {
        mImageView.setImageDrawable(icon);
        mImageView.setVisibility(icon != null ? View.VISIBLE : View.GONE);
    }
}
+39 −55
Original line number Diff line number Diff line
@@ -17,18 +17,16 @@
package com.android.systemui.statusbar.phone;

import android.animation.LayoutTransition;
import android.app.ActivityManager;
import android.app.ActivityOptions;
import android.content.ClipData;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.LauncherActivityInfo;
import android.content.pm.LauncherApps;
import android.content.pm.PackageManager;
import android.graphics.Canvas;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.UserHandle;
import android.util.AttributeSet;
@@ -42,8 +40,6 @@ import android.widget.LinearLayout;

import com.android.systemui.R;

import java.util.List;

/**
 * Container for application icons that appear in the navigation bar. Their appearance is similar
 * to the launcher hotseat. Clicking an icon launches the associated activity. A long click will
@@ -54,11 +50,9 @@ class NavigationBarApps extends LinearLayout {
    private final static boolean DEBUG = false;
    private final static String TAG = "NavigationBarApps";

    // The number of apps to show for demo purposes.
    // TODO: Remove this when the user can explicitly add and remove apps.
    private final static int NUM_APPS = 4;

    private final NavigationBarAppsModel mAppsModel;
    private final LauncherApps mLauncherApps;
    private final PackageManager mPackageManager;
    private final LayoutInflater mLayoutInflater;

    // The view being dragged, or null if the user is not dragging.
@@ -66,7 +60,9 @@ class NavigationBarApps extends LinearLayout {

    public NavigationBarApps(Context context, AttributeSet attrs) {
        super(context, attrs);
        mAppsModel = new NavigationBarAppsModel(context);
        mLauncherApps = (LauncherApps) context.getSystemService("launcherapps");
        mPackageManager = context.getPackageManager();
        mLayoutInflater = LayoutInflater.from(context);

        // Dragging an icon removes and adds back the dragged icon. Use the layout transitions to
@@ -88,11 +84,12 @@ class NavigationBarApps extends LinearLayout {

    /** Creates an ImageView for each pinned app. */
    private void createAppButtons() {
        // For demo purposes, just initialize with the first few apps from the owner profile.
        List<LauncherActivityInfo> apps = mLauncherApps.getActivityList(null, UserHandle.OWNER);
        int appCount = apps.size();
        for (int i = 0; i < NUM_APPS && i < appCount; i++) {
            ImageView button = createAppButton(apps.get(i));
        // Load the saved icons, if any.
        mAppsModel.initialize();

        int appCount = mAppsModel.getAppCount();
        for (int i = 0; i < appCount; i++) {
            ImageView button = createAppButton(mAppsModel.getApp(i));
            // TODO: remove padding from leftmost button.
            addView(button);
        }
@@ -102,40 +99,18 @@ class NavigationBarApps extends LinearLayout {
     * Creates a new ImageView for a launcher activity, inflated from
     * R.layout.navigation_bar_app_item.
     */
    private ImageView createAppButton(LauncherActivityInfo info) {
    private ImageView createAppButton(ComponentName activityName) {
        ImageView button = (ImageView) mLayoutInflater.inflate(
                R.layout.navigation_bar_app_item, this, false /* attachToRoot */);
        button.setOnClickListener(new AppClickListener(info.getComponentName()));
        button.setOnClickListener(new AppClickListener(activityName));
        // TODO: Ripple effect. Use either KeyButtonRipple or the default ripple background.
        button.setOnLongClickListener(new AppLongClickListener());
        button.setOnDragListener(new AppDragListener());
        // Load the icon asynchronously, as it may need to create a bitmap for badging.
        new GetBadgedIconTask(button).execute(info);
        // Load the icon asynchronously.
        new GetActivityIconTask(mPackageManager, button).execute(activityName);
        return button;
    }

    /** Loads a badged icon for an ImageView as a background task. */
    private static class GetBadgedIconTask extends AsyncTask<LauncherActivityInfo, Void, Drawable> {
        // The ImageView that will receive the icon.
        private final ImageView mButton;

        public GetBadgedIconTask(ImageView button) {
            mButton = button;
        }

        @Override
        protected Drawable doInBackground(LauncherActivityInfo... params) {
            // TODO: Should this request a particular resolution and scale it down?
            return params[0].getBadgedIcon(0 /* no particular density */);
        }

        @Override
        protected void onPostExecute(Drawable icon) {
            mButton.setImageDrawable(icon);
            mButton.setVisibility(View.VISIBLE);
        }
    }

    /** Starts a drag on long-click. */
    private class AppLongClickListener implements View.OnLongClickListener {
        @Override
@@ -195,16 +170,24 @@ class NavigationBarApps extends LinearLayout {

        // "Move" the dragged app by removing it and adding it back at the target location.
        int dragViewIndex = indexOfChild(mDragView);
        removeView(mDragView);
        // Removing the drag view above may change the index of the target view, so read it here.
        int targetIndex = indexOfChild(target);
        if (targetIndex < dragViewIndex) {
            // The dragged app is moving left. Add the app before the target.
        // This works, but is subtle:
        // * If dragViewIndex > targetIndex then the dragged app is moving from right to left and
        //   the dragged app will be added in front of the target.
        // * If dragViewIndex < targetIndex then the dragged app is moving from left to right.
        //   Removing the drag view will shift the later views one position to the left. Adding
        //   the view at targetIndex will therefore place the app *after* the target.
        removeView(mDragView);
        addView(mDragView, targetIndex);
        } else {
            // The dragged app is moving right. Add the app after the target.
            addView(mDragView, targetIndex + 1);

        // Update the data model.
        ComponentName app = mAppsModel.removeApp(dragViewIndex);
        mAppsModel.addApp(targetIndex, app);
    }

    private void onDrop() {
        // Persist the state of the reordered icons.
        mAppsModel.savePrefs();
    }

    /** Drag listener for app icons. */
@@ -235,8 +218,7 @@ class NavigationBarApps extends LinearLayout {
                    return false;
                }
                case DragEvent.ACTION_DROP: {
                    if (DEBUG) Log.d(TAG, "onDrop " + viewIndexInParent(v));
                    // TODO: Persist the state of the reordered icons.
                    onDrop();
                    return false;
                }
                case DragEvent.ACTION_DRAG_ENDED: {
@@ -262,10 +244,10 @@ class NavigationBarApps extends LinearLayout {
     * A click listener that launches an activity.
     */
    private class AppClickListener implements View.OnClickListener {
        private final ComponentName mComponentName;
        private final ComponentName mActivityName;

        public AppClickListener(ComponentName componentName) {
            mComponentName = componentName;
        public AppClickListener(ComponentName activityName) {
            mActivityName = activityName;
        }

        @Override
@@ -283,7 +265,9 @@ class NavigationBarApps extends LinearLayout {
            ActivityOptions opts =
                    ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.getWidth(), v.getHeight());
            Bundle optsBundle = opts.toBundle();
            mLauncherApps.startMainActivity(mComponentName, user, sourceBounds, optsBundle);

            // Launch the activity.
            mLauncherApps.startMainActivity(mActivityName, user, sourceBounds, optsBundle);
        }
    }
}
+161 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2015 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.systemui.statusbar.phone;

import android.content.ComponentName;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.LauncherActivityInfo;
import android.content.pm.LauncherApps;
import android.os.UserHandle;
import android.util.Slog;

import com.android.internal.annotations.VisibleForTesting;

import java.util.ArrayList;
import java.util.List;

/**
 * Data model and controller for app icons appearing in the navigation bar. The data is stored on
 * disk in SharedPreferences. Each icon has a separate pref entry consisting of a flattened
 * ComponentName.
 */
class NavigationBarAppsModel {
    private final static String TAG = "NavigationBarAppsModel";

    // Default number of apps to load initially.
    private final static int NUM_INITIAL_APPS = 4;

    // Preferences file name.
    private final static String SHARED_PREFERENCES_NAME = "com.android.systemui.navbarapps";

    // Preference name for the version of the other preferences.
    private final static String VERSION_PREF = "version";

    // Current version number for preferences.
    private final static int CURRENT_VERSION = 1;

    // Preference name for the number of app icons.
    private final static String APP_COUNT_PREF = "app_count";

    // Preference name prefix for each app's info. The actual pref has an integer appended to it.
    private final static String APP_PREF_PREFIX = "app_";

    private final LauncherApps mLauncherApps;
    private final SharedPreferences mPrefs;

    // Apps are represented as an ordered list of component names.
    private final List<ComponentName> mApps = new ArrayList<ComponentName>();

    public NavigationBarAppsModel(Context context) {
        mLauncherApps = (LauncherApps) context.getSystemService("launcherapps");
        mPrefs = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
    }

    @VisibleForTesting
    NavigationBarAppsModel(LauncherApps launcherApps, SharedPreferences prefs) {
        mLauncherApps = launcherApps;
        mPrefs = prefs;
    }

    /**
     * Initializes the model with a list of apps, either by loading it off disk or by supplying
     * a default list.
     */
    public void initialize() {
        if (mApps.size() > 0) {
            Slog.e(TAG, "Model already initialized");
            return;
        }

        // Check for an existing list of apps.
        int version = mPrefs.getInt(VERSION_PREF, -1);
        if (version == CURRENT_VERSION) {
            loadAppsFromPrefs();
        } else {
            addDefaultApps();
        }
    }

    /** Returns the number of apps. */
    public int getAppCount() {
        return mApps.size();
    }

    /** Returns the app at the given index. */
    public ComponentName getApp(int index) {
        return mApps.get(index);
    }

    /** Adds the app before the given index. */
    public void addApp(int index, ComponentName name) {
        mApps.add(index, name);
    }

    /** Remove the app at the given index. */
    public ComponentName removeApp(int index) {
        return mApps.remove(index);
    }

    /** Saves the current model to disk. */
    public void savePrefs() {
        SharedPreferences.Editor edit = mPrefs.edit();
        // The user might have removed icons, so clear all the old prefs.
        edit.clear();
        edit.putInt(VERSION_PREF, CURRENT_VERSION);
        int appCount = mApps.size();
        edit.putInt(APP_COUNT_PREF, appCount);
        for (int i = 0; i < appCount; i++) {
            String componentNameString = mApps.get(i).flattenToString();
            edit.putString(prefNameForApp(i), componentNameString);
        }
        // Start an asynchronous disk write.
        edit.apply();
    }

    /** Loads the list of apps from SharedPreferences. */
    private void loadAppsFromPrefs() {
        int appCount = mPrefs.getInt(APP_COUNT_PREF, -1);
        for (int i = 0; i < appCount; i++) {
            String prefValue = mPrefs.getString(prefNameForApp(i), null);
            if (prefValue == null) {
                Slog.w(TAG, "Couldn't find pref " + prefNameForApp(i));
                // Couldn't find the saved state. Just skip this item.
                continue;
            }
            ComponentName componentName = ComponentName.unflattenFromString(prefValue);
            mApps.add(componentName);
        }
    }

    /** Adds the first few apps from the owner profile. Used for demo purposes. */
    private void addDefaultApps() {
        // Get a list of all app activities.
        List<LauncherActivityInfo> apps =
                mLauncherApps.getActivityList(null /* packageName */, UserHandle.OWNER);
        int appCount = apps.size();
        for (int i = 0; i < NUM_INITIAL_APPS && i < appCount; i++) {
            LauncherActivityInfo activityInfo = apps.get(i);
            mApps.add(activityInfo.getComponentName());
        }
    }

    /** Returns the pref name for the app at a given index. */
    private static String prefNameForApp(int index) {
        return APP_PREF_PREFIX + Integer.toString(index);
    }
}
+1 −28
Original line number Diff line number Diff line
@@ -114,7 +114,7 @@ class NavigationBarRecents extends LinearLayout {
        }

        // Load the activity icon on a background thread.
        new GetActivityIconTask(button).execute(component);
        new GetActivityIconTask(mPackageManager, button).execute(component);

        final Intent baseIntent = task.baseIntent;
        button.setOnClickListener(new View.OnClickListener() {
@@ -131,33 +131,6 @@ class NavigationBarRecents extends LinearLayout {
        button.setVisibility(View.GONE);
    }

    /**
     * Retrieves the icon for an activity and sets it as the Drawable on an ImageView. The ImageView
     * is hidden if the activity isn't recognized or if there is no icon.
     */
    private class GetActivityIconTask extends AsyncTask<ComponentName, Void, Drawable> {
        private final ImageView mButton;  // The ImageView that will receive the icon.

        public GetActivityIconTask(ImageView button) {
            mButton = button;
        }

        @Override
        protected Drawable doInBackground(ComponentName... params) {
            try {
                return mPackageManager.getActivityIcon(params[0]);
            } catch (NameNotFoundException e) {
                return null;
            }
        }

        @Override
        protected void onPostExecute(Drawable icon) {
            mButton.setImageDrawable(icon);
            mButton.setVisibility(icon != null ? View.VISIBLE : View.GONE);
        }
    }

    /**
     * A listener that updates the app buttons whenever the recents task stack changes.
     * NOTE: This is not the right way to do this.
+134 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2015 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.systemui.statusbar.phone;

import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.content.ComponentName;
import android.content.SharedPreferences;
import android.content.pm.LauncherActivityInfo;
import android.content.pm.LauncherApps;
import android.os.UserHandle;
import android.test.AndroidTestCase;

import java.util.ArrayList;
import java.util.List;

/** Tests for the data model for the navigation bar app icons. */
public class NavigationBarAppsModelTest extends AndroidTestCase {
    private LauncherApps mMockLauncherApps;
    private SharedPreferences mMockPrefs;

    private NavigationBarAppsModel mModel;

    @Override
    protected void setUp() throws Exception {
        super.setUp();

        // Mockito setup boilerplate.
        System.setProperty("dexmaker.dexcache", mContext.getCacheDir().getPath());
        Thread.currentThread().setContextClassLoader(getClass().getClassLoader());

        mMockLauncherApps = mock(LauncherApps.class);
        mMockPrefs = mock(SharedPreferences.class);
        mModel = new NavigationBarAppsModel(mMockLauncherApps, mMockPrefs);
    }

    /** Initializes the model from SharedPreferences for a few app activites. */
    private void initializeModelFromPrefs() {
        // Assume the version pref is present.
        when(mMockPrefs.getInt("version", -1)).thenReturn(1);

        // Assume several apps are stored.
        when(mMockPrefs.getInt("app_count", -1)).thenReturn(3);
        when(mMockPrefs.getString("app_0", null)).thenReturn("package0/class0");
        when(mMockPrefs.getString("app_1", null)).thenReturn("package1/class1");
        when(mMockPrefs.getString("app_2", null)).thenReturn("package2/class2");

        mModel.initialize();
    }

    /** Tests initializing the model from SharedPreferences. */
    public void testInitializeFromPrefs() {
        initializeModelFromPrefs();
        assertEquals(3, mModel.getAppCount());
        assertEquals("package0/class0", mModel.getApp(0).flattenToString());
        assertEquals("package1/class1", mModel.getApp(1).flattenToString());
        assertEquals("package2/class2", mModel.getApp(2).flattenToString());
    }

    /** Tests initializing the model when the SharedPreferences aren't available. */
    public void testInitializeDefaultApps() {
        // Assume the version pref isn't available.
        when(mMockPrefs.getInt("version", -1)).thenReturn(-1);

        // Assume some installed activities.
        LauncherActivityInfo activity1 = mock(LauncherActivityInfo.class);
        when(activity1.getComponentName()).thenReturn(new ComponentName("package1", "class1"));
        LauncherActivityInfo activity2 = mock(LauncherActivityInfo.class);
        when(activity2.getComponentName()).thenReturn(new ComponentName("package2", "class2"));
        List<LauncherActivityInfo> apps = new ArrayList<LauncherActivityInfo>();
        apps.add(activity1);
        apps.add(activity2);
        when(mMockLauncherApps.getActivityList(anyString(), any(UserHandle.class)))
                .thenReturn(apps);

        // Initializing the model should load the installed activities.
        mModel.initialize();
        assertEquals(2, mModel.getAppCount());
        assertEquals("package1/class1", mModel.getApp(0).flattenToString());
        assertEquals("package2/class2", mModel.getApp(1).flattenToString());
    }

    /** Tests initializing the model if one of the prefs is missing. */
    public void testInitializeWithMissingPref() {
        // Assume the version pref is present.
        when(mMockPrefs.getInt("version", -1)).thenReturn(1);

        // Assume two apps are nominally stored.
        when(mMockPrefs.getInt("app_count", -1)).thenReturn(2);
        when(mMockPrefs.getString("app_0", null)).thenReturn("package0/class0");

        // But assume one pref is missing.
        when(mMockPrefs.getString("app_1", null)).thenReturn(null);

        // Initializing the model should load from prefs and skip the missing one.
        mModel.initialize();
        assertEquals(1, mModel.getAppCount());
        assertEquals("package0/class0", mModel.getApp(0).flattenToString());
    }

    /** Tests saving the model to SharedPreferences. */
    public void testSavePrefs() {
        initializeModelFromPrefs();

        SharedPreferences.Editor mockEdit = mock(SharedPreferences.Editor.class);
        when(mMockPrefs.edit()).thenReturn(mockEdit);

        mModel.savePrefs();
        verify(mockEdit).clear();  // Old prefs were removed.
        verify(mockEdit).putInt("version", 1);
        verify(mockEdit).putInt("app_count", 3);
        verify(mockEdit).putString("app_0", "package0/class0");
        verify(mockEdit).putString("app_1", "package1/class1");
        verify(mockEdit).putString("app_2", "package2/class2");
    }
}