blob: 41d30c72990d69ac3bc6f976cc654f86b9ac4ad4 [file] [log] [blame]
/*
* 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.animation.LayoutTransition;
import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.ActivityOptions;
import android.app.AppGlobals;
import android.content.BroadcastReceiver;
import android.content.ClipData;
import android.content.ClipDescription;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ApplicationInfo;
import android.content.pm.ActivityInfo;
import android.content.pm.IPackageManager;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Rect;
import android.os.Bundle;
import android.os.RemoteException;
import android.os.UserHandle;
import android.os.UserManager;
import android.util.AttributeSet;
import android.util.Log;
import android.util.Slog;
import android.view.DragEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.Toast;
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
* trigger a drag to allow the icons to be reordered. As an icon is dragged the other icons shift
* to make space for it to be dropped. These layout changes are animated.
*/
class NavigationBarApps extends LinearLayout {
private final static boolean DEBUG = false;
private final static String TAG = "NavigationBarApps";
/**
* Intent extra to store user serial number.
*/
static final String EXTRA_PROFILE = "profile";
// There are separate NavigationBarApps view instances for landscape vs. portrait, but they
// share the data model.
private static NavigationBarAppsModel sAppsModel;
private final PackageManager mPackageManager;
private final UserManager mUserManager;
private final LayoutInflater mLayoutInflater;
// This view has two roles:
// 1) If the drag started outside the pinned apps list, it is a placeholder icon with a null
// data model entry.
// 2) If the drag started inside the pinned apps list, it is the icon for the app being dragged
// with the associated data model entry.
// The icon is set invisible for the duration of the drag, creating a visual space for a drop.
// When the user is not dragging this member is null.
private View mDragView;
private long mCurrentUserSerialNumber = -1;
private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (Intent.ACTION_USER_SWITCHED.equals(action)) {
int currentUserId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1);
onUserSwitched(currentUserId);
}
}
};
public NavigationBarApps(Context context, AttributeSet attrs) {
super(context, attrs);
if (sAppsModel == null) {
sAppsModel = new NavigationBarAppsModel(context);
}
mPackageManager = context.getPackageManager();
mUserManager = (UserManager) getContext().getSystemService(Context.USER_SERVICE);
mLayoutInflater = LayoutInflater.from(context);
// Dragging an icon removes and adds back the dragged icon. Use the layout transitions to
// trigger animation. By default all transitions animate, so turn off the unneeded ones.
LayoutTransition transition = new LayoutTransition();
// Don't trigger on disappear. Adding the view will trigger the layout animation.
transition.disableTransitionType(LayoutTransition.DISAPPEARING);
// Don't animate the dragged icon itself.
transition.disableTransitionType(LayoutTransition.APPEARING);
// When an icon is dragged off the shelf, start sliding the other icons over immediately
// to match the parent view's animation.
transition.setStartDelay(LayoutTransition.CHANGE_DISAPPEARING, 0);
transition.setStagger(LayoutTransition.CHANGE_DISAPPEARING, 0);
setLayoutTransition(transition);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
// When an icon is dragged out of the pinned area this view's width changes, which causes
// the parent container's layout to change and the divider and recents icons to shift left.
// Animate the parent's CHANGING transition.
ViewGroup parent = (ViewGroup) getParent();
LayoutTransition transition = new LayoutTransition();
transition.disableTransitionType(LayoutTransition.APPEARING);
transition.disableTransitionType(LayoutTransition.DISAPPEARING);
transition.disableTransitionType(LayoutTransition.CHANGE_APPEARING);
transition.disableTransitionType(LayoutTransition.CHANGE_DISAPPEARING);
transition.enableTransitionType(LayoutTransition.CHANGING);
parent.setLayoutTransition(transition);
mCurrentUserSerialNumber = mUserManager.getSerialNumberForUser(
new UserHandle(ActivityManager.getCurrentUser()));
sAppsModel.setCurrentUser(mCurrentUserSerialNumber);
recreateAppButtons();
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_USER_SWITCHED);
mContext.registerReceiver(mBroadcastReceiver, filter);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mContext.unregisterReceiver(mBroadcastReceiver);
}
/**
* Creates an ImageView icon for each pinned app. Removes any existing icons. May be called
* to synchronize the current view with the shared data mode.
*/
public void recreateAppButtons() {
// Remove any existing icon buttons.
removeAllViews();
int appCount = sAppsModel.getAppCount();
for (int i = 0; i < appCount; i++) {
ImageView button = createAppButton();
addView(button);
AppInfo app = sAppsModel.getApp(i);
CharSequence appLabel = getAppLabel(mPackageManager, app.getComponentName());
button.setContentDescription(appLabel);
// Load the icon asynchronously.
new GetActivityIconTask(mPackageManager, button).execute(app.getComponentName());
}
}
/**
* Creates a new ImageView for a launcher activity, inflated from
* R.layout.navigation_bar_app_item.
*/
private ImageView createAppButton() {
ImageView button = (ImageView) mLayoutInflater.inflate(
R.layout.navigation_bar_app_item, this, false /* attachToRoot */);
button.setOnClickListener(new AppClickListener());
// TODO: Ripple effect. Use either KeyButtonRipple or the default ripple background.
button.setOnLongClickListener(new AppLongClickListener());
button.setOnDragListener(new AppIconDragListener());
return button;
}
// Not shared with NavigationBarRecents because the data model is specific to pinned apps.
private class AppLongClickListener implements View.OnLongClickListener {
@Override
public boolean onLongClick(View v) {
mDragView = v;
ImageView icon = (ImageView) v;
AppInfo app = sAppsModel.getApp(indexOfChild(v));
startAppDrag(icon, app);
return true;
}
}
/**
* Returns the human-readable name for an activity's package or null.
* TODO: Cache the labels, perhaps in an LruCache.
*/
@Nullable
static CharSequence getAppLabel(PackageManager packageManager,
ComponentName activityName) {
String packageName = activityName.getPackageName();
ApplicationInfo info;
try {
info = packageManager.getApplicationInfo(packageName, 0x0 /* flags */);
} catch (PackageManager.NameNotFoundException e) {
Slog.w(TAG, "Package not found " + packageName);
return null;
}
return packageManager.getApplicationLabel(info);
}
/** Helper function to start dragging an app icon (either pinned or recent). */
static void startAppDrag(ImageView icon, AppInfo appInfo) {
// The drag data is an Intent to launch the activity.
Intent mainIntent = Intent.makeMainActivity(appInfo.getComponentName());
long userSerialNumber = appInfo.getUserSerialNumber();
if (userSerialNumber != AppInfo.USER_UNSPECIFIED) {
mainIntent.putExtra(EXTRA_PROFILE, userSerialNumber);
}
ClipData dragData = ClipData.newIntent("", mainIntent);
// Use the ImageView to create the shadow.
View.DragShadowBuilder shadow = new AppIconDragShadowBuilder(icon);
// Use a global drag because the icon might be dragged into the launcher.
icon.startDrag(dragData, shadow, null /* myLocalState */, View.DRAG_FLAG_GLOBAL);
}
@Override
public boolean dispatchDragEvent(DragEvent event) {
// ACTION_DRAG_ENTERED is handled by each individual app icon drag listener.
boolean childHandled = super.dispatchDragEvent(event);
// Other drag types are handled once per drag by this view. This is handled explicitly
// because attaching a DragListener to this ViewGroup does not work -- the DragListener in
// the children consumes the drag events.
boolean handled = false;
switch (event.getAction()) {
case DragEvent.ACTION_DRAG_STARTED:
handled = onDragStarted(event);
break;
case DragEvent.ACTION_DRAG_ENDED:
handled = onDragEnded();
break;
case DragEvent.ACTION_DROP:
handled = onDrop(event);
break;
case DragEvent.ACTION_DRAG_EXITED:
handled = onDragExited();
break;
}
return handled || childHandled;
}
/** Returns true if a drag should be handled. */
private static boolean canAcceptDrag(DragEvent event) {
// Poorly behaved apps might not provide a clip description.
if (event.getClipDescription() == null) {
return false;
}
// The event must contain an intent.
return event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_INTENT);
}
/**
* Sets up for a drag. Runs once per drag operation. Returns true if the data represents
* an app shortcut and will be accepted for a drop.
*/
private boolean onDragStarted(DragEvent event) {
if (DEBUG) Slog.d(TAG, "onDragStarted");
// Ensure that an app shortcut is being dragged.
if (!canAcceptDrag(event)) {
return false;
}
// If there are no pinned apps this view will be collapsed, but the user still needs some
// empty space to use as a drag target.
if (getChildCount() == 0) {
mDragView = createPlaceholderDragView(0);
}
// If this is an existing icon being reordered, hide the app icon. The drag shadow will
// continue to draw.
if (mDragView != null) {
mDragView.setVisibility(View.INVISIBLE);
}
// Listen for the drag end event.
return true;
}
/**
* Creates a blank icon-sized View to create an empty space during a drag. Also creates a data
* model entry so the rest of the code can assume it is reordering existing entries.
*/
private ImageView createPlaceholderDragView(int index) {
ImageView button = createAppButton();
addView(button, index);
sAppsModel.addApp(index, null /* name */);
return button;
}
/**
* Handles a drag entering an existing icon. Not implemented in the drag listener because it
* needs to use LinearLayout/ViewGroup methods.
*/
private void onDragEnteredIcon(View target) {
if (DEBUG) Slog.d(TAG, "onDragEntered " + indexOfChild(target));
// If the drag didn't start from an existing icon, add an invisible placeholder to create
// empty space for the user to drag into.
if (mDragView == null) {
int placeholderIndex = indexOfChild(target);
mDragView = createPlaceholderDragView(placeholderIndex);
return;
}
// If the user is dragging on top of the original icon location, do nothing.
if (target == mDragView) {
return;
}
// "Move" the dragged app by removing it and adding it back at the target location.
int dragViewIndex = indexOfChild(mDragView);
int targetIndex = indexOfChild(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);
// Update the data model.
AppInfo app = sAppsModel.removeApp(dragViewIndex);
sAppsModel.addApp(targetIndex, app);
}
private boolean onDrop(DragEvent event) {
if (DEBUG) Slog.d(TAG, "onDrop");
// An earlier drag event might have canceled the drag. If so, there is nothing to do.
if (mDragView == null) {
return true;
}
// If this was an existing app being dragged then end the drag.
int dragViewIndex = indexOfChild(mDragView);
if (sAppsModel.getApp(dragViewIndex) != null) {
endDrag();
return true;
}
// The drag view was a placeholder. Unpack the drop.
AppInfo appInfo = getAppFromDragEvent(event);
if (appInfo == null) {
// This wasn't a valid drop. Clean up the placeholder and model.
removePlaceholderDragViewIfNeeded();
return false;
}
// The drop had valid data. Update the placeholder with a real activity and icon.
updateAppAt(dragViewIndex, appInfo);
endDrag();
return true;
}
/** Cleans up at the end of a drag. */
private void endDrag() {
mDragView.setVisibility(View.VISIBLE);
mDragView = null;
sAppsModel.savePrefs();
}
/** Returns an app info from a DragEvent, or null if the data wasn't valid. */
private AppInfo getAppFromDragEvent(DragEvent event) {
ClipData data = event.getClipData();
if (data == null) {
return null;
}
if (data.getItemCount() != 1) {
return null;
}
Intent intent = data.getItemAt(0).getIntent();
if (intent == null) {
return null;
}
long userSerialNumber = intent.getLongExtra(EXTRA_PROFILE, AppInfo.USER_UNSPECIFIED);
return new AppInfo(intent.getComponent(), userSerialNumber);
}
/** Updates the app at a given view index. */
private void updateAppAt(int index, AppInfo appInfo) {
sAppsModel.setApp(index, appInfo);
ImageView button = (ImageView) getChildAt(index);
new GetActivityIconTask(mPackageManager, button).execute(appInfo.getComponentName());
}
/** Removes the empty placeholder view and cleans up the data model. */
private void removePlaceholderDragViewIfNeeded() {
// If the drag has ended already there is nothing to do.
if (mDragView == null) {
return;
}
int index = indexOfChild(mDragView);
removeViewAt(index);
sAppsModel.removeApp(index);
endDrag();
}
/** Cleans up at the end of the drag. */
private boolean onDragEnded() {
if (DEBUG) Slog.d(TAG, "onDragEnded");
// If the icon wasn't already dropped into the app list then remove the placeholder.
removePlaceholderDragViewIfNeeded();
return true;
}
/** Handles the dragged icon exiting the bounds of this view during the drag. */
private boolean onDragExited() {
if (DEBUG) Slog.d(TAG, "onDragExited");
// Remove the placeholder. It will be added again if the user drags the icon back over
// the shelf.
removePlaceholderDragViewIfNeeded();
return true;
}
/** Drag listener for individual app icons. */
private class AppIconDragListener implements View.OnDragListener {
@Override
public boolean onDrag(View v, DragEvent event) {
switch (event.getAction()) {
case DragEvent.ACTION_DRAG_STARTED: {
// Every button listens for drag events in order to detect enter/exit.
return canAcceptDrag(event);
}
case DragEvent.ACTION_DRAG_ENTERED: {
// Forward to NavigationBarApps.
onDragEnteredIcon(v);
return false;
}
}
return false;
}
}
/**
* A click listener that launches an activity.
*/
private class AppClickListener implements View.OnClickListener {
@Override
public void onClick(View v) {
AppInfo appInfo = sAppsModel.getApp(indexOfChild(v));
ComponentName component = appInfo.getComponentName();
long appUserSerialNumber = appInfo.getUserSerialNumber();
UserHandle appUser = null;
if (appUserSerialNumber != AppInfo.USER_UNSPECIFIED) {
appUser = mUserManager.getUserForSerialNumber(appUserSerialNumber);
}
int appUserId;
if (appUser != null) {
appUserId = appUser.getIdentifier();
} else {
appUserId = ActivityManager.getCurrentUser();
appUser = new UserHandle(appUserId);
}
// Play a scale-up animation while launching the activity.
// TODO: Consider playing a different animation, or no animation, if the activity is
// already open in a visible window. In that case we should move the task to front
// with minimal animation, perhaps using ActivityManager.moveTaskToFront().
Rect sourceBounds = new Rect();
v.getBoundsOnScreen(sourceBounds);
ActivityOptions opts =
ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.getWidth(), v.getHeight());
Bundle optsBundle = opts.toBundle();
// Launch the activity. This code is based on LauncherAppsService.startActivityAsUser code.
Intent launchIntent = new Intent(Intent.ACTION_MAIN);
launchIntent.addCategory(Intent.CATEGORY_LAUNCHER);
launchIntent.setSourceBounds(sourceBounds);
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
launchIntent.setPackage(component.getPackageName());
IPackageManager pm = AppGlobals.getPackageManager();
try {
ActivityInfo info = pm.getActivityInfo(component, 0, appUserId);
if (info == null) {
Toast.makeText(getContext(), R.string.activity_not_found, Toast.LENGTH_SHORT).show();
Log.e(TAG, "Can't start activity " + component + " because it's not installed.");
return;
}
if (!info.exported) {
Toast.makeText(getContext(), R.string.activity_not_found, Toast.LENGTH_SHORT).show();
Log.e(TAG, "Can't start activity " + component + " because it doesn't have 'exported' attribute.");
return;
}
} catch (RemoteException e) {
Toast.makeText(getContext(), R.string.activity_not_found, Toast.LENGTH_SHORT).show();
Log.e(TAG, "Failed to get activity info for " + component, e);
return;
}
// Check that the component actually has Intent.CATEGORY_LAUCNCHER
// as calling startActivityAsUser ignores the category and just
// resolves based on the component if present.
List<ResolveInfo> apps = getContext().getPackageManager().queryIntentActivitiesAsUser(launchIntent,
0 /* flags */, appUserId);
final int size = apps.size();
for (int i = 0; i < size; ++i) {
ActivityInfo activityInfo = apps.get(i).activityInfo;
if (activityInfo.packageName.equals(component.getPackageName()) &&
activityInfo.name.equals(component.getClassName())) {
// Found an activity with category launcher that matches
// this component so ok to launch.
launchIntent.setComponent(component);
mContext.startActivityAsUser(launchIntent, optsBundle, appUser);
return;
}
}
Toast.makeText(getContext(), R.string.activity_not_found, Toast.LENGTH_SHORT).show();
Log.e(TAG, "Attempt to launch activity without category Intent.CATEGORY_LAUNCHER " + component);
}
}
private void onUserSwitched(int currentUserId) {
final long newUserSerialNumber =
mUserManager.getSerialNumberForUser(new UserHandle(currentUserId));
if (newUserSerialNumber != mCurrentUserSerialNumber) {
mCurrentUserSerialNumber = newUserSerialNumber;
sAppsModel.setCurrentUser(newUserSerialNumber);
recreateAppButtons();
}
}
}