From 7f3c2dc240ea1f8af2c40893c6885c447fa9e7a6 Mon Sep 17 00:00:00 2001 From: Andras Kloczl Date: Wed, 22 Jul 2020 11:44:41 +0100 Subject: [PATCH] Add user name photo dialog to user creation - Remove unused method switchTo - Move edit user controllers from settings module - Create new activity for new user creation - Add ActivityStarter for delegating activity starting - Add UserCreator for user creation logic - Add UserCreatingDialog to display while user is creating Test: manual test && atest EditUserInfoControllerTest Doc: http://shortn/_cJE9o6pBZR Screenrecords: http://shortn/_VEN6rr5ta6 Bug: 147653252 Change-Id: I6a7f1dba499e95891c69569ab731f86e6fd1dc16 --- .../layout/edit_user_info_dialog_content.xml | 51 ++ .../res/layout/restricted_popup_menu_item.xml | 47 ++ .../layout/user_creation_progress_dialog.xml | 27 + packages/SettingsLib/res/values/dimens.xml | 4 + packages/SettingsLib/res/values/strings.xml | 12 + .../settingslib/users/ActivityStarter.java | 31 ++ .../users/EditUserInfoController.java | 219 ++++++++ .../users/EditUserPhotoController.java | 509 ++++++++++++++++++ .../users/PhotoCapabilityUtils.java | 76 +++ .../settingslib/users/UserCreatingDialog.java | 58 ++ .../users/EditUserInfoControllerTest.java | 270 ++++++++++ packages/SystemUI/AndroidManifest.xml | 8 + .../res/layout/activity_create_new_user.xml | 23 + packages/SystemUI/res/values/styles.xml | 7 + packages/SystemUI/res/xml/fileprovider.xml | 1 + .../dagger/DefaultActivityBinder.java | 7 + .../systemui/dagger/SystemUIModule.java | 2 + .../systemui/qs/tiles/UserDetailView.java | 2 +- .../policy/KeyguardUserSwitcher.java | 2 +- .../policy/UserSwitcherController.java | 67 ++- .../systemui/user/CreateUserActivity.java | 161 ++++++ .../android/systemui/user/UserCreator.java | 82 +++ .../com/android/systemui/user/UserModule.java | 36 ++ 23 files changed, 1671 insertions(+), 31 deletions(-) create mode 100644 packages/SettingsLib/res/layout/edit_user_info_dialog_content.xml create mode 100644 packages/SettingsLib/res/layout/restricted_popup_menu_item.xml create mode 100644 packages/SettingsLib/res/layout/user_creation_progress_dialog.xml create mode 100644 packages/SettingsLib/src/com/android/settingslib/users/ActivityStarter.java create mode 100644 packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java create mode 100644 packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java create mode 100644 packages/SettingsLib/src/com/android/settingslib/users/PhotoCapabilityUtils.java create mode 100644 packages/SettingsLib/src/com/android/settingslib/users/UserCreatingDialog.java create mode 100644 packages/SettingsLib/tests/robotests/src/com/android/settingslib/users/EditUserInfoControllerTest.java create mode 100644 packages/SystemUI/res/layout/activity_create_new_user.xml create mode 100644 packages/SystemUI/src/com/android/systemui/user/CreateUserActivity.java create mode 100644 packages/SystemUI/src/com/android/systemui/user/UserCreator.java create mode 100644 packages/SystemUI/src/com/android/systemui/user/UserModule.java diff --git a/packages/SettingsLib/res/layout/edit_user_info_dialog_content.xml b/packages/SettingsLib/res/layout/edit_user_info_dialog_content.xml new file mode 100644 index 0000000000000..f66ff007fb900 --- /dev/null +++ b/packages/SettingsLib/res/layout/edit_user_info_dialog_content.xml @@ -0,0 +1,51 @@ + + + + + + + + diff --git a/packages/SettingsLib/res/layout/restricted_popup_menu_item.xml b/packages/SettingsLib/res/layout/restricted_popup_menu_item.xml new file mode 100644 index 0000000000000..923d022aea686 --- /dev/null +++ b/packages/SettingsLib/res/layout/restricted_popup_menu_item.xml @@ -0,0 +1,47 @@ + + + + + + + + + + diff --git a/packages/SettingsLib/res/layout/user_creation_progress_dialog.xml b/packages/SettingsLib/res/layout/user_creation_progress_dialog.xml new file mode 100644 index 0000000000000..fe09aaca0b1f2 --- /dev/null +++ b/packages/SettingsLib/res/layout/user_creation_progress_dialog.xml @@ -0,0 +1,27 @@ + + + + diff --git a/packages/SettingsLib/res/values/dimens.xml b/packages/SettingsLib/res/values/dimens.xml index e552d78e1a453..ef4b97f7743fa 100644 --- a/packages/SettingsLib/res/values/dimens.xml +++ b/packages/SettingsLib/res/values/dimens.xml @@ -97,4 +97,8 @@ 18dp + + + 300dp + diff --git a/packages/SettingsLib/res/values/strings.xml b/packages/SettingsLib/res/values/strings.xml index 03161d0513429..6a4c8c301cbe4 100644 --- a/packages/SettingsLib/res/values/strings.xml +++ b/packages/SettingsLib/res/values/strings.xml @@ -1354,6 +1354,11 @@ Set lock Switch to %s + + Creating new user… + + + Nickname Add guest @@ -1362,6 +1367,13 @@ Guest + + Take a photo + + Choose an image + + Select photo + Device default diff --git a/packages/SettingsLib/src/com/android/settingslib/users/ActivityStarter.java b/packages/SettingsLib/src/com/android/settingslib/users/ActivityStarter.java new file mode 100644 index 0000000000000..081b5075ebbed --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/users/ActivityStarter.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2020 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.users; + +import android.content.Intent; + +/** + * An interface to start activities for result. This is used as a callback from controllers where + * activity starting isn't possible but we want to keep the intent building logic there. + */ +public interface ActivityStarter { + + /** + * Launch an activity for which you would like a result when it finished. + */ + void startActivityForResult(Intent intent, int requestCode); +} diff --git a/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java b/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java new file mode 100644 index 0000000000000..58599532d9cb7 --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2013 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.users; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; +import android.widget.EditText; +import android.widget.ImageView; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.android.internal.util.UserIcons; +import com.android.settingslib.R; +import com.android.settingslib.drawable.CircleFramedDrawable; + +import java.io.File; +import java.util.function.BiConsumer; + +/** + * This class encapsulates a Dialog for editing the user nickname and photo. + */ +public class EditUserInfoController { + + private static final String KEY_AWAITING_RESULT = "awaiting_result"; + private static final String KEY_SAVED_PHOTO = "pending_photo"; + + private Dialog mEditUserInfoDialog; + private Bitmap mSavedPhoto; + private EditUserPhotoController mEditUserPhotoController; + private boolean mWaitingForActivityResult = false; + private final String mFileAuthority; + + public EditUserInfoController(String fileAuthority) { + mFileAuthority = fileAuthority; + } + + private void clear() { + if (mEditUserPhotoController != null) { + mEditUserPhotoController.removeNewUserPhotoBitmapFile(); + } + mEditUserInfoDialog = null; + mSavedPhoto = null; + } + + /** + * This should be called when the container activity/fragment got re-initialized from a + * previously saved state. + */ + public void onRestoreInstanceState(Bundle icicle) { + String pendingPhoto = icicle.getString(KEY_SAVED_PHOTO); + if (pendingPhoto != null) { + mSavedPhoto = EditUserPhotoController.loadNewUserPhotoBitmap(new File(pendingPhoto)); + } + mWaitingForActivityResult = icicle.getBoolean(KEY_AWAITING_RESULT, false); + } + + /** + * Should be called from the container activity/fragment when it's onSaveInstanceState is + * called. + */ + public void onSaveInstanceState(Bundle outState) { + if (mEditUserInfoDialog != null && mEditUserPhotoController != null) { + // Bitmap cannot be stored into bundle because it may exceed parcel limit + // Store it in a temporary file instead + File file = mEditUserPhotoController.saveNewUserPhotoBitmap(); + if (file != null) { + outState.putString(KEY_SAVED_PHOTO, file.getPath()); + } + } + outState.putBoolean(KEY_AWAITING_RESULT, mWaitingForActivityResult); + } + + /** + * Should be called from the container activity/fragment when an activity has started for + * take/choose/crop photo actions. + */ + public void startingActivityForResult() { + mWaitingForActivityResult = true; + } + + /** + * Should be called from the container activity/fragment after it receives a result from + * take/choose/crop photo activity. + */ + public void onActivityResult(int requestCode, int resultCode, Intent data) { + mWaitingForActivityResult = false; + + if (mEditUserPhotoController != null && mEditUserInfoDialog != null) { + mEditUserPhotoController.onActivityResult(requestCode, resultCode, data); + } + } + + /** + * Creates a user edit dialog with option to change the user's name and photo. + * + * @param activityStarter - ActivityStarter is called with appropriate intents and request + * codes to take photo/choose photo/crop photo. + */ + public Dialog createDialog(Activity activity, ActivityStarter activityStarter, + @Nullable Drawable oldUserIcon, String defaultUserName, String title, + BiConsumer successCallback, Runnable cancelCallback) { + LayoutInflater inflater = LayoutInflater.from(activity); + View content = inflater.inflate(R.layout.edit_user_info_dialog_content, null); + + EditText userNameView = content.findViewById(R.id.user_name); + userNameView.setText(defaultUserName); + + ImageView userPhotoView = content.findViewById(R.id.user_photo); + + // if oldUserIcon param is null then we use a default gray user icon + Drawable defaultUserIcon = oldUserIcon != null ? oldUserIcon : UserIcons.getDefaultUserIcon( + activity.getResources(), UserHandle.USER_NULL, false); + // in case a new photo was selected and the activity got recreated we have to load the image + Drawable userIcon = getUserIcon(activity, defaultUserIcon); + userPhotoView.setImageDrawable(userIcon); + + if (canChangePhoto(activity)) { + mEditUserPhotoController = createEditUserPhotoController(activity, activityStarter, + userPhotoView); + } else { + // some users can't change their photos so we need to remove suggestive + // background from the photoView + userPhotoView.setBackground(null); + } + + mEditUserInfoDialog = buildDialog(activity, content, userNameView, oldUserIcon, + defaultUserName, title, successCallback, cancelCallback); + + // Make sure the IME is up. + mEditUserInfoDialog.getWindow() + .setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + + return mEditUserInfoDialog; + } + + private Drawable getUserIcon(Activity activity, Drawable defaultUserIcon) { + if (mSavedPhoto != null) { + return CircleFramedDrawable.getInstance(activity, mSavedPhoto); + } + return defaultUserIcon; + } + + private Dialog buildDialog(Activity activity, View content, EditText userNameView, + @Nullable Drawable oldUserIcon, String defaultUserName, String title, + BiConsumer successCallback, Runnable cancelCallback) { + return new AlertDialog.Builder(activity) + .setTitle(title) + .setView(content) + .setCancelable(true) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + Drawable newUserIcon = mEditUserPhotoController != null + ? mEditUserPhotoController.getNewUserPhotoDrawable() + : null; + Drawable userIcon = newUserIcon != null + ? newUserIcon + : oldUserIcon; + + String newName = userNameView.getText().toString().trim(); + String userName = !newName.isEmpty() ? newName : defaultUserName; + + clear(); + if (successCallback != null) { + successCallback.accept(userName, userIcon); + } + }) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + clear(); + if (cancelCallback != null) { + cancelCallback.run(); + } + }) + .setOnCancelListener(dialog -> { + clear(); + if (cancelCallback != null) { + cancelCallback.run(); + } + }) + .create(); + } + + @VisibleForTesting + boolean canChangePhoto(Context context) { + return (PhotoCapabilityUtils.canCropPhoto(context) + && PhotoCapabilityUtils.canChoosePhoto(context)) + || PhotoCapabilityUtils.canTakePhoto(context); + } + + @VisibleForTesting + EditUserPhotoController createEditUserPhotoController(Activity activity, + ActivityStarter activityStarter, ImageView userPhotoView) { + return new EditUserPhotoController(activity, activityStarter, userPhotoView, + mSavedPhoto, mWaitingForActivityResult, mFileAuthority); + } +} diff --git a/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java b/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java new file mode 100644 index 0000000000000..ecd40667843e0 --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java @@ -0,0 +1,509 @@ +/* + * Copyright (C) 2013 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.users; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.StrictMode; +import android.os.UserHandle; +import android.os.UserManager; +import android.provider.ContactsContract.DisplayPhoto; +import android.provider.MediaStore; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.ListPopupWindow; +import android.widget.TextView; + +import androidx.core.content.FileProvider; + +import com.android.settingslib.R; +import com.android.settingslib.RestrictedLockUtils; +import com.android.settingslib.RestrictedLockUtilsInternal; +import com.android.settingslib.drawable.CircleFramedDrawable; + +import libcore.io.Streams; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * This class contains logic for starting activities to take/choose/crop photo, reads and transforms + * the result image. + */ +public class EditUserPhotoController { + private static final String TAG = "EditUserPhotoController"; + + // It seems that this class generates custom request codes and they may + // collide with ours, these values are very unlikely to have a conflict. + private static final int REQUEST_CODE_CHOOSE_PHOTO = 1001; + private static final int REQUEST_CODE_TAKE_PHOTO = 1002; + private static final int REQUEST_CODE_CROP_PHOTO = 1003; + // in rare cases we get a null Cursor when querying for DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI + // so we need a default photo size + private static final int DEFAULT_PHOTO_SIZE = 500; + + private static final String IMAGES_DIR = "multi_user"; + private static final String CROP_PICTURE_FILE_NAME = "CropEditUserPhoto.jpg"; + private static final String TAKE_PICTURE_FILE_NAME = "TakeEditUserPhoto.jpg"; + private static final String NEW_USER_PHOTO_FILE_NAME = "NewUserPhoto.png"; + + private final int mPhotoSize; + + private final Activity mActivity; + private final ActivityStarter mActivityStarter; + private final ImageView mImageView; + private final String mFileAuthority; + + private final File mImagesDir; + private final Uri mCropPictureUri; + private final Uri mTakePictureUri; + + private Bitmap mNewUserPhotoBitmap; + private Drawable mNewUserPhotoDrawable; + + public EditUserPhotoController(Activity activity, ActivityStarter activityStarter, + ImageView view, Bitmap bitmap, boolean waiting, String fileAuthority) { + mActivity = activity; + mActivityStarter = activityStarter; + mImageView = view; + mFileAuthority = fileAuthority; + + mImagesDir = new File(activity.getCacheDir(), IMAGES_DIR); + mImagesDir.mkdir(); + mCropPictureUri = createTempImageUri(activity, CROP_PICTURE_FILE_NAME, !waiting); + mTakePictureUri = createTempImageUri(activity, TAKE_PICTURE_FILE_NAME, !waiting); + mPhotoSize = getPhotoSize(activity); + mImageView.setOnClickListener(v -> showUpdatePhotoPopup()); + mNewUserPhotoBitmap = bitmap; + } + + /** + * Handles activity result from containing activity/fragment after a take/choose/crop photo + * action result is received. + */ + public boolean onActivityResult(int requestCode, int resultCode, Intent data) { + if (resultCode != Activity.RESULT_OK) { + return false; + } + final Uri pictureUri = data != null && data.getData() != null + ? data.getData() : mTakePictureUri; + switch (requestCode) { + case REQUEST_CODE_CROP_PHOTO: + onPhotoCropped(pictureUri); + return true; + case REQUEST_CODE_TAKE_PHOTO: + case REQUEST_CODE_CHOOSE_PHOTO: + if (mTakePictureUri.equals(pictureUri)) { + if (PhotoCapabilityUtils.canCropPhoto(mActivity)) { + cropPhoto(); + } else { + onPhotoNotCropped(pictureUri); + } + } else { + copyAndCropPhoto(pictureUri); + } + return true; + } + return false; + } + + public Drawable getNewUserPhotoDrawable() { + return mNewUserPhotoDrawable; + } + + private void showUpdatePhotoPopup() { + final Context context = mImageView.getContext(); + final boolean canTakePhoto = PhotoCapabilityUtils.canTakePhoto(context); + final boolean canChoosePhoto = PhotoCapabilityUtils.canChoosePhoto(context); + + if (!canTakePhoto && !canChoosePhoto) { + return; + } + + final List items = new ArrayList<>(); + + if (canTakePhoto) { + final String title = context.getString(R.string.user_image_take_photo); + items.add(new RestrictedMenuItem(context, title, UserManager.DISALLOW_SET_USER_ICON, + this::takePhoto)); + } + + if (canChoosePhoto) { + final String title = context.getString(R.string.user_image_choose_photo); + items.add(new RestrictedMenuItem(context, title, UserManager.DISALLOW_SET_USER_ICON, + this::choosePhoto)); + } + + final ListPopupWindow listPopupWindow = new ListPopupWindow(context); + + listPopupWindow.setAnchorView(mImageView); + listPopupWindow.setModal(true); + listPopupWindow.setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED); + listPopupWindow.setAdapter(new RestrictedPopupMenuAdapter(context, items)); + + final int width = Math.max(mImageView.getWidth(), context.getResources() + .getDimensionPixelSize(R.dimen.update_user_photo_popup_min_width)); + listPopupWindow.setWidth(width); + listPopupWindow.setDropDownGravity(Gravity.START); + + listPopupWindow.setOnItemClickListener((parent, view, position, id) -> { + listPopupWindow.dismiss(); + final RestrictedMenuItem item = + (RestrictedMenuItem) parent.getAdapter().getItem(position); + item.doAction(); + }); + + listPopupWindow.show(); + } + + private void takePhoto() { + Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE_SECURE); + appendOutputExtra(intent, mTakePictureUri); + mActivityStarter.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO); + } + + private void choosePhoto() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT, null); + intent.setType("image/*"); + appendOutputExtra(intent, mTakePictureUri); + mActivityStarter.startActivityForResult(intent, REQUEST_CODE_CHOOSE_PHOTO); + } + + private void copyAndCropPhoto(final Uri pictureUri) { + // TODO: Replace AsyncTask + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + final ContentResolver cr = mActivity.getContentResolver(); + try (InputStream in = cr.openInputStream(pictureUri); + OutputStream out = cr.openOutputStream(mTakePictureUri)) { + Streams.copy(in, out); + } catch (IOException e) { + Log.w(TAG, "Failed to copy photo", e); + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + if (!mActivity.isFinishing() && !mActivity.isDestroyed()) { + cropPhoto(); + } + } + }.execute(); + } + + private void cropPhoto() { + // TODO: Use a public intent, when there is one. + Intent intent = new Intent("com.android.camera.action.CROP"); + intent.setDataAndType(mTakePictureUri, "image/*"); + appendOutputExtra(intent, mCropPictureUri); + appendCropExtras(intent); + if (intent.resolveActivity(mActivity.getPackageManager()) != null) { + try { + StrictMode.disableDeathOnFileUriExposure(); + mActivityStarter.startActivityForResult(intent, REQUEST_CODE_CROP_PHOTO); + } finally { + StrictMode.enableDeathOnFileUriExposure(); + } + } else { + onPhotoNotCropped(mTakePictureUri); + } + } + + private void appendOutputExtra(Intent intent, Uri pictureUri) { + intent.putExtra(MediaStore.EXTRA_OUTPUT, pictureUri); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.setClipData(ClipData.newRawUri(MediaStore.EXTRA_OUTPUT, pictureUri)); + } + + private void appendCropExtras(Intent intent) { + intent.putExtra("crop", "true"); + intent.putExtra("scale", true); + intent.putExtra("scaleUpIfNeeded", true); + intent.putExtra("aspectX", 1); + intent.putExtra("aspectY", 1); + intent.putExtra("outputX", mPhotoSize); + intent.putExtra("outputY", mPhotoSize); + } + + private void onPhotoCropped(final Uri data) { + // TODO: Replace AsyncTask to avoid possible memory leaks and handle configuration change + new AsyncTask() { + @Override + protected Bitmap doInBackground(Void... params) { + InputStream imageStream = null; + try { + imageStream = mActivity.getContentResolver() + .openInputStream(data); + return BitmapFactory.decodeStream(imageStream); + } catch (FileNotFoundException fe) { + Log.w(TAG, "Cannot find image file", fe); + return null; + } finally { + if (imageStream != null) { + try { + imageStream.close(); + } catch (IOException ioe) { + Log.w(TAG, "Cannot close image stream", ioe); + } + } + } + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + onPhotoProcessed(bitmap); + + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null); + } + + private void onPhotoNotCropped(final Uri data) { + // TODO: Replace AsyncTask to avoid possible memory leaks and handle configuration change + new AsyncTask() { + @Override + protected Bitmap doInBackground(Void... params) { + // Scale and crop to a square aspect ratio + Bitmap croppedImage = Bitmap.createBitmap(mPhotoSize, mPhotoSize, + Config.ARGB_8888); + Canvas canvas = new Canvas(croppedImage); + Bitmap fullImage; + try { + InputStream imageStream = mActivity.getContentResolver() + .openInputStream(data); + fullImage = BitmapFactory.decodeStream(imageStream); + } catch (FileNotFoundException fe) { + return null; + } + if (fullImage != null) { + int rotation = getRotation(mActivity, data); + final int squareSize = Math.min(fullImage.getWidth(), + fullImage.getHeight()); + final int left = (fullImage.getWidth() - squareSize) / 2; + final int top = (fullImage.getHeight() - squareSize) / 2; + + Matrix matrix = new Matrix(); + RectF rectSource = new RectF(left, top, + left + squareSize, top + squareSize); + RectF rectDest = new RectF(0, 0, mPhotoSize, mPhotoSize); + matrix.setRectToRect(rectSource, rectDest, Matrix.ScaleToFit.CENTER); + matrix.postRotate(rotation, mPhotoSize / 2f, mPhotoSize / 2f); + canvas.drawBitmap(fullImage, matrix, new Paint()); + return croppedImage; + } else { + // Bah! Got nothin. + return null; + } + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + onPhotoProcessed(bitmap); + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null); + } + + /** + * Reads the image's exif data and determines the rotation degree needed to display the image + * in portrait mode. + */ + private int getRotation(Context context, Uri selectedImage) { + int rotation = -1; + try { + InputStream imageStream = context.getContentResolver().openInputStream(selectedImage); + ExifInterface exif = new ExifInterface(imageStream); + rotation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1); + } catch (IOException exception) { + Log.e(TAG, "Error while getting rotation", exception); + } + + switch (rotation) { + case ExifInterface.ORIENTATION_ROTATE_90: + return 90; + case ExifInterface.ORIENTATION_ROTATE_180: + return 180; + case ExifInterface.ORIENTATION_ROTATE_270: + return 270; + default: + return 0; + } + } + + private void onPhotoProcessed(Bitmap bitmap) { + if (bitmap != null) { + mNewUserPhotoBitmap = bitmap; + mNewUserPhotoDrawable = CircleFramedDrawable + .getInstance(mImageView.getContext(), mNewUserPhotoBitmap); + mImageView.setImageDrawable(mNewUserPhotoDrawable); + } + new File(mImagesDir, TAKE_PICTURE_FILE_NAME).delete(); + new File(mImagesDir, CROP_PICTURE_FILE_NAME).delete(); + } + + private static int getPhotoSize(Context context) { + try (Cursor cursor = context.getContentResolver().query( + DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI, + new String[]{DisplayPhoto.DISPLAY_MAX_DIM}, null, null, null)) { + if (cursor != null) { + cursor.moveToFirst(); + return cursor.getInt(0); + } else { + return DEFAULT_PHOTO_SIZE; + } + } + } + + private Uri createTempImageUri(Context context, String fileName, boolean purge) { + final File fullPath = new File(mImagesDir, fileName); + if (purge) { + fullPath.delete(); + } + return FileProvider.getUriForFile(context, mFileAuthority, fullPath); + } + + File saveNewUserPhotoBitmap() { + if (mNewUserPhotoBitmap == null) { + return null; + } + try { + File file = new File(mImagesDir, NEW_USER_PHOTO_FILE_NAME); + OutputStream os = new FileOutputStream(file); + mNewUserPhotoBitmap.compress(Bitmap.CompressFormat.PNG, 100, os); + os.flush(); + os.close(); + return file; + } catch (IOException e) { + Log.e(TAG, "Cannot create temp file", e); + } + return null; + } + + static Bitmap loadNewUserPhotoBitmap(File file) { + return BitmapFactory.decodeFile(file.getAbsolutePath()); + } + + void removeNewUserPhotoBitmapFile() { + new File(mImagesDir, NEW_USER_PHOTO_FILE_NAME).delete(); + } + + private static final class RestrictedMenuItem { + private final Context mContext; + private final String mTitle; + private final Runnable mAction; + private final RestrictedLockUtils.EnforcedAdmin mAdmin; + // Restriction may be set by system or something else via UserManager.setUserRestriction(). + private final boolean mIsRestrictedByBase; + + /** + * The menu item, used for popup menu. Any element of such a menu can be disabled by admin. + * + * @param context A context. + * @param title The title of the menu item. + * @param restriction The restriction, that if is set, blocks the menu item. + * @param action The action on menu item click. + */ + RestrictedMenuItem(Context context, String title, String restriction, + Runnable action) { + mContext = context; + mTitle = title; + mAction = action; + + final int myUserId = UserHandle.myUserId(); + mAdmin = RestrictedLockUtilsInternal.checkIfRestrictionEnforced(context, + restriction, myUserId); + mIsRestrictedByBase = RestrictedLockUtilsInternal.hasBaseUserRestriction(mContext, + restriction, myUserId); + } + + @Override + public String toString() { + return mTitle; + } + + void doAction() { + if (isRestrictedByBase()) { + return; + } + + if (isRestrictedByAdmin()) { + RestrictedLockUtils.sendShowAdminSupportDetailsIntent(mContext, mAdmin); + return; + } + + mAction.run(); + } + + boolean isRestrictedByAdmin() { + return mAdmin != null; + } + + boolean isRestrictedByBase() { + return mIsRestrictedByBase; + } + } + + /** + * Provide this adapter to ListPopupWindow.setAdapter() to have a popup window menu, where + * any element can be restricted by admin (profile owner or device owner). + */ + private static final class RestrictedPopupMenuAdapter extends ArrayAdapter { + RestrictedPopupMenuAdapter(Context context, List items) { + super(context, R.layout.restricted_popup_menu_item, R.id.text, items); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final View view = super.getView(position, convertView, parent); + final RestrictedMenuItem item = getItem(position); + final TextView text = (TextView) view.findViewById(R.id.text); + final ImageView image = (ImageView) view.findViewById(R.id.restricted_icon); + + text.setEnabled(!item.isRestrictedByAdmin() && !item.isRestrictedByBase()); + image.setVisibility(item.isRestrictedByAdmin() && !item.isRestrictedByBase() + ? ImageView.VISIBLE : ImageView.GONE); + + return view; + } + } +} diff --git a/packages/SettingsLib/src/com/android/settingslib/users/PhotoCapabilityUtils.java b/packages/SettingsLib/src/com/android/settingslib/users/PhotoCapabilityUtils.java new file mode 100644 index 0000000000000..165c2808f16d9 --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/users/PhotoCapabilityUtils.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2020 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.users; + +import android.app.KeyguardManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.provider.MediaStore; + +/** + * Utility class that contains helper methods to determine if the current user has permission and + * the device is in a proper state to start an activity for a given action. + */ +public class PhotoCapabilityUtils { + + /** + * Check if the current user can perform any activity for + * android.media.action.IMAGE_CAPTURE action. + */ + public static boolean canTakePhoto(Context context) { + return context.getPackageManager().queryIntentActivities( + new Intent(MediaStore.ACTION_IMAGE_CAPTURE), + PackageManager.MATCH_DEFAULT_ONLY).size() > 0; + } + + /** + * Check if the current user can perform any activity for + * android.intent.action.GET_CONTENT action for images. + * Returns false if the device is currently locked and + * requires a PIN, pattern or password to unlock. + */ + public static boolean canChoosePhoto(Context context) { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("image/*"); + boolean canPerformActivityForGetImage = + context.getPackageManager().queryIntentActivities(intent, 0).size() > 0; + // on locked device we can't access the images + return canPerformActivityForGetImage && !isDeviceLocked(context); + } + + /** + * Check if the current user can perform any activity for + * com.android.camera.action.CROP action for images. + * Returns false if the device is currently locked and + * requires a PIN, pattern or password to unlock. + */ + public static boolean canCropPhoto(Context context) { + Intent intent = new Intent("com.android.camera.action.CROP"); + intent.setType("image/*"); + boolean canPerformActivityForCropping = + context.getPackageManager().queryIntentActivities(intent, 0).size() > 0; + // on locked device we can't start a cropping activity + return canPerformActivityForCropping && !isDeviceLocked(context); + } + + private static boolean isDeviceLocked(Context context) { + KeyguardManager keyguardManager = context.getSystemService(KeyguardManager.class); + return keyguardManager == null || keyguardManager.isDeviceLocked(); + } + +} diff --git a/packages/SettingsLib/src/com/android/settingslib/users/UserCreatingDialog.java b/packages/SettingsLib/src/com/android/settingslib/users/UserCreatingDialog.java new file mode 100644 index 0000000000000..075635c87b1b1 --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/users/UserCreatingDialog.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2020 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.users; + +import android.app.AlertDialog; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; +import android.widget.TextView; + +import com.android.settingslib.R; + +/** + * Dialog to show when a user creation is in progress. + */ +public class UserCreatingDialog extends AlertDialog { + + public UserCreatingDialog(Context context) { + // hardcoding theme to be consistent with UserSwitchingDialog's theme + // todo replace both to adapt to the device's theme + super(context, com.android.internal.R.style.Theme_DeviceDefault_Light_Dialog_Alert); + + inflateContent(); + getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ERROR); + + WindowManager.LayoutParams attrs = getWindow().getAttributes(); + attrs.privateFlags = WindowManager.LayoutParams.PRIVATE_FLAG_SYSTEM_ERROR + | WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS; + getWindow().setAttributes(attrs); + } + + private void inflateContent() { + // using the same design as UserSwitchingDialog + setCancelable(false); + View view = LayoutInflater.from(getContext()) + .inflate(R.layout.user_creation_progress_dialog, null); + String message = getContext().getString(R.string.creating_new_user_dialog_message); + view.setAccessibilityPaneTitle(message); + ((TextView) view.findViewById(R.id.message)).setText(message); + setView(view); + } + +} diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/users/EditUserInfoControllerTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/users/EditUserInfoControllerTest.java new file mode 100644 index 0000000000000..d6c8816ecc585 --- /dev/null +++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/users/EditUserInfoControllerTest.java @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2018 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.users; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.widget.EditText; +import android.widget.ImageView; + +import androidx.fragment.app.FragmentActivity; + +import com.android.settingslib.R; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.android.controller.ActivityController; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowDialog; + +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@RunWith(RobolectricTestRunner.class) +public class EditUserInfoControllerTest { + private static final int MAX_USER_NAME_LENGTH = 100; + + @Mock + private Drawable mCurrentIcon; + @Mock + private ActivityStarter mActivityStarter; + + private boolean mCanChangePhoto; + private Activity mActivity; + private TestEditUserInfoController mController; + + public class TestEditUserInfoController extends EditUserInfoController { + private EditUserPhotoController mPhotoController; + + TestEditUserInfoController() { + super("file_authority"); + } + + private EditUserPhotoController getPhotoController() { + return mPhotoController; + } + + @Override + EditUserPhotoController createEditUserPhotoController(Activity activity, + ActivityStarter activityStarter, ImageView userPhotoView) { + mPhotoController = mock(EditUserPhotoController.class, Answers.RETURNS_DEEP_STUBS); + return mPhotoController; + } + + @Override + boolean canChangePhoto(Context context) { + return mCanChangePhoto; + } + } + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + mActivity = spy(ActivityController.of(new FragmentActivity()).get()); + mActivity.setTheme(R.style.Theme_AppCompat_DayNight); + mController = new TestEditUserInfoController(); + mCanChangePhoto = true; + } + + @Test + public void photoControllerOnActivityResult_whenWaiting_isCalled() { + mController.createDialog(mActivity, mActivityStarter, mCurrentIcon, "test user", + "title", null, null); + mController.startingActivityForResult(); + Intent resultData = new Intent(); + mController.onActivityResult(0, 0, resultData); + EditUserPhotoController photoController = mController.getPhotoController(); + + assertThat(photoController).isNotNull(); + verify(photoController).onActivityResult(0, 0, resultData); + } + + @Test + @Config(shadows = ShadowDialog.class) + public void userNameView_inputLongName_shouldBeConstrained() { + // generate a string of 200 'A's + final String longName = Stream.generate( + () -> String.valueOf('A')).limit(200).collect(Collectors.joining()); + + final AlertDialog dialog = (AlertDialog) mController.createDialog(mActivity, + mActivityStarter, mCurrentIcon, + "test user", "title", null, + null); + dialog.show(); + final EditText userNameEditText = dialog.findViewById(R.id.user_name); + userNameEditText.setText(longName); + + assertThat(userNameEditText.getText().length()).isEqualTo(MAX_USER_NAME_LENGTH); + } + + @Test + public void cancelCallback_isCalled_whenCancelled() { + BiConsumer successCallback = mock(BiConsumer.class); + Runnable cancelCallback = mock(Runnable.class); + + AlertDialog dialog = (AlertDialog) mController.createDialog( + mActivity, mActivityStarter, mCurrentIcon, "test", + "title", successCallback, cancelCallback); + dialog.show(); + dialog.cancel(); + + verifyZeroInteractions(successCallback); + verify(cancelCallback, times(1)) + .run(); + } + + @Test + public void cancelCallback_isCalled_whenNegativeClicked() { + BiConsumer successCallback = mock(BiConsumer.class); + Runnable cancelCallback = mock(Runnable.class); + + AlertDialog dialog = (AlertDialog) mController.createDialog( + mActivity, mActivityStarter, mCurrentIcon, "test", + "title", successCallback, cancelCallback); + dialog.show(); + dialog.getButton(Dialog.BUTTON_NEGATIVE).performClick(); + + verifyZeroInteractions(successCallback); + verify(cancelCallback, times(1)) + .run(); + } + + @Test + public void successCallback_isCalled_whenNothingChanged() { + BiConsumer successCallback = mock(BiConsumer.class); + Runnable cancelCallback = mock(Runnable.class); + + Drawable oldUserIcon = mCurrentIcon; + AlertDialog dialog = (AlertDialog) mController.createDialog( + mActivity, mActivityStarter, oldUserIcon, "test", + "title", successCallback, cancelCallback); + // No change to the photo. + when(mController.getPhotoController().getNewUserPhotoDrawable()).thenReturn(null); + dialog.show(); + dialog.getButton(Dialog.BUTTON_POSITIVE).performClick(); + + verify(successCallback, times(1)) + .accept("test", oldUserIcon); + verifyZeroInteractions(cancelCallback); + } + + @Test + public void successCallback_calledWithNullIcon_whenOldIconIsNullAndNothingChanged() { + BiConsumer successCallback = mock(BiConsumer.class); + Runnable cancelCallback = mock(Runnable.class); + + AlertDialog dialog = (AlertDialog) mController.createDialog( + mActivity, mActivityStarter, null, "test", + "title", successCallback, cancelCallback); + // No change to the photo. + when(mController.getPhotoController().getNewUserPhotoDrawable()).thenReturn(null); + dialog.show(); + dialog.getButton(Dialog.BUTTON_POSITIVE).performClick(); + + verify(successCallback, times(1)) + .accept("test", null); + verifyZeroInteractions(cancelCallback); + } + + @Test + public void successCallback_isCalled_whenLabelChanges() { + BiConsumer successCallback = mock(BiConsumer.class); + Runnable cancelCallback = mock(Runnable.class); + + AlertDialog dialog = (AlertDialog) mController.createDialog( + mActivity, mActivityStarter, mCurrentIcon, "test", + "title", successCallback, cancelCallback); + // No change to the photo. + when(mController.getPhotoController().getNewUserPhotoDrawable()).thenReturn(null); + dialog.show(); + String expectedNewName = "new test user"; + EditText editText = (EditText) dialog.findViewById(R.id.user_name); + editText.setText(expectedNewName); + dialog.getButton(Dialog.BUTTON_POSITIVE).performClick(); + + verify(successCallback, times(1)) + .accept(expectedNewName, mCurrentIcon); + verifyZeroInteractions(cancelCallback); + } + + @Test + public void successCallback_isCalled_whenPhotoChanges() { + BiConsumer successCallback = mock(BiConsumer.class); + Runnable cancelCallback = mock(Runnable.class); + + AlertDialog dialog = (AlertDialog) mController.createDialog( + mActivity, mActivityStarter, mCurrentIcon, "test", + "title", successCallback, cancelCallback); + // A different drawable. + Drawable newPhoto = mock(Drawable.class); + when(mController.getPhotoController().getNewUserPhotoDrawable()).thenReturn(newPhoto); + dialog.show(); + dialog.getButton(Dialog.BUTTON_POSITIVE).performClick(); + + verify(successCallback, times(1)) + .accept("test", newPhoto); + verifyZeroInteractions(cancelCallback); + } + + @Test + public void successCallback_isCalledWithChangedPhoto_whenOldIconIsNullAndPhotoChanges() { + BiConsumer successCallback = mock(BiConsumer.class); + Runnable cancelCallback = mock(Runnable.class); + + AlertDialog dialog = (AlertDialog) mController.createDialog( + mActivity, mActivityStarter, null, "test", + "title", successCallback, cancelCallback); + // A different drawable. + Drawable newPhoto = mock(Drawable.class); + when(mController.getPhotoController().getNewUserPhotoDrawable()).thenReturn(newPhoto); + dialog.show(); + dialog.getButton(Dialog.BUTTON_POSITIVE).performClick(); + + verify(successCallback, times(1)) + .accept("test", newPhoto); + verifyZeroInteractions(cancelCallback); + } + + @Test + public void createDialog_canNotChangePhoto_nullPhotoController() { + mCanChangePhoto = false; + + mController.createDialog(mActivity, mActivityStarter, mCurrentIcon, + "test", "title", null, null); + + assertThat(mController.mPhotoController).isNull(); + } +} diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml index 505ef7a588431..4c9005a5b6bad 100644 --- a/packages/SystemUI/AndroidManifest.xml +++ b/packages/SystemUI/AndroidManifest.xml @@ -606,6 +606,14 @@ + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/values/styles.xml b/packages/SystemUI/res/values/styles.xml index 2b0a963bff185..283918912c76c 100644 --- a/packages/SystemUI/res/values/styles.xml +++ b/packages/SystemUI/res/values/styles.xml @@ -637,6 +637,13 @@ @*android:color/primary_text_material_dark + + diff --git a/packages/SystemUI/res/xml/fileprovider.xml b/packages/SystemUI/res/xml/fileprovider.xml index fa6468fefe048..b67378e638e13 100644 --- a/packages/SystemUI/res/xml/fileprovider.xml +++ b/packages/SystemUI/res/xml/fileprovider.xml @@ -18,4 +18,5 @@ + \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java b/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java index 28bcf3a351177..ba88a599b785e 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/DefaultActivityBinder.java @@ -26,6 +26,7 @@ import com.android.systemui.settings.BrightnessDialog; import com.android.systemui.tuner.TunerActivity; import com.android.systemui.usb.UsbDebuggingActivity; import com.android.systemui.usb.UsbDebuggingSecondaryUserActivity; +import com.android.systemui.user.CreateUserActivity; import dagger.Binds; import dagger.Module; @@ -85,4 +86,10 @@ public abstract class DefaultActivityBinder { @ClassKey(UsbDebuggingSecondaryUserActivity.class) public abstract Activity bindUsbDebuggingSecondaryUserActivity( UsbDebuggingSecondaryUserActivity activity); + + /** Inject into CreateUserActivity. */ + @Binds + @IntoMap + @ClassKey(CreateUserActivity.class) + public abstract Activity bindCreateUserActivity(CreateUserActivity activity); } diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java index 8f4e738e5a5fe..90dc213ce6e6c 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java @@ -43,6 +43,7 @@ import com.android.systemui.statusbar.phone.dagger.StatusBarComponent; import com.android.systemui.statusbar.policy.HeadsUpManager; import com.android.systemui.statusbar.policy.dagger.StatusBarPolicyModule; import com.android.systemui.tuner.dagger.TunerModule; +import com.android.systemui.user.UserModule; import com.android.systemui.util.concurrency.SysUIConcurrencyModule; import com.android.systemui.util.dagger.UtilModule; import com.android.systemui.util.sensors.SensorModule; @@ -76,6 +77,7 @@ import dagger.Provides; StatusBarPolicyModule.class, SysUIConcurrencyModule.class, TunerModule.class, + UserModule.class, UtilModule.class, VolumeModule.class }, diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/UserDetailView.java b/packages/SystemUI/src/com/android/systemui/qs/tiles/UserDetailView.java index 201ed9c9ebec7..8ddd4c9816cd3 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/UserDetailView.java +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/UserDetailView.java @@ -155,7 +155,7 @@ public class UserDetailView extends PseudoGridView { } view.setActivated(true); } - switchTo(tag); + onUserListItemClicked(tag); } } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/KeyguardUserSwitcher.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/KeyguardUserSwitcher.java index f52a6e0191a19..f45178cd22300 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/KeyguardUserSwitcher.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/KeyguardUserSwitcher.java @@ -342,7 +342,7 @@ public class KeyguardUserSwitcher { } v.setActivated(true); } - switchTo(user); + onUserListItemClicked(user); } } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/UserSwitcherController.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/UserSwitcherController.java index 17fcb1dd6f1a9..72e8e38735e9b 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/UserSwitcherController.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/UserSwitcherController.java @@ -23,6 +23,7 @@ import static com.android.systemui.DejankUtils.whitelistIpcs; import android.app.ActivityManager; import android.app.Dialog; +import android.app.IActivityTaskManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.DialogInterface; @@ -53,7 +54,6 @@ import android.widget.BaseAdapter; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.UiEventLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import com.android.internal.util.UserIcons; import com.android.settingslib.RestrictedLockUtilsInternal; import com.android.systemui.Dumpable; import com.android.systemui.GuestResumeSessionReceiver; @@ -69,6 +69,7 @@ import com.android.systemui.plugins.qs.DetailAdapter; import com.android.systemui.qs.QSUserSwitcherEvent; import com.android.systemui.qs.tiles.UserDetailView; import com.android.systemui.statusbar.phone.SystemUIDialog; +import com.android.systemui.user.CreateUserActivity; import java.io.FileDescriptor; import java.io.PrintWriter; @@ -104,6 +105,7 @@ public class UserSwitcherController implements Dumpable { protected final Handler mHandler; private final ActivityStarter mActivityStarter; private final BroadcastDispatcher mBroadcastDispatcher; + private final IActivityTaskManager mActivityTaskManager; private ArrayList mUsers = new ArrayList<>(); private Dialog mExitGuestDialog; @@ -121,9 +123,11 @@ public class UserSwitcherController implements Dumpable { @Inject public UserSwitcherController(Context context, KeyguardStateController keyguardStateController, @Main Handler handler, ActivityStarter activityStarter, - BroadcastDispatcher broadcastDispatcher, UiEventLogger uiEventLogger) { + BroadcastDispatcher broadcastDispatcher, UiEventLogger uiEventLogger, + IActivityTaskManager activityTaskManager) { mContext = context; mBroadcastDispatcher = broadcastDispatcher; + mActivityTaskManager = activityTaskManager; mUiEventLogger = uiEventLogger; if (!UserManager.isGuestUserEphemeral()) { mGuestResumeSessionReceiver.register(mBroadcastDispatcher); @@ -363,7 +367,7 @@ public class UserSwitcherController implements Dumpable { } } - public void switchTo(UserRecord record) { + private void onUserListItemClicked(UserRecord record) { int id; if (record.isGuest && record.info == null) { // No guest user. Create one. @@ -408,19 +412,6 @@ public class UserSwitcherController implements Dumpable { switchToUserId(id); } - public void switchTo(int userId) { - final int count = mUsers.size(); - for (int i = 0; i < count; ++i) { - UserRecord record = mUsers.get(i); - if (record.info != null && record.info.id == userId) { - switchTo(record); - return; - } - } - - Log.e(TAG, "Couldn't switch to user, id=" + userId); - } - protected void switchToUserId(int id) { try { pauseRefreshUsers(); @@ -666,8 +657,11 @@ public class UserSwitcherController implements Dumpable { return position; } - public void switchTo(UserRecord record) { - mController.switchTo(record); + /** + * It handles click events on user list items. + */ + public void onUserListItemClicked(UserRecord record) { + mController.onUserListItemClicked(record); } public String getName(Context context, UserRecord item) { @@ -924,18 +918,33 @@ public class UserSwitcherController implements Dumpable { if (ActivityManager.isUserAMonkey()) { return; } - UserInfo user = mUserManager.createUser( - mContext.getString(R.string.user_new_user_name), 0 /* flags */); - if (user == null) { - // Couldn't create user, most likely because there are too many, but we haven't - // been able to reload the list yet. - return; + Intent intent = CreateUserActivity.createIntentForStart(getContext()); + + // There are some differences between ActivityStarter and ActivityTaskManager in + // terms of how they start an activity. ActivityStarter hides the notification bar + // before starting the activity to make sure nothing is in front of the new + // activity. ActivityStarter also tries to unlock the device if it's locked. + // When locked with PIN/pattern/password then it shows the prompt, if there are no + // security steps then it dismisses the keyguard and then starts the activity. + // ActivityTaskManager doesn't hide the notification bar or unlocks the device, but + // it can start an activity on top of the locked screen. + if (!mKeyguardStateController.isUnlocked() + && !mKeyguardStateController.canDismissLockScreen()) { + // Device is locked and can't be unlocked without a PIN/pattern/password so we + // need to use ActivityTaskManager to start the activity on top of the locked + // screen. + try { + mActivityTaskManager.startActivity(null, + mContext.getBasePackageName(), mContext.getAttributionTag(), intent, + intent.resolveTypeIfNeeded(mContext.getContentResolver()), null, + null, 0, 0, null, null); + } catch (RemoteException e) { + e.printStackTrace(); + Log.e(TAG, "Couldn't start create user activity", e); + } + } else { + mActivityStarter.startActivity(intent, true); } - int id = user.id; - Bitmap icon = UserIcons.convertToBitmap(UserIcons.getDefaultUserIcon( - mContext.getResources(), id, /* light= */ false)); - mUserManager.setUserIcon(id, icon); - switchToUserId(id); } } } diff --git a/packages/SystemUI/src/com/android/systemui/user/CreateUserActivity.java b/packages/SystemUI/src/com/android/systemui/user/CreateUserActivity.java new file mode 100644 index 0000000000000..890ee5f453093 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/CreateUserActivity.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2020 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.user; + +import android.app.Activity; +import android.app.Dialog; +import android.app.IActivityManager; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.RemoteException; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.settingslib.users.EditUserInfoController; +import com.android.systemui.R; + +import javax.inject.Inject; + +/** + * This screen shows a Dialog for choosing nickname and photo for a new user, and then delegates the + * user creation to a UserCreator. + */ +public class CreateUserActivity extends Activity { + + /** + * Creates an intent to start this activity. + */ + public static Intent createIntentForStart(Context context) { + return new Intent(context, CreateUserActivity.class); + } + + private static final String TAG = "CreateUserActivity"; + private static final String DIALOG_STATE_KEY = "create_user_dialog_state"; + + private final UserCreator mUserCreator; + private final EditUserInfoController mEditUserInfoController; + private final IActivityManager mActivityManager; + + private Dialog mSetupUserDialog; + + @Inject + public CreateUserActivity(UserCreator userCreator, + EditUserInfoController editUserInfoController, IActivityManager activityManager) { + mUserCreator = userCreator; + mEditUserInfoController = editUserInfoController; + mActivityManager = activityManager; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setShowWhenLocked(true); + setContentView(R.layout.activity_create_new_user); + + if (savedInstanceState != null) { + mEditUserInfoController.onRestoreInstanceState(savedInstanceState); + } + + mSetupUserDialog = createDialog(); + mSetupUserDialog.show(); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + if (mSetupUserDialog != null && mSetupUserDialog.isShowing()) { + outState.putBundle(DIALOG_STATE_KEY, mSetupUserDialog.onSaveInstanceState()); + } + + mEditUserInfoController.onSaveInstanceState(outState); + super.onSaveInstanceState(outState); + } + + @Override + protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + Bundle savedDialogState = savedInstanceState.getBundle(DIALOG_STATE_KEY); + if (savedDialogState != null && mSetupUserDialog != null) { + mSetupUserDialog.onRestoreInstanceState(savedDialogState); + } + } + + private Dialog createDialog() { + String defaultUserName = getString(com.android.settingslib.R.string.user_new_user_name); + + return mEditUserInfoController.createDialog( + this, + (intent, requestCode) -> { + mEditUserInfoController.startingActivityForResult(); + startActivityForResult(intent, requestCode); + }, + null, + defaultUserName, + getString(R.string.user_add_user), + this::addUserNow, + this::finish + ); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + mEditUserInfoController.onActivityResult(requestCode, resultCode, data); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + if (mSetupUserDialog != null) { + mSetupUserDialog.dismiss(); + } + } + + private void addUserNow(String userName, Drawable userIcon) { + mSetupUserDialog.dismiss(); + + userName = (userName == null || userName.trim().isEmpty()) + ? getString(R.string.user_new_user_name) + : userName; + + mUserCreator.createUser(userName, userIcon, + userInfo -> { + switchToUser(userInfo.id); + finishIfNeeded(); + }, () -> { + Log.e(TAG, "Unable to create user"); + finishIfNeeded(); + }); + } + + private void finishIfNeeded() { + if (!isFinishing() && !isDestroyed()) { + finish(); + } + } + + private void switchToUser(int userId) { + try { + mActivityManager.switchUser(userId); + } catch (RemoteException e) { + Log.e(TAG, "Couldn't switch user.", e); + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/UserCreator.java b/packages/SystemUI/src/com/android/systemui/user/UserCreator.java new file mode 100644 index 0000000000000..3a270bb77e461 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/UserCreator.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2020 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.user; + +import android.app.Dialog; +import android.content.Context; +import android.content.pm.UserInfo; +import android.graphics.drawable.Drawable; +import android.os.UserManager; + +import com.android.internal.util.UserIcons; +import com.android.settingslib.users.UserCreatingDialog; +import com.android.settingslib.utils.ThreadUtils; + +import java.util.function.Consumer; + +import javax.inject.Inject; + +/** + * A class to do the user creation process. It shows a progress dialog, and manages the user + * creation + */ +public class UserCreator { + + private final Context mContext; + private final UserManager mUserManager; + + @Inject + public UserCreator(Context context, UserManager userManager) { + mContext = context; + mUserManager = userManager; + } + + /** + * Shows a progress dialog then starts the user creation process on the main thread. + * + * @param successCallback is called when the user creation is successful. + * @param errorCallback is called when userManager.createUser returns null. + * (Exceptions are not handled by this class) + */ + public void createUser(String userName, Drawable userIcon, Consumer successCallback, + Runnable errorCallback) { + + Dialog userCreationProgressDialog = new UserCreatingDialog(mContext); + userCreationProgressDialog.show(); + + // userManager.createUser will block the thread so post is needed for the dialog to show + ThreadUtils.postOnMainThread(() -> { + UserInfo user = + mUserManager.createUser(userName, UserManager.USER_TYPE_FULL_SECONDARY, 0); + if (user == null) { + // Couldn't create user for some reason + userCreationProgressDialog.dismiss(); + errorCallback.run(); + return; + } + + Drawable newUserIcon = userIcon; + if (newUserIcon == null) { + newUserIcon = UserIcons.getDefaultUserIcon(mContext.getResources(), user.id, false); + } + mUserManager.setUserIcon(user.id, UserIcons.convertToBitmap(newUserIcon)); + + userCreationProgressDialog.dismiss(); + successCallback.accept(user); + }); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/UserModule.java b/packages/SystemUI/src/com/android/systemui/user/UserModule.java new file mode 100644 index 0000000000000..0ad0984e8231d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/UserModule.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2020 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.user; + +import com.android.settingslib.users.EditUserInfoController; + +import dagger.Module; +import dagger.Provides; + +/** + * Dagger module for User related classes. + */ +@Module +public class UserModule { + + private static final String FILE_PROVIDER_AUTHORITY = "com.android.systemui.fileprovider"; + + @Provides + EditUserInfoController provideEditUserInfoController() { + return new EditUserInfoController(FILE_PROVIDER_AUTHORITY); + } +}