Merge changes from topic "avatarpicker1"

* changes:
  Offer colored default avatars if no preselected images are provided.
  Support taking or choosing a photo in avatar picker.
  Add avatar picker that shows a predefined set of images.
This commit is contained in:
Oli Lan
2022-01-28 19:09:23 +00:00
committed by Android (Google) Code Review
28 changed files with 1153 additions and 398 deletions

View File

@@ -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;
}
}

View File

@@ -50,6 +50,7 @@ android_library {
"SettingsLibSettingsTransition",
"SettingsLibActivityEmbedding",
"SettingsLibButtonPreference",
"setupdesign",
],
// ANDROIDMK TRANSLATION ERROR: unsupported assignment to LOCAL_SHARED_JAVA_LIBRARIES

View File

@@ -18,4 +18,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.android.settingslib">
<application>
<activity
android:name="com.android.settingslib.users.AvatarPickerActivity"
android:theme="@style/SudThemeGlifV2.DayNight"/>
</application>
</manifest>

View File

@@ -0,0 +1,30 @@
<!--
~ 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.
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<stroke
android:width="2dp"
android:color="?android:attr/colorPrimary"/>
</shape>
</item>
<item
android:left="@dimen/avatar_picker_icon_inset"
android:right="@dimen/avatar_picker_icon_inset"
android:top="@dimen/avatar_picker_icon_inset"
android:bottom="@dimen/avatar_picker_icon_inset"
android:drawable="@drawable/ic_avatar_choose_photo"/>
</layer-list>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="oval">
<stroke
android:color="?android:attr/colorPrimary"
android:width="@dimen/avatar_picker_padding"/>
</shape>
</item>
</selector>

View File

@@ -0,0 +1,30 @@
<!--
~ 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.
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<stroke
android:width="2dp"
android:color="?android:attr/colorPrimary"/>
</shape>
</item>
<item
android:left="@dimen/avatar_picker_icon_inset"
android:right="@dimen/avatar_picker_icon_inset"
android:top="@dimen/avatar_picker_icon_inset"
android:bottom="@dimen/avatar_picker_icon_inset"
android:drawable="@drawable/ic_avatar_take_photo"/>
</layer-list>

View File

@@ -0,0 +1,25 @@
<!--
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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?android:attr/colorPrimary"
android:pathData="M5.85,17.1q1.275,-0.975 2.85,-1.538Q10.275,15 12,15q1.725,0 3.3,0.563 1.575,0.562 2.85,1.537 0.875,-1.025 1.363,-2.325Q20,13.475 20,12q0,-3.325 -2.337,-5.662Q15.325,4 12,4T6.338,6.338Q4,8.675 4,12q0,1.475 0.487,2.775 0.488,1.3 1.363,2.325zM12,13q-1.475,0 -2.488,-1.012Q8.5,10.975 8.5,9.5t1.012,-2.487Q10.525,6 12,6t2.488,1.013Q15.5,8.024 15.5,9.5t-1.012,2.488Q13.475,13 12,13zM12,22q-2.075,0 -3.9,-0.788 -1.825,-0.787 -3.175,-2.137 -1.35,-1.35 -2.137,-3.175Q2,14.075 2,12t0.788,-3.9q0.787,-1.825 2.137,-3.175 1.35,-1.35 3.175,-2.137Q9.925,2 12,2t3.9,0.788q1.825,0.787 3.175,2.137 1.35,1.35 2.137,3.175Q22,9.925 22,12t-0.788,3.9q-0.787,1.825 -2.137,3.175 -1.35,1.35 -3.175,2.137Q14.075,22 12,22zM12,20q1.325,0 2.5,-0.387 1.175,-0.388 2.15,-1.113 -0.975,-0.725 -2.15,-1.113Q13.325,17 12,17t-2.5,0.387q-1.175,0.388 -2.15,1.113 0.975,0.725 2.15,1.113Q10.675,20 12,20zM12,11q0.65,0 1.075,-0.425 0.425,-0.425 0.425,-1.075 0,-0.65 -0.425,-1.075Q12.65,8 12,8q-0.65,0 -1.075,0.425Q10.5,8.85 10.5,9.5q0,0.65 0.425,1.075Q11.35,11 12,11zM12,9.5zM12,18.5z"/>
</vector>

View File

@@ -0,0 +1,24 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?android:attr/colorPrimary"
android:pathData="M9,14h10l-3.45,-4.5 -2.3,3 -1.55,-2zM8,18q-0.825,0 -1.412,-0.587Q6,16.825 6,16L6,4q0,-0.825 0.588,-1.413Q7.175,2 8,2h12q0.825,0 1.413,0.587Q22,3.175 22,4v12q0,0.825 -0.587,1.413Q20.825,18 20,18zM8,16h12L20,4L8,4v12zM4,22q-0.825,0 -1.413,-0.587Q2,20.825 2,20L2,6h2v14h14v2zM8,4v12L8,4z"/>
</vector>

View File

@@ -0,0 +1,24 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?android:attr/colorPrimary"
android:pathData="M12,17.5q1.875,0 3.188,-1.313Q16.5,14.876 16.5,13q0,-1.875 -1.313,-3.188Q13.876,8.5 12,8.5q-1.875,0 -3.188,1.313Q7.5,11.124 7.5,13q0,1.875 1.313,3.188Q10.124,17.5 12,17.5zM4,21q-0.825,0 -1.413,-0.587Q2,19.825 2,19L2,7q0,-0.825 0.587,-1.412Q3.175,5 4,5h3.15L9,3h6l1.85,2L20,5q0.825,0 1.413,0.588Q22,6.175 22,7v12q0,0.825 -0.587,1.413Q20.825,21 20,21zM20,19L20,7L4,7v12zM4,19L4,7v12z"/>
</vector>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/avatar_image"
android:layout_height="@dimen/avatar_size_in_picker"
android:layout_width="@dimen/avatar_size_in_picker"
android:layout_margin="@dimen/avatar_picker_margin"
android:layout_gravity="center"
android:padding="@dimen/avatar_picker_padding"
android:background="@drawable/avatar_selector"/>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<com.google.android.setupdesign.GlifLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/glif_layout"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:icon="@drawable/ic_account_circle_outline"
app:sucUsePartnerResource="true"
app:sucHeaderText="@string/avatar_picker_title">
<LinearLayout
android:layout_height="match_parent"
android:layout_width="match_parent"
android:gravity="center_horizontal"
style="@style/SudContentFrame">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/avatar_grid"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
</com.google.android.setupdesign.GlifLayout>

View File

@@ -0,0 +1,21 @@
<!--
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.
-->
<resources>
<integer name="avatar_picker_columns">4</integer>
<dimen name="avatar_size_in_picker">104dp</dimen>
<dimen name="avatar_picker_padding">8dp</dimen>
<dimen name="avatar_picker_margin">3dp</dimen>
</resources>

View File

@@ -0,0 +1,21 @@
<!--
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.
-->
<resources>
<integer name="avatar_picker_columns">4</integer>
<dimen name="avatar_size_in_picker">128dp</dimen>
<dimen name="avatar_picker_padding">8dp</dimen>
<dimen name="avatar_picker_margin">4dp</dimen>
</resources>

View File

@@ -0,0 +1,21 @@
<!--
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.
-->
<resources>
<integer name="avatar_picker_columns">4</integer>
<dimen name="avatar_size_in_picker">148dp</dimen>
<dimen name="avatar_picker_padding">8dp</dimen>
<dimen name="avatar_picker_margin">6dp</dimen>
</resources>

View File

@@ -0,0 +1,21 @@
<!--
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.
-->
<resources>
<integer name="avatar_picker_columns">3</integer>
<dimen name="avatar_size_in_picker">112dp</dimen>
<dimen name="avatar_picker_padding">8dp</dimen>
<dimen name="avatar_picker_margin">3dp</dimen>
</resources>

View File

@@ -0,0 +1,21 @@
<!--
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.
-->
<resources>
<integer name="avatar_picker_columns">4</integer>
<dimen name="avatar_size_in_picker">104dp</dimen>
<dimen name="avatar_picker_padding">8dp</dimen>
<dimen name="avatar_picker_margin">3dp</dimen>
</resources>

View File

@@ -0,0 +1,21 @@
<!--
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.
-->
<resources>
<integer name="avatar_picker_columns">4</integer>
<dimen name="avatar_size_in_picker">128dp</dimen>
<dimen name="avatar_picker_padding">8dp</dimen>
<dimen name="avatar_picker_margin">4dp</dimen>
</resources>

View File

@@ -0,0 +1,21 @@
<!--
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.
-->
<resources>
<integer name="avatar_picker_columns">4</integer>
<dimen name="avatar_size_in_picker">148dp</dimen>
<dimen name="avatar_picker_padding">8dp</dimen>
<dimen name="avatar_picker_margin">6dp</dimen>
</resources>

View File

@@ -0,0 +1,21 @@
<!--
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.
-->
<resources>
<integer name="avatar_picker_columns">4</integer>
<dimen name="avatar_size_in_picker">96dp</dimen>
<dimen name="avatar_picker_padding">6dp</dimen>
<dimen name="avatar_picker_margin">2dp</dimen>
</resources>

View File

@@ -647,4 +647,6 @@
<item>disabled</item>
</array>
<array name="avatar_images"/>
</resources>

View File

@@ -102,4 +102,11 @@
<dimen name="user_photo_size_in_profile_info_dialog">112dp</dimen>
<dimen name="add_a_photo_icon_size_in_profile_info_dialog">32dp</dimen>
<integer name="avatar_picker_columns">3</integer>
<dimen name="avatar_size_in_picker">96dp</dimen>
<dimen name="avatar_picker_padding">6dp</dimen>
<dimen name="avatar_picker_margin">2dp</dimen>
<dimen name="avatar_picker_icon_inset">25dp</dimen>
</resources>

View File

@@ -1548,4 +1548,7 @@
<!-- Content description of the no calling for accessibility (not shown on the screen). [CHAR LIMIT=NONE] -->
<string name="accessibility_no_calling">No calling.</string>
<!-- Title for a screen allowing the user to choose a profile picture. [CHAR LIMIT=NONE] -->
<string name="avatar_picker_title">Choose a profile picture</string>
</resources>

View File

@@ -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.returnUriResult(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<Void, Void, Void>() {
@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<Void, Void, Bitmap>() {
@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.returnUriResult(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);
}
}

View File

@@ -0,0 +1,334 @@
/*
* 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.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;
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.internal.util.UserIcons;
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;
import java.util.ArrayList;
import java.util.List;
/**
* Activity to allow the user to choose a user profile picture.
*
* <p>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.
*
* <p>If preselected images are not provided, the default avatar will be shown instead, in a range
* of colors.
*
* <p>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";
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();
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 =
new FooterButton.Builder(this)
.setText("Cancel")
.setListener(view -> cancel())
.build();
mDoneButton =
new FooterButton.Builder(this)
.setText("Done")
.setListener(view -> mAdapter.returnSelectionResult())
.build();
mDoneButton.setEnabled(false);
mixin.setSecondaryButton(secondaryButton);
mixin.setPrimaryButton(mDoneButton);
}
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 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();
}
private class AvatarAdapter extends RecyclerView.Adapter<AvatarViewHolder> {
private static final int NONE = -1;
private final int mTakePhotoPosition;
private final int mChoosePhotoPosition;
private final int mPreselectedImageStartPosition;
private final List<Drawable> mImageDrawables;
private final TypedArray mPreselectedImages;
private final int[] mUserIconColors;
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);
mPreselectedImages = getResources().obtainTypedArray(R.array.avatar_images);
mUserIconColors = UserIcons.getUserIconColors(getResources());
mImageDrawables = buildDrawableList();
}
@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) {
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) {
viewHolder.setSelected(position == mSelectedPosition);
viewHolder.setDrawable(mImageDrawables.get(indexFromPosition(position)));
viewHolder.setClickListener(view -> {
if (mSelectedPosition == position) {
deselect(position);
} else {
select(position);
}
});
}
}
@Override
public int getItemCount() {
return mPreselectedImageStartPosition + mImageDrawables.size();
}
private List<Drawable> buildDrawableList() {
List<Drawable> 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) {
Bitmap bitmap = drawable.getBitmap();
RoundedBitmapDrawable roundedBitmapDrawable =
RoundedBitmapDrawableFactory.create(getResources(), bitmap);
roundedBitmapDrawable.setCircular(true);
return roundedBitmapDrawable;
}
private int indexFromPosition(int position) {
return position - mPreselectedImageStartPosition;
}
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 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]);
}
}
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 {
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);
}
}
}

View File

@@ -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,16 +214,21 @@ 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
EditUserPhotoController createEditUserPhotoController(Activity activity,
ActivityStarter activityStarter, ImageView userPhotoView) {
return new EditUserPhotoController(activity, activityStarter, userPhotoView,
mSavedPhoto, mWaitingForActivityResult, mFileAuthority);
mSavedPhoto, mFileAuthority);
}
}

View File

@@ -16,46 +16,21 @@
package com.android.settingslib.users;
import android.annotation.NonNull;
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.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.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 libcore.io.Streams;
import com.android.settingslib.utils.ThreadUtils;
import java.io.File;
import java.io.FileNotFoundException;
@@ -63,8 +38,7 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
/**
* This class contains logic for starting activities to take/choose/crop photo, reads and transforms
@@ -75,45 +49,30 @@ 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;
// 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 int REQUEST_CODE_PICK_AVATAR = 1004;
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.setOnClickListener(v -> showUpdatePhotoPopup());
mImageView = view;
mImageView.setOnClickListener(v -> showAvatarPicker());
mNewUserPhotoBitmap = bitmap;
}
@@ -125,32 +84,19 @@ 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;
}
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;
}
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;
}
@@ -159,224 +105,60 @@ public class EditUserPhotoController {
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<EditUserPhotoController.RestrictedMenuItem> 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 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 takePhoto() {
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE_SECURE);
appendOutputExtra(intent, mTakePictureUri);
mActivityStarter.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO);
}
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));
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<Void, Void, Void>() {
@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);
ThreadUtils.postOnMainThread(() -> onPhotoProcessed(bitmap));
}).get();
} catch (InterruptedException | ExecutionException e) {
Log.e(TAG, "Error processing default icon", e);
}
}
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 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<Void, Void, Bitmap>() {
@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 onPhotoNotCropped(final Uri data) {
// TODO: Replace AsyncTask to avoid possible memory leaks and handle configuration change
new AsyncTask<Void, Void, Bitmap>() {
@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) {
@@ -386,29 +168,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() {
@@ -435,84 +194,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<RestrictedMenuItem> {
RestrictedPopupMenuAdapter(Context context, List<RestrictedMenuItem> 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;
}
}
}

View File

@@ -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;

View File

@@ -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);