/* * 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.content.pm.PackageManager; import android.content.pm.ResolveInfo; 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.StrictMode; import android.provider.MediaStore; import android.util.EventLog; import android.util.Log; import androidx.core.content.FileProvider; import com.android.settingslib.utils.ThreadUtils; 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.List; import java.util.concurrent.ExecutionException; class AvatarPhotoController { interface AvatarUi { boolean isFinishing(); void returnUriResult(Uri uri); void startActivityForResult(Intent intent, int resultCode); boolean startSystemActivityForResult(Intent intent, int resultCode); int getPhotoSize(); } interface ContextInjector { File getCacheDir(); Uri createTempImageUri(File parentDir, String fileName, boolean purge); ContentResolver getContentResolver(); } private static final String TAG = "AvatarPhotoController"; static final int REQUEST_CODE_CHOOSE_PHOTO = 1001; static final int REQUEST_CODE_TAKE_PHOTO = 1002; static final int REQUEST_CODE_CROP_PHOTO = 1003; /** * Delay to allow the photo picker exit animation to complete before the crop activity opens. */ private static final long DELAY_BEFORE_CROP_MILLIS = 150; private static final String IMAGES_DIR = "multi_user"; private static final String PRE_CROP_PICTURE_FILE_NAME = "PreCropEditUserPhoto.jpg"; 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 AvatarUi mAvatarUi; private final ContextInjector mContextInjector; private final File mImagesDir; private final Uri mPreCropPictureUri; private final Uri mCropPictureUri; private final Uri mTakePictureUri; AvatarPhotoController(AvatarUi avatarUi, ContextInjector contextInjector, boolean waiting) { mAvatarUi = avatarUi; mContextInjector = contextInjector; mImagesDir = new File(mContextInjector.getCacheDir(), IMAGES_DIR); mImagesDir.mkdir(); mPreCropPictureUri = mContextInjector .createTempImageUri(mImagesDir, PRE_CROP_PICTURE_FILE_NAME, !waiting); mCropPictureUri = mContextInjector.createTempImageUri(mImagesDir, CROP_PICTURE_FILE_NAME, !waiting); mTakePictureUri = mContextInjector.createTempImageUri(mImagesDir, TAKE_PICTURE_FILE_NAME, !waiting); mPhotoSize = mAvatarUi.getPhotoSize(); } /** * 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: mAvatarUi.returnUriResult(pictureUri); return true; case REQUEST_CODE_TAKE_PHOTO: if (mTakePictureUri.equals(pictureUri)) { cropPhoto(pictureUri); } else { copyAndCropPhoto(pictureUri, false); } return true; case REQUEST_CODE_CHOOSE_PHOTO: copyAndCropPhoto(pictureUri, true); return true; } return false; } void takePhoto() { Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE_SECURE); appendOutputExtra(intent, mTakePictureUri); mAvatarUi.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO); } void choosePhoto() { Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES, null); intent.setType("image/*"); mAvatarUi.startActivityForResult(intent, REQUEST_CODE_CHOOSE_PHOTO); } private void copyAndCropPhoto(final Uri pictureUri, boolean delayBeforeCrop) { try { ThreadUtils.postOnBackgroundThread(() -> { final ContentResolver cr = mContextInjector.getContentResolver(); try (InputStream in = cr.openInputStream(pictureUri); OutputStream out = cr.openOutputStream(mPreCropPictureUri)) { Streams.copy(in, out); } catch (IOException e) { Log.w(TAG, "Failed to copy photo", e); return; } Runnable cropRunnable = () -> { if (!mAvatarUi.isFinishing()) { cropPhoto(mPreCropPictureUri); } }; if (delayBeforeCrop) { ThreadUtils.postOnMainThreadDelayed(cropRunnable, DELAY_BEFORE_CROP_MILLIS); } else { ThreadUtils.postOnMainThread(cropRunnable); } }).get(); } catch (InterruptedException | ExecutionException e) { Log.e(TAG, "Error performing copy-and-crop", e); } } private void cropPhoto(final Uri pictureUri) { // TODO: Use a public intent, when there is one. Intent intent = new Intent("com.android.camera.action.CROP"); intent.setDataAndType(pictureUri, "image/*"); appendOutputExtra(intent, mCropPictureUri); appendCropExtras(intent); try { StrictMode.disableDeathOnFileUriExposure(); if (mAvatarUi.startSystemActivityForResult(intent, REQUEST_CODE_CROP_PHOTO)) { return; } } finally { StrictMode.enableDeathOnFileUriExposure(); } onPhotoNotCropped(pictureUri); } 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) { try { ThreadUtils.postOnBackgroundThread(() -> { // 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 = mContextInjector.getContentResolver() .openInputStream(data); fullImage = BitmapFactory.decodeStream(imageStream); } catch (FileNotFoundException fe) { return; } if (fullImage != null) { int rotation = getRotation(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()); saveBitmapToFile(croppedImage, new File(mImagesDir, CROP_PICTURE_FILE_NAME)); ThreadUtils.postOnMainThread(() -> { mAvatarUi.returnUriResult(mCropPictureUri); }); } }).get(); } catch (InterruptedException | ExecutionException e) { Log.e(TAG, "Error performing internal crop", e); } } /** * Reads the image's exif data and determines the rotation degree needed to display the image * in portrait mode. */ private int getRotation(Uri selectedImage) { int rotation = -1; try { InputStream imageStream = mContextInjector.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); } } static class AvatarUiImpl implements AvatarUi { private final AvatarPickerActivity mActivity; AvatarUiImpl(AvatarPickerActivity activity) { mActivity = activity; } @Override public boolean isFinishing() { return mActivity.isFinishing() || mActivity.isDestroyed(); } @Override public void returnUriResult(Uri uri) { mActivity.returnUriResult(uri); } @Override public void startActivityForResult(Intent intent, int resultCode) { mActivity.startActivityForResult(intent, resultCode); } @Override public boolean startSystemActivityForResult(Intent intent, int code) { List resolveInfos = mActivity.getPackageManager() .queryIntentActivities(intent, PackageManager.MATCH_SYSTEM_ONLY); if (resolveInfos.isEmpty()) { Log.w(TAG, "No system package activity could be found for code " + code); return false; } intent.setPackage(resolveInfos.get(0).activityInfo.packageName); mActivity.startActivityForResult(intent, code); return true; } @Override public int getPhotoSize() { return mActivity.getResources() .getDimensionPixelSize(com.android.internal.R.dimen.user_icon_size); } } static class ContextInjectorImpl implements ContextInjector { private final Context mContext; private final String mFileAuthority; ContextInjectorImpl(Context context, String fileAuthority) { mContext = context; mFileAuthority = fileAuthority; } @Override public File getCacheDir() { return mContext.getCacheDir(); } @Override public Uri createTempImageUri(File parentDir, String fileName, boolean purge) { final File fullPath = new File(parentDir, fileName); if (purge) { fullPath.delete(); } return FileProvider.getUriForFile(mContext, mFileAuthority, fullPath); } @Override public ContentResolver getContentResolver() { return mContext.getContentResolver(); } } }