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