blob: 4ce88ee115a28495095f36362468647a56f1f8ca [file] [log] [blame]
/*
* 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<ResolveInfo> 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();
}
}
}