From f98ae93fdf751e5b2326b1f78599a87e0be028a7 Mon Sep 17 00:00:00 2001 From: Oli Lan Date: Mon, 17 Jan 2022 18:19:36 +0000 Subject: [PATCH 1/3] Add avatar picker that shows a predefined set of images. This is the first in a series of CLs to add a new avatar picker. This CL adds the basic avatar picker screen to show a set of predefined images (that can be supplied as an array of drawables). Resources are provided to adapt the screen to phones and tablets of various sizes. Future CLs will integrate the take photo and choose photo functionality from the current dialog, and will also add a set of default icons that can be chosen if no predefined images have been provided. Bug: 215134398 Test: manual check screen on various screen sizes Change-Id: I6ce95549cc17dc5a9b67d9dc43c5b7d2bda37874 --- packages/SettingsLib/Android.bp | 1 + packages/SettingsLib/AndroidManifest.xml | 6 + .../res/drawable/avatar_selector.xml | 23 +++ .../res/drawable/ic_account_circle.xml | 25 +++ .../SettingsLib/res/layout/avatar_item.xml | 24 +++ .../SettingsLib/res/layout/avatar_picker.xml | 38 ++++ .../res/values-w1280dp-land/dimens.xml | 21 ++ .../res/values-w1440dp-land/dimens.xml | 21 ++ .../res/values-w1600dp-land/dimens.xml | 21 ++ .../res/values-w480dp-port/dimens.xml | 21 ++ .../res/values-w600dp-port/dimens.xml | 21 ++ .../res/values-w720dp-port/dimens.xml | 21 ++ .../res/values-w840dp-port/dimens.xml | 21 ++ .../res/values-w960dp-land/dimens.xml | 21 ++ packages/SettingsLib/res/values/arrays.xml | 2 + packages/SettingsLib/res/values/dimens.xml | 5 + packages/SettingsLib/res/values/strings.xml | 3 + .../users/AvatarPickerActivity.java | 194 ++++++++++++++++++ .../users/EditUserInfoController.java | 34 ++- .../users/EditUserPhotoController.java | 23 ++- .../users/EditUserInfoControllerTest.java | 10 +- 21 files changed, 541 insertions(+), 15 deletions(-) create mode 100644 packages/SettingsLib/res/drawable/avatar_selector.xml create mode 100644 packages/SettingsLib/res/drawable/ic_account_circle.xml create mode 100644 packages/SettingsLib/res/layout/avatar_item.xml create mode 100644 packages/SettingsLib/res/layout/avatar_picker.xml create mode 100644 packages/SettingsLib/res/values-w1280dp-land/dimens.xml create mode 100644 packages/SettingsLib/res/values-w1440dp-land/dimens.xml create mode 100644 packages/SettingsLib/res/values-w1600dp-land/dimens.xml create mode 100644 packages/SettingsLib/res/values-w480dp-port/dimens.xml create mode 100644 packages/SettingsLib/res/values-w600dp-port/dimens.xml create mode 100644 packages/SettingsLib/res/values-w720dp-port/dimens.xml create mode 100644 packages/SettingsLib/res/values-w840dp-port/dimens.xml create mode 100644 packages/SettingsLib/res/values-w960dp-land/dimens.xml create mode 100644 packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java diff --git a/packages/SettingsLib/Android.bp b/packages/SettingsLib/Android.bp index fcf2282160a7f..684f4dee9ac2c 100644 --- a/packages/SettingsLib/Android.bp +++ b/packages/SettingsLib/Android.bp @@ -50,6 +50,7 @@ android_library { "SettingsLibSettingsTransition", "SettingsLibActivityEmbedding", "SettingsLibButtonPreference", + "setupdesign", ], // ANDROIDMK TRANSLATION ERROR: unsupported assignment to LOCAL_SHARED_JAVA_LIBRARIES diff --git a/packages/SettingsLib/AndroidManifest.xml b/packages/SettingsLib/AndroidManifest.xml index a3473459948d8..13f8a372c9b57 100644 --- a/packages/SettingsLib/AndroidManifest.xml +++ b/packages/SettingsLib/AndroidManifest.xml @@ -18,4 +18,10 @@ + + + + diff --git a/packages/SettingsLib/res/drawable/avatar_selector.xml b/packages/SettingsLib/res/drawable/avatar_selector.xml new file mode 100644 index 0000000000000..b4eea209780b1 --- /dev/null +++ b/packages/SettingsLib/res/drawable/avatar_selector.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/SettingsLib/res/drawable/ic_account_circle.xml b/packages/SettingsLib/res/drawable/ic_account_circle.xml new file mode 100644 index 0000000000000..0cc54b6b49728 --- /dev/null +++ b/packages/SettingsLib/res/drawable/ic_account_circle.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/packages/SettingsLib/res/layout/avatar_item.xml b/packages/SettingsLib/res/layout/avatar_item.xml new file mode 100644 index 0000000000000..c52f6648cb7ee --- /dev/null +++ b/packages/SettingsLib/res/layout/avatar_item.xml @@ -0,0 +1,24 @@ + + + diff --git a/packages/SettingsLib/res/layout/avatar_picker.xml b/packages/SettingsLib/res/layout/avatar_picker.xml new file mode 100644 index 0000000000000..ffb32f70b14ed --- /dev/null +++ b/packages/SettingsLib/res/layout/avatar_picker.xml @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/SettingsLib/res/values-w1280dp-land/dimens.xml b/packages/SettingsLib/res/values-w1280dp-land/dimens.xml new file mode 100644 index 0000000000000..4097d05642512 --- /dev/null +++ b/packages/SettingsLib/res/values-w1280dp-land/dimens.xml @@ -0,0 +1,21 @@ + + + 4 + 104dp + 8dp + 3dp + \ No newline at end of file diff --git a/packages/SettingsLib/res/values-w1440dp-land/dimens.xml b/packages/SettingsLib/res/values-w1440dp-land/dimens.xml new file mode 100644 index 0000000000000..764870efe2228 --- /dev/null +++ b/packages/SettingsLib/res/values-w1440dp-land/dimens.xml @@ -0,0 +1,21 @@ + + + 4 + 128dp + 8dp + 4dp + \ No newline at end of file diff --git a/packages/SettingsLib/res/values-w1600dp-land/dimens.xml b/packages/SettingsLib/res/values-w1600dp-land/dimens.xml new file mode 100644 index 0000000000000..872b88a3776d8 --- /dev/null +++ b/packages/SettingsLib/res/values-w1600dp-land/dimens.xml @@ -0,0 +1,21 @@ + + + 4 + 148dp + 8dp + 6dp + \ No newline at end of file diff --git a/packages/SettingsLib/res/values-w480dp-port/dimens.xml b/packages/SettingsLib/res/values-w480dp-port/dimens.xml new file mode 100644 index 0000000000000..cab78d64ce8df --- /dev/null +++ b/packages/SettingsLib/res/values-w480dp-port/dimens.xml @@ -0,0 +1,21 @@ + + + 3 + 112dp + 8dp + 3dp + diff --git a/packages/SettingsLib/res/values-w600dp-port/dimens.xml b/packages/SettingsLib/res/values-w600dp-port/dimens.xml new file mode 100644 index 0000000000000..4097d05642512 --- /dev/null +++ b/packages/SettingsLib/res/values-w600dp-port/dimens.xml @@ -0,0 +1,21 @@ + + + 4 + 104dp + 8dp + 3dp + \ No newline at end of file diff --git a/packages/SettingsLib/res/values-w720dp-port/dimens.xml b/packages/SettingsLib/res/values-w720dp-port/dimens.xml new file mode 100644 index 0000000000000..764870efe2228 --- /dev/null +++ b/packages/SettingsLib/res/values-w720dp-port/dimens.xml @@ -0,0 +1,21 @@ + + + 4 + 128dp + 8dp + 4dp + \ No newline at end of file diff --git a/packages/SettingsLib/res/values-w840dp-port/dimens.xml b/packages/SettingsLib/res/values-w840dp-port/dimens.xml new file mode 100644 index 0000000000000..872b88a3776d8 --- /dev/null +++ b/packages/SettingsLib/res/values-w840dp-port/dimens.xml @@ -0,0 +1,21 @@ + + + 4 + 148dp + 8dp + 6dp + \ No newline at end of file diff --git a/packages/SettingsLib/res/values-w960dp-land/dimens.xml b/packages/SettingsLib/res/values-w960dp-land/dimens.xml new file mode 100644 index 0000000000000..8403dba543292 --- /dev/null +++ b/packages/SettingsLib/res/values-w960dp-land/dimens.xml @@ -0,0 +1,21 @@ + + + 4 + 96dp + 6dp + 2dp + diff --git a/packages/SettingsLib/res/values/arrays.xml b/packages/SettingsLib/res/values/arrays.xml index 2b5e9cdc017d0..93e3deef7aa52 100644 --- a/packages/SettingsLib/res/values/arrays.xml +++ b/packages/SettingsLib/res/values/arrays.xml @@ -647,4 +647,6 @@ disabled + + diff --git a/packages/SettingsLib/res/values/dimens.xml b/packages/SettingsLib/res/values/dimens.xml index 9ee42b648d3df..e46d69e135d68 100644 --- a/packages/SettingsLib/res/values/dimens.xml +++ b/packages/SettingsLib/res/values/dimens.xml @@ -102,4 +102,9 @@ 112dp 32dp + 3 + 96dp + 6dp + 2dp + diff --git a/packages/SettingsLib/res/values/strings.xml b/packages/SettingsLib/res/values/strings.xml index 5ada02825b31c..45f8f1debe340 100644 --- a/packages/SettingsLib/res/values/strings.xml +++ b/packages/SettingsLib/res/values/strings.xml @@ -1548,4 +1548,7 @@ No calling. + + + Choose a profile picture diff --git a/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java new file mode 100644 index 0000000000000..4cd107df1d090 --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settingslib.users; + +import android.app.Activity; +import android.content.Intent; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.core.graphics.drawable.RoundedBitmapDrawable; +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.android.settingslib.R; + +import com.google.android.setupcompat.template.FooterBarMixin; +import com.google.android.setupcompat.template.FooterButton; +import com.google.android.setupdesign.GlifLayout; +import com.google.android.setupdesign.util.ThemeHelper; + +/** + * Activity to allow the user to choose a user profile picture. + */ +public class AvatarPickerActivity extends Activity { + + static final String EXTRA_AVATAR_INDEX = "avatar_index"; + + private FooterButton mDoneButton; + private AvatarAdapter mAdapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ThemeHelper.trySetDynamicColor(this); + setContentView(R.layout.avatar_picker); + + GlifLayout glifLayout = findViewById(R.id.glif_layout); + RecyclerView recyclerView = findViewById(R.id.avatar_grid); + FooterBarMixin mixin = glifLayout.getMixin(FooterBarMixin.class); + + FooterButton secondaryButton = + new FooterButton.Builder(this) + .setText("Cancel") + .setListener(view -> cancel()) + .build(); + + mDoneButton = + new FooterButton.Builder(this) + .setText("Done") + .setListener(view -> confirmSelection()) + .build(); + mDoneButton.setEnabled(false); + + mixin.setSecondaryButton(secondaryButton); + mixin.setPrimaryButton(mDoneButton); + + mAdapter = new AvatarAdapter(); + recyclerView.setAdapter(mAdapter); + recyclerView.setLayoutManager(new GridLayoutManager(this, + getResources().getInteger(R.integer.avatar_picker_columns))); + } + + private void confirmSelection() { + Intent data = new Intent(); + data.putExtra(EXTRA_AVATAR_INDEX, mAdapter.indexFromPosition(mAdapter.mSelectedPosition)); + setResult(RESULT_OK, data); + finish(); + } + + private void cancel() { + setResult(RESULT_CANCELED); + finish(); + } + + private class AvatarAdapter extends RecyclerView.Adapter { + + private static final int NONE = -1; + private static final int AVATAR_START_POSITION = 0; + + private final TypedArray mImageDrawables; + private int mSelectedPosition = NONE; + + AvatarAdapter() { + mImageDrawables = getResources().obtainTypedArray(R.array.avatar_images); + } + + @NonNull + @Override + public AvatarViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int position) { + LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + View itemView = layoutInflater.inflate(R.layout.avatar_item, parent, false); + return new AvatarViewHolder(itemView); + } + + @Override + public void onBindViewHolder(@NonNull AvatarViewHolder viewHolder, int position) { + Drawable drawable = mImageDrawables.getDrawable(indexFromPosition(position)); + if (drawable instanceof BitmapDrawable) { + drawable = circularDrawableFrom((BitmapDrawable) drawable); + } else { + throw new IllegalStateException("Avatar drawables must be bitmaps"); + } + viewHolder.setSelected(position == mSelectedPosition); + viewHolder.setDrawable(drawable); + viewHolder.setClickListener(view -> { + if (mSelectedPosition == position) { + deselect(position); + } else { + select(position); + } + }); + } + + @Override + public int getItemCount() { + return AVATAR_START_POSITION + mImageDrawables.length(); + } + + private Drawable circularDrawableFrom(BitmapDrawable drawable) { + Bitmap bitmap = drawable.getBitmap(); + + RoundedBitmapDrawable roundedBitmapDrawable = + RoundedBitmapDrawableFactory.create(getResources(), bitmap); + roundedBitmapDrawable.setCircular(true); + + return roundedBitmapDrawable; + } + + private int indexFromPosition(int position) { + return position - AVATAR_START_POSITION; + } + + private void select(int position) { + final int oldSelection = mSelectedPosition; + mSelectedPosition = position; + notifyItemChanged(position); + if (oldSelection != NONE) { + notifyItemChanged(oldSelection); + } else { + mDoneButton.setEnabled(true); + } + } + + private void deselect(int position) { + mSelectedPosition = NONE; + notifyItemChanged(position); + mDoneButton.setEnabled(false); + } + } + + private static class AvatarViewHolder extends RecyclerView.ViewHolder { + private final ImageView mImageView; + + AvatarViewHolder(View view) { + super(view); + mImageView = view.findViewById(R.id.avatar_image); + } + + public void setDrawable(Drawable drawable) { + mImageView.setImageDrawable(drawable); + } + + public void setClickListener(View.OnClickListener listener) { + mImageView.setOnClickListener(listener); + } + + public void setSelected(boolean selected) { + mImageView.setSelected(selected); + } + } +} diff --git a/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java b/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java index 62043363d85f7..fb0346dacb53a 100644 --- a/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java +++ b/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java @@ -25,6 +25,7 @@ import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; +import android.os.UserManager; import android.view.LayoutInflater; import android.view.View; import android.view.WindowManager; @@ -36,6 +37,8 @@ import androidx.annotation.VisibleForTesting; import com.android.internal.util.UserIcons; import com.android.settingslib.R; +import com.android.settingslib.RestrictedLockUtils; +import com.android.settingslib.RestrictedLockUtilsInternal; import com.android.settingslib.drawable.CircleFramedDrawable; import java.io.File; @@ -139,12 +142,20 @@ public class EditUserInfoController { 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 the suggestive icon + if (isChangePhotoRestrictedByBase(activity)) { + // some users can't change their photos so we need to remove the suggestive icon content.findViewById(R.id.add_a_photo_icon).setVisibility(View.GONE); + } else { + RestrictedLockUtils.EnforcedAdmin adminRestriction = + getChangePhotoAdminRestriction(activity); + if (adminRestriction != null) { + userPhotoView.setOnClickListener(view -> + RestrictedLockUtils.sendShowAdminSupportDetailsIntent( + activity, adminRestriction)); + } else { + mEditUserPhotoController = createEditUserPhotoController(activity, activityStarter, + userPhotoView); + } } mEditUserInfoDialog = buildDialog(activity, content, userNameView, oldUserIcon, @@ -203,10 +214,15 @@ public class EditUserInfoController { } @VisibleForTesting - boolean canChangePhoto(Context context) { - return (PhotoCapabilityUtils.canCropPhoto(context) - && PhotoCapabilityUtils.canChoosePhoto(context)) - || PhotoCapabilityUtils.canTakePhoto(context); + boolean isChangePhotoRestrictedByBase(Context context) { + return RestrictedLockUtilsInternal.hasBaseUserRestriction( + context, UserManager.DISALLOW_SET_USER_ICON, UserHandle.myUserId()); + } + + @VisibleForTesting + RestrictedLockUtils.EnforcedAdmin getChangePhotoAdminRestriction(Context context) { + return RestrictedLockUtilsInternal.checkIfRestrictionEnforced( + context, UserManager.DISALLOW_SET_USER_ICON, UserHandle.myUserId()); } @VisibleForTesting diff --git a/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java b/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java index f9584a3e15e9a..ee4c9fde9b6e5 100644 --- a/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java +++ b/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java @@ -21,6 +21,8 @@ import android.content.ClipData; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; +import android.content.res.Resources; +import android.content.res.TypedArray; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; @@ -29,6 +31,7 @@ import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.media.ExifInterface; import android.net.Uri; @@ -78,6 +81,7 @@ public class EditUserPhotoController { 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; + private static final int REQUEST_CODE_PICK_AVATAR = 1004; // 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; @@ -113,7 +117,7 @@ public class EditUserPhotoController { mCropPictureUri = createTempImageUri(activity, CROP_PICTURE_FILE_NAME, !waiting); mTakePictureUri = createTempImageUri(activity, TAKE_PICTURE_FILE_NAME, !waiting); mPhotoSize = getPhotoSize(activity); - mImageView.setOnClickListener(v -> showUpdatePhotoPopup()); + mImageView.setOnClickListener(v -> showAvatarPicker()); mNewUserPhotoBitmap = bitmap; } @@ -151,6 +155,18 @@ public class EditUserPhotoController { copyAndCropPhoto(pictureUri); } return true; + case REQUEST_CODE_PICK_AVATAR: + int index = data.getIntExtra(AvatarPickerActivity.EXTRA_AVATAR_INDEX, -1); + Resources res = mImageView.getContext().getResources(); + try (TypedArray avatarImages = res.obtainTypedArray(R.array.avatar_images)) { + if (index >= 0 && index < avatarImages.length()) { + Drawable drawable = avatarImages.getDrawable(index); + if (!(drawable instanceof BitmapDrawable)) { + return false; + } + onPhotoProcessed(((BitmapDrawable) drawable).getBitmap()); + } + } } return false; } @@ -159,6 +175,11 @@ public class EditUserPhotoController { return mNewUserPhotoDrawable; } + private void showAvatarPicker() { + Intent intent = new Intent(mImageView.getContext(), AvatarPickerActivity.class); + mActivityStarter.startActivityForResult(intent, REQUEST_CODE_PICK_AVATAR); + } + private void showUpdatePhotoPopup() { final Context context = mImageView.getContext(); final boolean canTakePhoto = PhotoCapabilityUtils.canTakePhoto(context); 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 index d6c8816ecc585..a5ee4c35f7244 100644 --- a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/users/EditUserInfoControllerTest.java +++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/users/EditUserInfoControllerTest.java @@ -62,7 +62,7 @@ public class EditUserInfoControllerTest { @Mock private ActivityStarter mActivityStarter; - private boolean mCanChangePhoto; + private boolean mPhotoRestrictedByBase; private Activity mActivity; private TestEditUserInfoController mController; @@ -85,8 +85,8 @@ public class EditUserInfoControllerTest { } @Override - boolean canChangePhoto(Context context) { - return mCanChangePhoto; + boolean isChangePhotoRestrictedByBase(Context context) { + return mPhotoRestrictedByBase; } } @@ -96,7 +96,7 @@ public class EditUserInfoControllerTest { mActivity = spy(ActivityController.of(new FragmentActivity()).get()); mActivity.setTheme(R.style.Theme_AppCompat_DayNight); mController = new TestEditUserInfoController(); - mCanChangePhoto = true; + mPhotoRestrictedByBase = true; } @Test @@ -260,7 +260,7 @@ public class EditUserInfoControllerTest { @Test public void createDialog_canNotChangePhoto_nullPhotoController() { - mCanChangePhoto = false; + mPhotoRestrictedByBase = false; mController.createDialog(mActivity, mActivityStarter, mCurrentIcon, "test", "title", null, null); From b475112036761e60efaf61d5fdb0ba2e149191e6 Mon Sep 17 00:00:00 2001 From: Oli Lan Date: Tue, 18 Jan 2022 10:05:36 +0000 Subject: [PATCH 2/3] Support taking or choosing a photo in avatar picker. This adds options to take a photo or to choose a photo to the new avatar picker. The functionality to support both of these operations, plus cropping, is largely the same as before but has been moved from EditUserPhotoController to a new AvatarPhotoController. The code from EditUserPhotoController has been moved without changes where possible. Future CLs may introduce improvements such as replacing AsyncTask. The 'choose photo' has been changed from the previous behaviour, however - it now shows the new photo picker. Bug: 215134398 Test: manual, check new UI from settings and sysui and confirm working Change-Id: I5d98902f3e8c186ad30d288b8eed544977ba0e48 --- .../drawable/avatar_choose_photo_circled.xml | 30 ++ .../drawable/avatar_take_photo_circled.xml | 30 ++ .../res/drawable/ic_avatar_choose_photo.xml | 24 ++ .../res/drawable/ic_avatar_take_photo.xml | 24 ++ packages/SettingsLib/res/values/dimens.xml | 2 + .../users/AvatarPhotoController.java | 297 ++++++++++++++ .../users/AvatarPickerActivity.java | 146 +++++-- .../users/EditUserInfoController.java | 2 +- .../users/EditUserPhotoController.java | 388 +----------------- .../users/PhotoCapabilityUtils.java | 4 +- 10 files changed, 536 insertions(+), 411 deletions(-) create mode 100644 packages/SettingsLib/res/drawable/avatar_choose_photo_circled.xml create mode 100644 packages/SettingsLib/res/drawable/avatar_take_photo_circled.xml create mode 100644 packages/SettingsLib/res/drawable/ic_avatar_choose_photo.xml create mode 100644 packages/SettingsLib/res/drawable/ic_avatar_take_photo.xml create mode 100644 packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java diff --git a/packages/SettingsLib/res/drawable/avatar_choose_photo_circled.xml b/packages/SettingsLib/res/drawable/avatar_choose_photo_circled.xml new file mode 100644 index 0000000000000..97aec740ea435 --- /dev/null +++ b/packages/SettingsLib/res/drawable/avatar_choose_photo_circled.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/packages/SettingsLib/res/drawable/avatar_take_photo_circled.xml b/packages/SettingsLib/res/drawable/avatar_take_photo_circled.xml new file mode 100644 index 0000000000000..7033aaeec9117 --- /dev/null +++ b/packages/SettingsLib/res/drawable/avatar_take_photo_circled.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/packages/SettingsLib/res/drawable/ic_avatar_choose_photo.xml b/packages/SettingsLib/res/drawable/ic_avatar_choose_photo.xml new file mode 100644 index 0000000000000..b85fdc2de5970 --- /dev/null +++ b/packages/SettingsLib/res/drawable/ic_avatar_choose_photo.xml @@ -0,0 +1,24 @@ + + + + diff --git a/packages/SettingsLib/res/drawable/ic_avatar_take_photo.xml b/packages/SettingsLib/res/drawable/ic_avatar_take_photo.xml new file mode 100644 index 0000000000000..5c56276ec4f34 --- /dev/null +++ b/packages/SettingsLib/res/drawable/ic_avatar_take_photo.xml @@ -0,0 +1,24 @@ + + + + diff --git a/packages/SettingsLib/res/values/dimens.xml b/packages/SettingsLib/res/values/dimens.xml index e46d69e135d68..120df76218b37 100644 --- a/packages/SettingsLib/res/values/dimens.xml +++ b/packages/SettingsLib/res/values/dimens.xml @@ -107,4 +107,6 @@ 6dp 2dp + 25dp + diff --git a/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java new file mode 100644 index 0000000000000..8dfd1bd5d05c6 --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settingslib.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.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.RectF; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.StrictMode; +import android.provider.ContactsContract; +import android.provider.MediaStore; +import android.util.EventLog; +import android.util.Log; + +import androidx.core.content.FileProvider; + +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; + +class AvatarPhotoController { + private static final String TAG = "AvatarPhotoController"; + + 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 final int mPhotoSize; + + private final AvatarPickerActivity mActivity; + private final String mFileAuthority; + + private final File mImagesDir; + private final Uri mCropPictureUri; + private final Uri mTakePictureUri; + + AvatarPhotoController(AvatarPickerActivity activity, boolean waiting, String fileAuthority) { + mActivity = activity; + 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); + } + + /** + * 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; + + // Check if the result is a content uri + if (!ContentResolver.SCHEME_CONTENT.equals(pictureUri.getScheme())) { + Log.e(TAG, "Invalid pictureUri scheme: " + pictureUri.getScheme()); + EventLog.writeEvent(0x534e4554, "172939189", -1, pictureUri.getPath()); + return false; + } + + switch (requestCode) { + case REQUEST_CODE_CROP_PHOTO: + mActivity.returnResult(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; + } + + void takePhoto() { + Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE_SECURE); + appendOutputExtra(intent, mTakePictureUri); + mActivity.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO); + } + + void choosePhoto() { + Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES, null); + intent.setType("image/*"); + mActivity.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(); + mActivity.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 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, + Bitmap.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) { + saveBitmapToFile(bitmap, new File(mImagesDir, CROP_PICTURE_FILE_NAME)); + mActivity.returnResult(mCropPictureUri); + } + }.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 saveBitmapToFile(Bitmap bitmap, File file) { + try { + OutputStream os = new FileOutputStream(file); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, os); + os.flush(); + os.close(); + } catch (IOException e) { + Log.e(TAG, "Cannot create temp file", e); + } + } + + private static int getPhotoSize(Context context) { + try (Cursor cursor = context.getContentResolver().query( + ContactsContract.DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI, + new String[]{ContactsContract.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); + } +} diff --git a/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java index 4cd107df1d090..2d9809d2f69ed 100644 --- a/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java +++ b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java @@ -17,11 +17,13 @@ package com.android.settingslib.users; import android.app.Activity; +import android.content.ContentResolver; import android.content.Intent; import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -46,19 +48,38 @@ import com.google.android.setupdesign.util.ThemeHelper; */ public class AvatarPickerActivity extends Activity { - static final String EXTRA_AVATAR_INDEX = "avatar_index"; + static final String EXTRA_FILE_AUTHORITY = "file_authority"; + private static final String KEY_AWAITING_RESULT = "awaiting_result"; + private static final String KEY_SELECTED_POSITION = "selected_position"; + + private boolean mWaitingForActivityResult; private FooterButton mDoneButton; private AvatarAdapter mAdapter; + private AvatarPhotoController mAvatarPhotoController; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ThemeHelper.trySetDynamicColor(this); setContentView(R.layout.avatar_picker); + setUpButtons(); - GlifLayout glifLayout = findViewById(R.id.glif_layout); RecyclerView recyclerView = findViewById(R.id.avatar_grid); + mAdapter = new AvatarAdapter(); + recyclerView.setAdapter(mAdapter); + recyclerView.setLayoutManager(new GridLayoutManager(this, + getResources().getInteger(R.integer.avatar_picker_columns))); + + restoreState(savedInstanceState); + + mAvatarPhotoController = new AvatarPhotoController( + this, mWaitingForActivityResult, getFileAuthority()); + } + + private void setUpButtons() { + GlifLayout glifLayout = findViewById(R.id.glif_layout); FooterBarMixin mixin = glifLayout.getMixin(FooterBarMixin.class); FooterButton secondaryButton = @@ -70,23 +91,53 @@ public class AvatarPickerActivity extends Activity { mDoneButton = new FooterButton.Builder(this) .setText("Done") - .setListener(view -> confirmSelection()) + .setListener(view -> returnResult(mAdapter.uriForSelection())) .build(); mDoneButton.setEnabled(false); mixin.setSecondaryButton(secondaryButton); mixin.setPrimaryButton(mDoneButton); - - mAdapter = new AvatarAdapter(); - recyclerView.setAdapter(mAdapter); - recyclerView.setLayoutManager(new GridLayoutManager(this, - getResources().getInteger(R.integer.avatar_picker_columns))); } - private void confirmSelection() { - Intent data = new Intent(); - data.putExtra(EXTRA_AVATAR_INDEX, mAdapter.indexFromPosition(mAdapter.mSelectedPosition)); - setResult(RESULT_OK, data); + private String getFileAuthority() { + String authority = getIntent().getStringExtra(EXTRA_FILE_AUTHORITY); + if (authority == null) { + throw new IllegalStateException("File authority must be provided"); + } + return authority; + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + mWaitingForActivityResult = false; + mAvatarPhotoController.onActivityResult(requestCode, resultCode, data); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + outState.putBoolean(KEY_AWAITING_RESULT, mWaitingForActivityResult); + outState.putInt(KEY_SELECTED_POSITION, mAdapter.mSelectedPosition); + super.onSaveInstanceState(outState); + } + + private void restoreState(Bundle savedInstanceState) { + if (savedInstanceState != null) { + mWaitingForActivityResult = savedInstanceState.getBoolean(KEY_AWAITING_RESULT, false); + mAdapter.mSelectedPosition = + savedInstanceState.getInt(KEY_SELECTED_POSITION, AvatarAdapter.NONE); + } + } + + @Override + public void startActivityForResult(Intent intent, int requestCode) { + mWaitingForActivityResult = true; + super.startActivityForResult(intent, requestCode); + } + + void returnResult(Uri uri) { + Intent resultData = new Intent(); + resultData.setData(uri); + setResult(RESULT_OK, resultData); finish(); } @@ -98,12 +149,23 @@ public class AvatarPickerActivity extends Activity { private class AvatarAdapter extends RecyclerView.Adapter { private static final int NONE = -1; - private static final int AVATAR_START_POSITION = 0; + + private final int mTakePhotoPosition; + private final int mChoosePhotoPosition; + private final int mPreselectedImageStartPosition; private final TypedArray mImageDrawables; private int mSelectedPosition = NONE; AvatarAdapter() { + final boolean canTakePhoto = + PhotoCapabilityUtils.canTakePhoto(AvatarPickerActivity.this); + final boolean canChoosePhoto = + PhotoCapabilityUtils.canChoosePhoto(AvatarPickerActivity.this); + mTakePhotoPosition = (canTakePhoto ? 0 : NONE); + mChoosePhotoPosition = (canChoosePhoto ? (canTakePhoto ? 1 : 0) : NONE); + mPreselectedImageStartPosition = (canTakePhoto ? 1 : 0) + (canChoosePhoto ? 1 : 0); + mImageDrawables = getResources().obtainTypedArray(R.array.avatar_images); } @@ -117,26 +179,36 @@ public class AvatarPickerActivity extends Activity { @Override public void onBindViewHolder(@NonNull AvatarViewHolder viewHolder, int position) { - Drawable drawable = mImageDrawables.getDrawable(indexFromPosition(position)); - if (drawable instanceof BitmapDrawable) { - drawable = circularDrawableFrom((BitmapDrawable) drawable); - } else { - throw new IllegalStateException("Avatar drawables must be bitmaps"); - } - viewHolder.setSelected(position == mSelectedPosition); - viewHolder.setDrawable(drawable); - viewHolder.setClickListener(view -> { - if (mSelectedPosition == position) { - deselect(position); + if (position == mTakePhotoPosition) { + viewHolder.setDrawable(getDrawable(R.drawable.avatar_take_photo_circled)); + viewHolder.setClickListener(view -> mAvatarPhotoController.takePhoto()); + + } else if (position == mChoosePhotoPosition) { + viewHolder.setDrawable(getDrawable(R.drawable.avatar_choose_photo_circled)); + viewHolder.setClickListener(view -> mAvatarPhotoController.choosePhoto()); + + } else if (position >= mPreselectedImageStartPosition) { + Drawable drawable = mImageDrawables.getDrawable(indexFromPosition(position)); + if (drawable instanceof BitmapDrawable) { + drawable = circularDrawableFrom((BitmapDrawable) drawable); } else { - select(position); + throw new IllegalStateException("Avatar drawables must be bitmaps"); } - }); + viewHolder.setSelected(position == mSelectedPosition); + viewHolder.setDrawable(drawable); + viewHolder.setClickListener(view -> { + if (mSelectedPosition == position) { + deselect(position); + } else { + select(position); + } + }); + } } @Override public int getItemCount() { - return AVATAR_START_POSITION + mImageDrawables.length(); + return mPreselectedImageStartPosition + mImageDrawables.length(); } private Drawable circularDrawableFrom(BitmapDrawable drawable) { @@ -150,7 +222,7 @@ public class AvatarPickerActivity extends Activity { } private int indexFromPosition(int position) { - return position - AVATAR_START_POSITION; + return position - mPreselectedImageStartPosition; } private void select(int position) { @@ -169,6 +241,24 @@ public class AvatarPickerActivity extends Activity { notifyItemChanged(position); mDoneButton.setEnabled(false); } + + private Uri uriForSelection() { + int resourceId = + mImageDrawables.getResourceId(indexFromPosition(mSelectedPosition), -1); + if (resourceId == -1) { + throw new IllegalStateException("Preselected avatar images must be resources."); + } + return uriForResourceId(resourceId); + } + + private Uri uriForResourceId(int resourceId) { + return new Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(getResources().getResourcePackageName(resourceId)) + .appendPath(getResources().getResourceTypeName(resourceId)) + .appendPath(getResources().getResourceEntryName(resourceId)) + .build(); + } } private static class AvatarViewHolder extends RecyclerView.ViewHolder { diff --git a/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java b/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java index fb0346dacb53a..80ee86f5e4891 100644 --- a/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java +++ b/packages/SettingsLib/src/com/android/settingslib/users/EditUserInfoController.java @@ -229,6 +229,6 @@ public class EditUserInfoController { EditUserPhotoController createEditUserPhotoController(Activity activity, ActivityStarter activityStarter, ImageView userPhotoView) { return new EditUserPhotoController(activity, activityStarter, userPhotoView, - mSavedPhoto, mWaitingForActivityResult, mFileAuthority); + mSavedPhoto, mFileAuthority); } } diff --git a/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java b/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java index ee4c9fde9b6e5..03b6d2444f225 100644 --- a/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java +++ b/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java @@ -17,57 +17,23 @@ 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.content.res.Resources; -import android.content.res.TypedArray; -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.BitmapDrawable; 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.EventLog; 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 @@ -78,45 +44,29 @@ public class 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; private static final int REQUEST_CODE_PICK_AVATAR = 1004; - // 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) { + ImageView view, Bitmap bitmap, 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 = view; mImageView.setOnClickListener(v -> showAvatarPicker()); mNewUserPhotoBitmap = bitmap; } @@ -129,44 +79,12 @@ public class EditUserPhotoController { if (resultCode != Activity.RESULT_OK) { return false; } - final Uri pictureUri = data != null && data.getData() != null - ? data.getData() : mTakePictureUri; - // Check if the result is a content uri - if (!ContentResolver.SCHEME_CONTENT.equals(pictureUri.getScheme())) { - Log.e(TAG, "Invalid pictureUri scheme: " + pictureUri.getScheme()); - EventLog.writeEvent(0x534e4554, "172939189", -1, pictureUri.getPath()); - return false; - } - - 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; - case REQUEST_CODE_PICK_AVATAR: - int index = data.getIntExtra(AvatarPickerActivity.EXTRA_AVATAR_INDEX, -1); - Resources res = mImageView.getContext().getResources(); - try (TypedArray avatarImages = res.obtainTypedArray(R.array.avatar_images)) { - if (index >= 0 && index < avatarImages.length()) { - Drawable drawable = avatarImages.getDrawable(index); - if (!(drawable instanceof BitmapDrawable)) { - return false; - } - onPhotoProcessed(((BitmapDrawable) drawable).getBitmap()); - } - } + if (requestCode == REQUEST_CODE_PICK_AVATAR) { + if (data.getData() != null) { + onPhotoCropped(data.getData()); + } + return true; } return false; } @@ -177,126 +95,10 @@ public class EditUserPhotoController { private void showAvatarPicker() { Intent intent = new Intent(mImageView.getContext(), AvatarPickerActivity.class); + intent.putExtra(AvatarPickerActivity.EXTRA_FILE_AUTHORITY, mFileAuthority); mActivityStarter.startActivityForResult(intent, REQUEST_CODE_PICK_AVATAR); } - 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() { @@ -329,77 +131,6 @@ public class EditUserPhotoController { }.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; @@ -407,29 +138,6 @@ public class EditUserPhotoController { .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() { @@ -456,84 +164,4 @@ public class EditUserPhotoController { 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 index 165c2808f16d9..b8615a7e5aa92 100644 --- a/packages/SettingsLib/src/com/android/settingslib/users/PhotoCapabilityUtils.java +++ b/packages/SettingsLib/src/com/android/settingslib/users/PhotoCapabilityUtils.java @@ -40,12 +40,12 @@ public class PhotoCapabilityUtils { /** * Check if the current user can perform any activity for - * android.intent.action.GET_CONTENT action for images. + * ACTION_PICK_IMAGES 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 intent = new Intent(MediaStore.ACTION_PICK_IMAGES); intent.setType("image/*"); boolean canPerformActivityForGetImage = context.getPackageManager().queryIntentActivities(intent, 0).size() > 0; From 4f3a0b8fdbf7f7947345240d53fd6dcfc51371a2 Mon Sep 17 00:00:00 2001 From: Oli Lan Date: Fri, 21 Jan 2022 10:24:34 +0000 Subject: [PATCH 3/3] Offer colored default avatars if no preselected images are provided. In the new avatar picker, if no preselected images have been provided, this CL adds several color variations of the default avatar to the picker instead. Bug: 215134398 Test: manual Change-Id: I3e1f617c760b241517f94c606f5cb21eafcc022d --- .../com/android/internal/util/UserIcons.java | 24 +++++- .../res/drawable/avatar_selector.xml | 4 +- ...rcle.xml => ic_account_circle_outline.xml} | 0 .../SettingsLib/res/layout/avatar_picker.xml | 2 +- .../users/AvatarPhotoController.java | 4 +- .../users/AvatarPickerActivity.java | 86 +++++++++++++++---- .../users/EditUserPhotoController.java | 82 ++++++++++++------ 7 files changed, 153 insertions(+), 49 deletions(-) rename packages/SettingsLib/res/drawable/{ic_account_circle.xml => ic_account_circle_outline.xml} (100%) diff --git a/core/java/com/android/internal/util/UserIcons.java b/core/java/com/android/internal/util/UserIcons.java index bfe43237da58d..17b84ffc2f3fa 100644 --- a/core/java/com/android/internal/util/UserIcons.java +++ b/core/java/com/android/internal/util/UserIcons.java @@ -16,6 +16,7 @@ package com.android.internal.util; +import android.annotation.ColorInt; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Canvas; @@ -72,9 +73,30 @@ public class UserIcons { // Return colored icon instead colorResId = USER_ICON_COLORS[userId % USER_ICON_COLORS.length]; } + return getDefaultUserIconInColor(resources, resources.getColor(colorResId, null)); + } + + /** + * Returns a default user icon in a particular color. + * + * @param resources resources object to fetch the user icon + * @param color the color used for the icon + */ + public static Drawable getDefaultUserIconInColor(Resources resources, @ColorInt int color) { Drawable icon = resources.getDrawable(R.drawable.ic_account_circle, null).mutate(); - icon.setColorFilter(resources.getColor(colorResId, null), Mode.SRC_IN); + icon.setColorFilter(color, Mode.SRC_IN); icon.setBounds(0, 0, icon.getIntrinsicWidth(), icon.getIntrinsicHeight()); return icon; } + + /** + * Returns an array containing colors to be used for default user icons. + */ + public static int[] getUserIconColors(Resources resources) { + int[] result = new int[USER_ICON_COLORS.length]; + for (int i = 0; i < result.length; i++) { + result[i] = resources.getColor(USER_ICON_COLORS[i], null); + } + return result; + } } diff --git a/packages/SettingsLib/res/drawable/avatar_selector.xml b/packages/SettingsLib/res/drawable/avatar_selector.xml index b4eea209780b1..ccde59763a4ab 100644 --- a/packages/SettingsLib/res/drawable/avatar_selector.xml +++ b/packages/SettingsLib/res/drawable/avatar_selector.xml @@ -17,7 +17,9 @@ - + \ No newline at end of file diff --git a/packages/SettingsLib/res/drawable/ic_account_circle.xml b/packages/SettingsLib/res/drawable/ic_account_circle_outline.xml similarity index 100% rename from packages/SettingsLib/res/drawable/ic_account_circle.xml rename to packages/SettingsLib/res/drawable/ic_account_circle_outline.xml diff --git a/packages/SettingsLib/res/layout/avatar_picker.xml b/packages/SettingsLib/res/layout/avatar_picker.xml index ffb32f70b14ed..2d40bd0de7eae 100644 --- a/packages/SettingsLib/res/layout/avatar_picker.xml +++ b/packages/SettingsLib/res/layout/avatar_picker.xml @@ -20,7 +20,7 @@ android:id="@+id/glif_layout" android:layout_height="match_parent" android:layout_width="match_parent" - android:icon="@drawable/ic_account_circle" + android:icon="@drawable/ic_account_circle_outline" app:sucUsePartnerResource="true" app:sucHeaderText="@string/avatar_picker_title"> diff --git a/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java index 8dfd1bd5d05c6..61b8911acea45 100644 --- a/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java +++ b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java @@ -102,7 +102,7 @@ class AvatarPhotoController { switch (requestCode) { case REQUEST_CODE_CROP_PHOTO: - mActivity.returnResult(pictureUri); + mActivity.returnUriResult(pictureUri); return true; case REQUEST_CODE_TAKE_PHOTO: case REQUEST_CODE_CHOOSE_PHOTO: @@ -232,7 +232,7 @@ class AvatarPhotoController { @Override protected void onPostExecute(Bitmap bitmap) { saveBitmapToFile(bitmap, new File(mImagesDir, CROP_PICTURE_FILE_NAME)); - mActivity.returnResult(mCropPictureUri); + mActivity.returnUriResult(mCropPictureUri); } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null); } diff --git a/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java index 2d9809d2f69ed..50015e6533999 100644 --- a/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java +++ b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPickerActivity.java @@ -36,6 +36,7 @@ import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import com.android.internal.util.UserIcons; import com.android.settingslib.R; import com.google.android.setupcompat.template.FooterBarMixin; @@ -43,12 +44,29 @@ import com.google.android.setupcompat.template.FooterButton; import com.google.android.setupdesign.GlifLayout; import com.google.android.setupdesign.util.ThemeHelper; +import java.util.ArrayList; +import java.util.List; + /** * Activity to allow the user to choose a user profile picture. + * + *

Options are provided to take a photo or choose a photo using the photo picker. In addition, + * preselected avatar images may be provided in the resource array {@code avatar_images}. If + * provided, every element of that array must be a bitmap drawable. + * + *

If preselected images are not provided, the default avatar will be shown instead, in a range + * of colors. + * + *

This activity should be started with startActivityForResult. If a photo or a preselected image + * is selected, a Uri will be returned in the data field of the result intent. If a colored default + * avatar is selected, the chosen color will be returned as {@code EXTRA_DEFAULT_ICON_TINT_COLOR} + * and the data field will be empty. */ public class AvatarPickerActivity extends Activity { static final String EXTRA_FILE_AUTHORITY = "file_authority"; + static final String EXTRA_DEFAULT_ICON_TINT_COLOR = "default_icon_tint_color"; + private static final String KEY_AWAITING_RESULT = "awaiting_result"; private static final String KEY_SELECTED_POSITION = "selected_position"; @@ -91,7 +109,7 @@ public class AvatarPickerActivity extends Activity { mDoneButton = new FooterButton.Builder(this) .setText("Done") - .setListener(view -> returnResult(mAdapter.uriForSelection())) + .setListener(view -> mAdapter.returnSelectionResult()) .build(); mDoneButton.setEnabled(false); @@ -134,13 +152,20 @@ public class AvatarPickerActivity extends Activity { super.startActivityForResult(intent, requestCode); } - void returnResult(Uri uri) { + void returnUriResult(Uri uri) { Intent resultData = new Intent(); resultData.setData(uri); setResult(RESULT_OK, resultData); finish(); } + void returnColorResult(int color) { + Intent resultData = new Intent(); + resultData.putExtra(EXTRA_DEFAULT_ICON_TINT_COLOR, color); + setResult(RESULT_OK, resultData); + finish(); + } + private void cancel() { setResult(RESULT_CANCELED); finish(); @@ -154,7 +179,9 @@ public class AvatarPickerActivity extends Activity { private final int mChoosePhotoPosition; private final int mPreselectedImageStartPosition; - private final TypedArray mImageDrawables; + private final List mImageDrawables; + private final TypedArray mPreselectedImages; + private final int[] mUserIconColors; private int mSelectedPosition = NONE; AvatarAdapter() { @@ -166,7 +193,9 @@ public class AvatarPickerActivity extends Activity { mChoosePhotoPosition = (canChoosePhoto ? (canTakePhoto ? 1 : 0) : NONE); mPreselectedImageStartPosition = (canTakePhoto ? 1 : 0) + (canChoosePhoto ? 1 : 0); - mImageDrawables = getResources().obtainTypedArray(R.array.avatar_images); + mPreselectedImages = getResources().obtainTypedArray(R.array.avatar_images); + mUserIconColors = UserIcons.getUserIconColors(getResources()); + mImageDrawables = buildDrawableList(); } @NonNull @@ -188,14 +217,8 @@ public class AvatarPickerActivity extends Activity { viewHolder.setClickListener(view -> mAvatarPhotoController.choosePhoto()); } else if (position >= mPreselectedImageStartPosition) { - Drawable drawable = mImageDrawables.getDrawable(indexFromPosition(position)); - if (drawable instanceof BitmapDrawable) { - drawable = circularDrawableFrom((BitmapDrawable) drawable); - } else { - throw new IllegalStateException("Avatar drawables must be bitmaps"); - } viewHolder.setSelected(position == mSelectedPosition); - viewHolder.setDrawable(drawable); + viewHolder.setDrawable(mImageDrawables.get(indexFromPosition(position))); viewHolder.setClickListener(view -> { if (mSelectedPosition == position) { deselect(position); @@ -208,7 +231,29 @@ public class AvatarPickerActivity extends Activity { @Override public int getItemCount() { - return mPreselectedImageStartPosition + mImageDrawables.length(); + return mPreselectedImageStartPosition + mImageDrawables.size(); + } + + private List buildDrawableList() { + List result = new ArrayList<>(); + + for (int i = 0; i < mPreselectedImages.length(); i++) { + Drawable drawable = mPreselectedImages.getDrawable(i); + if (drawable instanceof BitmapDrawable) { + result.add(circularDrawableFrom((BitmapDrawable) drawable)); + } else { + throw new IllegalStateException("Avatar drawables must be bitmaps"); + } + } + if (!result.isEmpty()) { + return result; + } + + // No preselected images. Use tinted default icon. + for (int i = 0; i < mUserIconColors.length; i++) { + result.add(UserIcons.getDefaultUserIconInColor(getResources(), mUserIconColors[i])); + } + return result; } private Drawable circularDrawableFrom(BitmapDrawable drawable) { @@ -242,13 +287,18 @@ public class AvatarPickerActivity extends Activity { mDoneButton.setEnabled(false); } - private Uri uriForSelection() { - int resourceId = - mImageDrawables.getResourceId(indexFromPosition(mSelectedPosition), -1); - if (resourceId == -1) { - throw new IllegalStateException("Preselected avatar images must be resources."); + private void returnSelectionResult() { + int index = indexFromPosition(mSelectedPosition); + if (mPreselectedImages.length() > 0) { + int resourceId = mPreselectedImages.getResourceId(index, -1); + if (resourceId == -1) { + throw new IllegalStateException("Preselected avatar images must be resources."); + } + returnUriResult(uriForResourceId(resourceId)); + } else { + returnColorResult( + mUserIconColors[index]); } - return uriForResourceId(resourceId); } private Uri uriForResourceId(int resourceId) { diff --git a/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java b/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java index 03b6d2444f225..f8bb38b5978ec 100644 --- a/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java +++ b/packages/SettingsLib/src/com/android/settingslib/users/EditUserPhotoController.java @@ -16,17 +16,21 @@ package com.android.settingslib.users; +import android.annotation.NonNull; import android.app.Activity; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.Canvas; import android.graphics.drawable.Drawable; import android.net.Uri; -import android.os.AsyncTask; import android.util.Log; import android.widget.ImageView; +import com.android.internal.util.UserIcons; +import com.android.settingslib.R; import com.android.settingslib.drawable.CircleFramedDrawable; +import com.android.settingslib.utils.ThreadUtils; import java.io.File; import java.io.FileNotFoundException; @@ -34,6 +38,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.concurrent.ExecutionException; /** * This class contains logic for starting activities to take/choose/crop photo, reads and transforms @@ -81,10 +86,17 @@ public class EditUserPhotoController { } if (requestCode == REQUEST_CODE_PICK_AVATAR) { + if (data.hasExtra(AvatarPickerActivity.EXTRA_DEFAULT_ICON_TINT_COLOR)) { + int tintColor = + data.getIntExtra(AvatarPickerActivity.EXTRA_DEFAULT_ICON_TINT_COLOR, -1); + onDefaultIconSelected(tintColor); + return true; + } if (data.getData() != null) { onPhotoCropped(data.getData()); + return true; } - return true; + } return false; } @@ -99,36 +111,54 @@ public class EditUserPhotoController { mActivityStarter.startActivityForResult(intent, REQUEST_CODE_PICK_AVATAR); } + private void onDefaultIconSelected(int tintColor) { + try { + ThreadUtils.postOnBackgroundThread(() -> { + Drawable drawable = + UserIcons.getDefaultUserIconInColor(mActivity.getResources(), tintColor); + Bitmap bitmap = convertToBitmap(drawable, + (int) mActivity.getResources().getDimension(R.dimen.circle_avatar_size)); + + ThreadUtils.postOnMainThread(() -> onPhotoProcessed(bitmap)); + }).get(); + } catch (InterruptedException | ExecutionException e) { + Log.e(TAG, "Error processing default icon", e); + } + } + + private static Bitmap convertToBitmap(@NonNull Drawable icon, int size) { + Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + icon.setBounds(0, 0, size, size); + icon.draw(canvas); + return bitmap; + } + 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); - } + ThreadUtils.postOnBackgroundThread(() -> { + InputStream imageStream = null; + Bitmap bitmap = null; + try { + imageStream = mActivity.getContentResolver() + .openInputStream(data); + bitmap = BitmapFactory.decodeStream(imageStream); + } catch (FileNotFoundException fe) { + Log.w(TAG, "Cannot find image file", fe); + } 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); - + if (bitmap != null) { + Bitmap finalBitmap = bitmap; + ThreadUtils.postOnMainThread(() -> onPhotoProcessed(finalBitmap)); } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null); + }); } private void onPhotoProcessed(Bitmap bitmap) {