1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.settingslib.users; 18 19 import android.app.Activity; 20 import android.content.ClipData; 21 import android.content.ContentResolver; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.content.pm.PackageManager; 25 import android.content.pm.ResolveInfo; 26 import android.graphics.Bitmap; 27 import android.graphics.BitmapFactory; 28 import android.graphics.Canvas; 29 import android.graphics.Matrix; 30 import android.graphics.Paint; 31 import android.graphics.RectF; 32 import android.media.ExifInterface; 33 import android.net.Uri; 34 import android.os.StrictMode; 35 import android.provider.MediaStore; 36 import android.util.EventLog; 37 import android.util.Log; 38 39 import androidx.core.content.FileProvider; 40 41 import com.android.settingslib.utils.ThreadUtils; 42 43 import libcore.io.Streams; 44 45 import java.io.File; 46 import java.io.FileNotFoundException; 47 import java.io.FileOutputStream; 48 import java.io.IOException; 49 import java.io.InputStream; 50 import java.io.OutputStream; 51 import java.util.List; 52 import java.util.concurrent.ExecutionException; 53 54 class AvatarPhotoController { 55 56 interface AvatarUi { isFinishing()57 boolean isFinishing(); 58 returnUriResult(Uri uri)59 void returnUriResult(Uri uri); 60 startActivityForResult(Intent intent, int resultCode)61 void startActivityForResult(Intent intent, int resultCode); 62 startSystemActivityForResult(Intent intent, int resultCode)63 boolean startSystemActivityForResult(Intent intent, int resultCode); 64 getPhotoSize()65 int getPhotoSize(); 66 } 67 68 interface ContextInjector { getCacheDir()69 File getCacheDir(); 70 createTempImageUri(File parentDir, String fileName, boolean purge)71 Uri createTempImageUri(File parentDir, String fileName, boolean purge); 72 getContentResolver()73 ContentResolver getContentResolver(); 74 } 75 76 private static final String TAG = "AvatarPhotoController"; 77 78 static final int REQUEST_CODE_CHOOSE_PHOTO = 1001; 79 static final int REQUEST_CODE_TAKE_PHOTO = 1002; 80 static final int REQUEST_CODE_CROP_PHOTO = 1003; 81 82 /** 83 * Delay to allow the photo picker exit animation to complete before the crop activity opens. 84 */ 85 private static final long DELAY_BEFORE_CROP_MILLIS = 150; 86 87 private static final String IMAGES_DIR = "multi_user"; 88 private static final String PRE_CROP_PICTURE_FILE_NAME = "PreCropEditUserPhoto.jpg"; 89 private static final String CROP_PICTURE_FILE_NAME = "CropEditUserPhoto.jpg"; 90 private static final String TAKE_PICTURE_FILE_NAME = "TakeEditUserPhoto.jpg"; 91 92 private final int mPhotoSize; 93 94 private final AvatarUi mAvatarUi; 95 private final ContextInjector mContextInjector; 96 97 private final File mImagesDir; 98 private final Uri mPreCropPictureUri; 99 private final Uri mCropPictureUri; 100 private final Uri mTakePictureUri; 101 AvatarPhotoController(AvatarUi avatarUi, ContextInjector contextInjector, boolean waiting)102 AvatarPhotoController(AvatarUi avatarUi, ContextInjector contextInjector, boolean waiting) { 103 mAvatarUi = avatarUi; 104 mContextInjector = contextInjector; 105 106 mImagesDir = new File(mContextInjector.getCacheDir(), IMAGES_DIR); 107 mImagesDir.mkdir(); 108 mPreCropPictureUri = mContextInjector 109 .createTempImageUri(mImagesDir, PRE_CROP_PICTURE_FILE_NAME, !waiting); 110 mCropPictureUri = 111 mContextInjector.createTempImageUri(mImagesDir, CROP_PICTURE_FILE_NAME, !waiting); 112 mTakePictureUri = 113 mContextInjector.createTempImageUri(mImagesDir, TAKE_PICTURE_FILE_NAME, !waiting); 114 mPhotoSize = mAvatarUi.getPhotoSize(); 115 } 116 117 /** 118 * Handles activity result from containing activity/fragment after a take/choose/crop photo 119 * action result is received. 120 */ onActivityResult(int requestCode, int resultCode, Intent data)121 public boolean onActivityResult(int requestCode, int resultCode, Intent data) { 122 if (resultCode != Activity.RESULT_OK) { 123 return false; 124 } 125 final Uri pictureUri = data != null && data.getData() != null 126 ? data.getData() : mTakePictureUri; 127 128 // Check if the result is a content uri 129 if (!ContentResolver.SCHEME_CONTENT.equals(pictureUri.getScheme())) { 130 Log.e(TAG, "Invalid pictureUri scheme: " + pictureUri.getScheme()); 131 EventLog.writeEvent(0x534e4554, "172939189", -1, pictureUri.getPath()); 132 return false; 133 } 134 135 switch (requestCode) { 136 case REQUEST_CODE_CROP_PHOTO: 137 mAvatarUi.returnUriResult(pictureUri); 138 return true; 139 case REQUEST_CODE_TAKE_PHOTO: 140 if (mTakePictureUri.equals(pictureUri)) { 141 cropPhoto(pictureUri); 142 } else { 143 copyAndCropPhoto(pictureUri, false); 144 } 145 return true; 146 case REQUEST_CODE_CHOOSE_PHOTO: 147 copyAndCropPhoto(pictureUri, true); 148 return true; 149 } 150 return false; 151 } 152 takePhoto()153 void takePhoto() { 154 Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE_SECURE); 155 appendOutputExtra(intent, mTakePictureUri); 156 mAvatarUi.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO); 157 } 158 choosePhoto()159 void choosePhoto() { 160 Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES, null); 161 intent.setType("image/*"); 162 mAvatarUi.startActivityForResult(intent, REQUEST_CODE_CHOOSE_PHOTO); 163 } 164 copyAndCropPhoto(final Uri pictureUri, boolean delayBeforeCrop)165 private void copyAndCropPhoto(final Uri pictureUri, boolean delayBeforeCrop) { 166 try { 167 ThreadUtils.postOnBackgroundThread(() -> { 168 final ContentResolver cr = mContextInjector.getContentResolver(); 169 try (InputStream in = cr.openInputStream(pictureUri); 170 OutputStream out = cr.openOutputStream(mPreCropPictureUri)) { 171 Streams.copy(in, out); 172 } catch (IOException e) { 173 Log.w(TAG, "Failed to copy photo", e); 174 return; 175 } 176 Runnable cropRunnable = () -> { 177 if (!mAvatarUi.isFinishing()) { 178 cropPhoto(mPreCropPictureUri); 179 } 180 }; 181 if (delayBeforeCrop) { 182 ThreadUtils.postOnMainThreadDelayed(cropRunnable, DELAY_BEFORE_CROP_MILLIS); 183 } else { 184 ThreadUtils.postOnMainThread(cropRunnable); 185 } 186 187 }).get(); 188 } catch (InterruptedException | ExecutionException e) { 189 Log.e(TAG, "Error performing copy-and-crop", e); 190 } 191 } 192 cropPhoto(final Uri pictureUri)193 private void cropPhoto(final Uri pictureUri) { 194 // TODO: Use a public intent, when there is one. 195 Intent intent = new Intent("com.android.camera.action.CROP"); 196 intent.setDataAndType(pictureUri, "image/*"); 197 appendOutputExtra(intent, mCropPictureUri); 198 appendCropExtras(intent); 199 try { 200 StrictMode.disableDeathOnFileUriExposure(); 201 if (mAvatarUi.startSystemActivityForResult(intent, REQUEST_CODE_CROP_PHOTO)) { 202 return; 203 } 204 } finally { 205 StrictMode.enableDeathOnFileUriExposure(); 206 } 207 onPhotoNotCropped(pictureUri); 208 } 209 appendOutputExtra(Intent intent, Uri pictureUri)210 private void appendOutputExtra(Intent intent, Uri pictureUri) { 211 intent.putExtra(MediaStore.EXTRA_OUTPUT, pictureUri); 212 intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION 213 | Intent.FLAG_GRANT_READ_URI_PERMISSION); 214 intent.setClipData(ClipData.newRawUri(MediaStore.EXTRA_OUTPUT, pictureUri)); 215 } 216 appendCropExtras(Intent intent)217 private void appendCropExtras(Intent intent) { 218 intent.putExtra("crop", "true"); 219 intent.putExtra("scale", true); 220 intent.putExtra("scaleUpIfNeeded", true); 221 intent.putExtra("aspectX", 1); 222 intent.putExtra("aspectY", 1); 223 intent.putExtra("outputX", mPhotoSize); 224 intent.putExtra("outputY", mPhotoSize); 225 } 226 onPhotoNotCropped(final Uri data)227 private void onPhotoNotCropped(final Uri data) { 228 try { 229 ThreadUtils.postOnBackgroundThread(() -> { 230 // Scale and crop to a square aspect ratio 231 Bitmap croppedImage = Bitmap.createBitmap(mPhotoSize, mPhotoSize, 232 Bitmap.Config.ARGB_8888); 233 Canvas canvas = new Canvas(croppedImage); 234 Bitmap fullImage; 235 try { 236 InputStream imageStream = mContextInjector.getContentResolver() 237 .openInputStream(data); 238 fullImage = BitmapFactory.decodeStream(imageStream); 239 } catch (FileNotFoundException fe) { 240 return; 241 } 242 if (fullImage != null) { 243 int rotation = getRotation(data); 244 final int squareSize = Math.min(fullImage.getWidth(), 245 fullImage.getHeight()); 246 final int left = (fullImage.getWidth() - squareSize) / 2; 247 final int top = (fullImage.getHeight() - squareSize) / 2; 248 249 Matrix matrix = new Matrix(); 250 RectF rectSource = new RectF(left, top, 251 left + squareSize, top + squareSize); 252 RectF rectDest = new RectF(0, 0, mPhotoSize, mPhotoSize); 253 matrix.setRectToRect(rectSource, rectDest, Matrix.ScaleToFit.CENTER); 254 matrix.postRotate(rotation, mPhotoSize / 2f, mPhotoSize / 2f); 255 canvas.drawBitmap(fullImage, matrix, new Paint()); 256 saveBitmapToFile(croppedImage, new File(mImagesDir, CROP_PICTURE_FILE_NAME)); 257 258 ThreadUtils.postOnMainThread(() -> { 259 mAvatarUi.returnUriResult(mCropPictureUri); 260 }); 261 } 262 }).get(); 263 } catch (InterruptedException | ExecutionException e) { 264 Log.e(TAG, "Error performing internal crop", e); 265 } 266 } 267 268 /** 269 * Reads the image's exif data and determines the rotation degree needed to display the image 270 * in portrait mode. 271 */ getRotation(Uri selectedImage)272 private int getRotation(Uri selectedImage) { 273 int rotation = -1; 274 try { 275 InputStream imageStream = 276 mContextInjector.getContentResolver().openInputStream(selectedImage); 277 ExifInterface exif = new ExifInterface(imageStream); 278 rotation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1); 279 } catch (IOException exception) { 280 Log.e(TAG, "Error while getting rotation", exception); 281 } 282 283 switch (rotation) { 284 case ExifInterface.ORIENTATION_ROTATE_90: 285 return 90; 286 case ExifInterface.ORIENTATION_ROTATE_180: 287 return 180; 288 case ExifInterface.ORIENTATION_ROTATE_270: 289 return 270; 290 default: 291 return 0; 292 } 293 } 294 saveBitmapToFile(Bitmap bitmap, File file)295 private void saveBitmapToFile(Bitmap bitmap, File file) { 296 try { 297 OutputStream os = new FileOutputStream(file); 298 bitmap.compress(Bitmap.CompressFormat.PNG, 100, os); 299 os.flush(); 300 os.close(); 301 } catch (IOException e) { 302 Log.e(TAG, "Cannot create temp file", e); 303 } 304 } 305 306 static class AvatarUiImpl implements AvatarUi { 307 private final AvatarPickerActivity mActivity; 308 AvatarUiImpl(AvatarPickerActivity activity)309 AvatarUiImpl(AvatarPickerActivity activity) { 310 mActivity = activity; 311 } 312 313 @Override isFinishing()314 public boolean isFinishing() { 315 return mActivity.isFinishing() || mActivity.isDestroyed(); 316 } 317 318 @Override returnUriResult(Uri uri)319 public void returnUriResult(Uri uri) { 320 mActivity.returnUriResult(uri); 321 } 322 323 @Override startActivityForResult(Intent intent, int resultCode)324 public void startActivityForResult(Intent intent, int resultCode) { 325 mActivity.startActivityForResult(intent, resultCode); 326 } 327 328 @Override startSystemActivityForResult(Intent intent, int code)329 public boolean startSystemActivityForResult(Intent intent, int code) { 330 List<ResolveInfo> resolveInfos = mActivity.getPackageManager() 331 .queryIntentActivities(intent, PackageManager.MATCH_SYSTEM_ONLY); 332 if (resolveInfos.isEmpty()) { 333 Log.w(TAG, "No system package activity could be found for code " + code); 334 return false; 335 } 336 intent.setPackage(resolveInfos.get(0).activityInfo.packageName); 337 mActivity.startActivityForResult(intent, code); 338 return true; 339 } 340 341 @Override getPhotoSize()342 public int getPhotoSize() { 343 return mActivity.getResources() 344 .getDimensionPixelSize(com.android.internal.R.dimen.user_icon_size); 345 } 346 } 347 348 static class ContextInjectorImpl implements ContextInjector { 349 private final Context mContext; 350 private final String mFileAuthority; 351 ContextInjectorImpl(Context context, String fileAuthority)352 ContextInjectorImpl(Context context, String fileAuthority) { 353 mContext = context; 354 mFileAuthority = fileAuthority; 355 } 356 357 @Override getCacheDir()358 public File getCacheDir() { 359 return mContext.getCacheDir(); 360 } 361 362 @Override createTempImageUri(File parentDir, String fileName, boolean purge)363 public Uri createTempImageUri(File parentDir, String fileName, boolean purge) { 364 final File fullPath = new File(parentDir, fileName); 365 if (purge) { 366 fullPath.delete(); 367 } 368 return FileProvider.getUriForFile(mContext, mFileAuthority, fullPath); 369 } 370 371 @Override getContentResolver()372 public ContentResolver getContentResolver() { 373 return mContext.getContentResolver(); 374 } 375 } 376 } 377