1 /* 2 * Copyright (C) 2013 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.database.Cursor; 25 import android.graphics.Bitmap; 26 import android.graphics.Bitmap.Config; 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.graphics.drawable.Drawable; 33 import android.media.ExifInterface; 34 import android.net.Uri; 35 import android.os.AsyncTask; 36 import android.os.StrictMode; 37 import android.os.UserHandle; 38 import android.os.UserManager; 39 import android.provider.ContactsContract.DisplayPhoto; 40 import android.provider.MediaStore; 41 import android.util.EventLog; 42 import android.util.Log; 43 import android.view.Gravity; 44 import android.view.View; 45 import android.view.ViewGroup; 46 import android.widget.ArrayAdapter; 47 import android.widget.ImageView; 48 import android.widget.ListPopupWindow; 49 import android.widget.TextView; 50 51 import androidx.core.content.FileProvider; 52 53 import com.android.settingslib.R; 54 import com.android.settingslib.RestrictedLockUtils; 55 import com.android.settingslib.RestrictedLockUtilsInternal; 56 import com.android.settingslib.drawable.CircleFramedDrawable; 57 58 import libcore.io.Streams; 59 60 import java.io.File; 61 import java.io.FileNotFoundException; 62 import java.io.FileOutputStream; 63 import java.io.IOException; 64 import java.io.InputStream; 65 import java.io.OutputStream; 66 import java.util.ArrayList; 67 import java.util.List; 68 69 /** 70 * This class contains logic for starting activities to take/choose/crop photo, reads and transforms 71 * the result image. 72 */ 73 public class EditUserPhotoController { 74 private static final String TAG = "EditUserPhotoController"; 75 76 // It seems that this class generates custom request codes and they may 77 // collide with ours, these values are very unlikely to have a conflict. 78 private static final int REQUEST_CODE_CHOOSE_PHOTO = 1001; 79 private static final int REQUEST_CODE_TAKE_PHOTO = 1002; 80 private static final int REQUEST_CODE_CROP_PHOTO = 1003; 81 // in rare cases we get a null Cursor when querying for DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI 82 // so we need a default photo size 83 private static final int DEFAULT_PHOTO_SIZE = 500; 84 85 private static final String IMAGES_DIR = "multi_user"; 86 private static final String CROP_PICTURE_FILE_NAME = "CropEditUserPhoto.jpg"; 87 private static final String TAKE_PICTURE_FILE_NAME = "TakeEditUserPhoto.jpg"; 88 private static final String NEW_USER_PHOTO_FILE_NAME = "NewUserPhoto.png"; 89 90 private final int mPhotoSize; 91 92 private final Activity mActivity; 93 private final ActivityStarter mActivityStarter; 94 private final ImageView mImageView; 95 private final String mFileAuthority; 96 97 private final File mImagesDir; 98 private final Uri mCropPictureUri; 99 private final Uri mTakePictureUri; 100 101 private Bitmap mNewUserPhotoBitmap; 102 private Drawable mNewUserPhotoDrawable; 103 EditUserPhotoController(Activity activity, ActivityStarter activityStarter, ImageView view, Bitmap bitmap, boolean waiting, String fileAuthority)104 public EditUserPhotoController(Activity activity, ActivityStarter activityStarter, 105 ImageView view, Bitmap bitmap, boolean waiting, String fileAuthority) { 106 mActivity = activity; 107 mActivityStarter = activityStarter; 108 mImageView = view; 109 mFileAuthority = fileAuthority; 110 111 mImagesDir = new File(activity.getCacheDir(), IMAGES_DIR); 112 mImagesDir.mkdir(); 113 mCropPictureUri = createTempImageUri(activity, CROP_PICTURE_FILE_NAME, !waiting); 114 mTakePictureUri = createTempImageUri(activity, TAKE_PICTURE_FILE_NAME, !waiting); 115 mPhotoSize = getPhotoSize(activity); 116 mImageView.setOnClickListener(v -> showUpdatePhotoPopup()); 117 mNewUserPhotoBitmap = bitmap; 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 onPhotoCropped(pictureUri); 141 return true; 142 case REQUEST_CODE_TAKE_PHOTO: 143 case REQUEST_CODE_CHOOSE_PHOTO: 144 if (mTakePictureUri.equals(pictureUri)) { 145 if (PhotoCapabilityUtils.canCropPhoto(mActivity)) { 146 cropPhoto(); 147 } else { 148 onPhotoNotCropped(pictureUri); 149 } 150 } else { 151 copyAndCropPhoto(pictureUri); 152 } 153 return true; 154 } 155 return false; 156 } 157 getNewUserPhotoDrawable()158 public Drawable getNewUserPhotoDrawable() { 159 return mNewUserPhotoDrawable; 160 } 161 showUpdatePhotoPopup()162 private void showUpdatePhotoPopup() { 163 final Context context = mImageView.getContext(); 164 final boolean canTakePhoto = PhotoCapabilityUtils.canTakePhoto(context); 165 final boolean canChoosePhoto = PhotoCapabilityUtils.canChoosePhoto(context); 166 167 if (!canTakePhoto && !canChoosePhoto) { 168 return; 169 } 170 171 final List<EditUserPhotoController.RestrictedMenuItem> items = new ArrayList<>(); 172 173 if (canTakePhoto) { 174 final String title = context.getString(R.string.user_image_take_photo); 175 items.add(new RestrictedMenuItem(context, title, UserManager.DISALLOW_SET_USER_ICON, 176 this::takePhoto)); 177 } 178 179 if (canChoosePhoto) { 180 final String title = context.getString(R.string.user_image_choose_photo); 181 items.add(new RestrictedMenuItem(context, title, UserManager.DISALLOW_SET_USER_ICON, 182 this::choosePhoto)); 183 } 184 185 final ListPopupWindow listPopupWindow = new ListPopupWindow(context); 186 187 listPopupWindow.setAnchorView(mImageView); 188 listPopupWindow.setModal(true); 189 listPopupWindow.setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED); 190 listPopupWindow.setAdapter(new RestrictedPopupMenuAdapter(context, items)); 191 192 final int width = Math.max(mImageView.getWidth(), context.getResources() 193 .getDimensionPixelSize(R.dimen.update_user_photo_popup_min_width)); 194 listPopupWindow.setWidth(width); 195 listPopupWindow.setDropDownGravity(Gravity.START); 196 197 listPopupWindow.setOnItemClickListener((parent, view, position, id) -> { 198 listPopupWindow.dismiss(); 199 final RestrictedMenuItem item = 200 (RestrictedMenuItem) parent.getAdapter().getItem(position); 201 item.doAction(); 202 }); 203 204 listPopupWindow.show(); 205 } 206 takePhoto()207 private void takePhoto() { 208 Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE_SECURE); 209 appendOutputExtra(intent, mTakePictureUri); 210 mActivityStarter.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO); 211 } 212 choosePhoto()213 private void choosePhoto() { 214 Intent intent = new Intent(Intent.ACTION_GET_CONTENT, null); 215 intent.setType("image/*"); 216 appendOutputExtra(intent, mTakePictureUri); 217 mActivityStarter.startActivityForResult(intent, REQUEST_CODE_CHOOSE_PHOTO); 218 } 219 copyAndCropPhoto(final Uri pictureUri)220 private void copyAndCropPhoto(final Uri pictureUri) { 221 // TODO: Replace AsyncTask 222 new AsyncTask<Void, Void, Void>() { 223 @Override 224 protected Void doInBackground(Void... params) { 225 final ContentResolver cr = mActivity.getContentResolver(); 226 try (InputStream in = cr.openInputStream(pictureUri); 227 OutputStream out = cr.openOutputStream(mTakePictureUri)) { 228 Streams.copy(in, out); 229 } catch (IOException e) { 230 Log.w(TAG, "Failed to copy photo", e); 231 } 232 return null; 233 } 234 235 @Override 236 protected void onPostExecute(Void result) { 237 if (!mActivity.isFinishing() && !mActivity.isDestroyed()) { 238 cropPhoto(); 239 } 240 } 241 }.execute(); 242 } 243 cropPhoto()244 private void cropPhoto() { 245 // TODO: Use a public intent, when there is one. 246 Intent intent = new Intent("com.android.camera.action.CROP"); 247 intent.setDataAndType(mTakePictureUri, "image/*"); 248 appendOutputExtra(intent, mCropPictureUri); 249 appendCropExtras(intent); 250 if (intent.resolveActivity(mActivity.getPackageManager()) != null) { 251 try { 252 StrictMode.disableDeathOnFileUriExposure(); 253 mActivityStarter.startActivityForResult(intent, REQUEST_CODE_CROP_PHOTO); 254 } finally { 255 StrictMode.enableDeathOnFileUriExposure(); 256 } 257 } else { 258 onPhotoNotCropped(mTakePictureUri); 259 } 260 } 261 appendOutputExtra(Intent intent, Uri pictureUri)262 private void appendOutputExtra(Intent intent, Uri pictureUri) { 263 intent.putExtra(MediaStore.EXTRA_OUTPUT, pictureUri); 264 intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION 265 | Intent.FLAG_GRANT_READ_URI_PERMISSION); 266 intent.setClipData(ClipData.newRawUri(MediaStore.EXTRA_OUTPUT, pictureUri)); 267 } 268 appendCropExtras(Intent intent)269 private void appendCropExtras(Intent intent) { 270 intent.putExtra("crop", "true"); 271 intent.putExtra("scale", true); 272 intent.putExtra("scaleUpIfNeeded", true); 273 intent.putExtra("aspectX", 1); 274 intent.putExtra("aspectY", 1); 275 intent.putExtra("outputX", mPhotoSize); 276 intent.putExtra("outputY", mPhotoSize); 277 } 278 onPhotoCropped(final Uri data)279 private void onPhotoCropped(final Uri data) { 280 // TODO: Replace AsyncTask to avoid possible memory leaks and handle configuration change 281 new AsyncTask<Void, Void, Bitmap>() { 282 @Override 283 protected Bitmap doInBackground(Void... params) { 284 InputStream imageStream = null; 285 try { 286 imageStream = mActivity.getContentResolver() 287 .openInputStream(data); 288 return BitmapFactory.decodeStream(imageStream); 289 } catch (FileNotFoundException fe) { 290 Log.w(TAG, "Cannot find image file", fe); 291 return null; 292 } finally { 293 if (imageStream != null) { 294 try { 295 imageStream.close(); 296 } catch (IOException ioe) { 297 Log.w(TAG, "Cannot close image stream", ioe); 298 } 299 } 300 } 301 } 302 303 @Override 304 protected void onPostExecute(Bitmap bitmap) { 305 onPhotoProcessed(bitmap); 306 307 } 308 }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null); 309 } 310 onPhotoNotCropped(final Uri data)311 private void onPhotoNotCropped(final Uri data) { 312 // TODO: Replace AsyncTask to avoid possible memory leaks and handle configuration change 313 new AsyncTask<Void, Void, Bitmap>() { 314 @Override 315 protected Bitmap doInBackground(Void... params) { 316 // Scale and crop to a square aspect ratio 317 Bitmap croppedImage = Bitmap.createBitmap(mPhotoSize, mPhotoSize, 318 Config.ARGB_8888); 319 Canvas canvas = new Canvas(croppedImage); 320 Bitmap fullImage; 321 try { 322 InputStream imageStream = mActivity.getContentResolver() 323 .openInputStream(data); 324 fullImage = BitmapFactory.decodeStream(imageStream); 325 } catch (FileNotFoundException fe) { 326 return null; 327 } 328 if (fullImage != null) { 329 int rotation = getRotation(mActivity, data); 330 final int squareSize = Math.min(fullImage.getWidth(), 331 fullImage.getHeight()); 332 final int left = (fullImage.getWidth() - squareSize) / 2; 333 final int top = (fullImage.getHeight() - squareSize) / 2; 334 335 Matrix matrix = new Matrix(); 336 RectF rectSource = new RectF(left, top, 337 left + squareSize, top + squareSize); 338 RectF rectDest = new RectF(0, 0, mPhotoSize, mPhotoSize); 339 matrix.setRectToRect(rectSource, rectDest, Matrix.ScaleToFit.CENTER); 340 matrix.postRotate(rotation, mPhotoSize / 2f, mPhotoSize / 2f); 341 canvas.drawBitmap(fullImage, matrix, new Paint()); 342 return croppedImage; 343 } else { 344 // Bah! Got nothin. 345 return null; 346 } 347 } 348 349 @Override 350 protected void onPostExecute(Bitmap bitmap) { 351 onPhotoProcessed(bitmap); 352 } 353 }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null); 354 } 355 356 /** 357 * Reads the image's exif data and determines the rotation degree needed to display the image 358 * in portrait mode. 359 */ getRotation(Context context, Uri selectedImage)360 private int getRotation(Context context, Uri selectedImage) { 361 int rotation = -1; 362 try { 363 InputStream imageStream = context.getContentResolver().openInputStream(selectedImage); 364 ExifInterface exif = new ExifInterface(imageStream); 365 rotation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1); 366 } catch (IOException exception) { 367 Log.e(TAG, "Error while getting rotation", exception); 368 } 369 370 switch (rotation) { 371 case ExifInterface.ORIENTATION_ROTATE_90: 372 return 90; 373 case ExifInterface.ORIENTATION_ROTATE_180: 374 return 180; 375 case ExifInterface.ORIENTATION_ROTATE_270: 376 return 270; 377 default: 378 return 0; 379 } 380 } 381 onPhotoProcessed(Bitmap bitmap)382 private void onPhotoProcessed(Bitmap bitmap) { 383 if (bitmap != null) { 384 mNewUserPhotoBitmap = bitmap; 385 mNewUserPhotoDrawable = CircleFramedDrawable 386 .getInstance(mImageView.getContext(), mNewUserPhotoBitmap); 387 mImageView.setImageDrawable(mNewUserPhotoDrawable); 388 } 389 new File(mImagesDir, TAKE_PICTURE_FILE_NAME).delete(); 390 new File(mImagesDir, CROP_PICTURE_FILE_NAME).delete(); 391 } 392 getPhotoSize(Context context)393 private static int getPhotoSize(Context context) { 394 try (Cursor cursor = context.getContentResolver().query( 395 DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI, 396 new String[]{DisplayPhoto.DISPLAY_MAX_DIM}, null, null, null)) { 397 if (cursor != null) { 398 cursor.moveToFirst(); 399 return cursor.getInt(0); 400 } else { 401 return DEFAULT_PHOTO_SIZE; 402 } 403 } 404 } 405 createTempImageUri(Context context, String fileName, boolean purge)406 private Uri createTempImageUri(Context context, String fileName, boolean purge) { 407 final File fullPath = new File(mImagesDir, fileName); 408 if (purge) { 409 fullPath.delete(); 410 } 411 return FileProvider.getUriForFile(context, mFileAuthority, fullPath); 412 } 413 saveNewUserPhotoBitmap()414 File saveNewUserPhotoBitmap() { 415 if (mNewUserPhotoBitmap == null) { 416 return null; 417 } 418 try { 419 File file = new File(mImagesDir, NEW_USER_PHOTO_FILE_NAME); 420 OutputStream os = new FileOutputStream(file); 421 mNewUserPhotoBitmap.compress(Bitmap.CompressFormat.PNG, 100, os); 422 os.flush(); 423 os.close(); 424 return file; 425 } catch (IOException e) { 426 Log.e(TAG, "Cannot create temp file", e); 427 } 428 return null; 429 } 430 loadNewUserPhotoBitmap(File file)431 static Bitmap loadNewUserPhotoBitmap(File file) { 432 return BitmapFactory.decodeFile(file.getAbsolutePath()); 433 } 434 removeNewUserPhotoBitmapFile()435 void removeNewUserPhotoBitmapFile() { 436 new File(mImagesDir, NEW_USER_PHOTO_FILE_NAME).delete(); 437 } 438 439 private static final class RestrictedMenuItem { 440 private final Context mContext; 441 private final String mTitle; 442 private final Runnable mAction; 443 private final RestrictedLockUtils.EnforcedAdmin mAdmin; 444 // Restriction may be set by system or something else via UserManager.setUserRestriction(). 445 private final boolean mIsRestrictedByBase; 446 447 /** 448 * The menu item, used for popup menu. Any element of such a menu can be disabled by admin. 449 * 450 * @param context A context. 451 * @param title The title of the menu item. 452 * @param restriction The restriction, that if is set, blocks the menu item. 453 * @param action The action on menu item click. 454 */ RestrictedMenuItem(Context context, String title, String restriction, Runnable action)455 RestrictedMenuItem(Context context, String title, String restriction, 456 Runnable action) { 457 mContext = context; 458 mTitle = title; 459 mAction = action; 460 461 final int myUserId = UserHandle.myUserId(); 462 mAdmin = RestrictedLockUtilsInternal.checkIfRestrictionEnforced(context, 463 restriction, myUserId); 464 mIsRestrictedByBase = RestrictedLockUtilsInternal.hasBaseUserRestriction(mContext, 465 restriction, myUserId); 466 } 467 468 @Override toString()469 public String toString() { 470 return mTitle; 471 } 472 doAction()473 void doAction() { 474 if (isRestrictedByBase()) { 475 return; 476 } 477 478 if (isRestrictedByAdmin()) { 479 RestrictedLockUtils.sendShowAdminSupportDetailsIntent(mContext, mAdmin); 480 return; 481 } 482 483 mAction.run(); 484 } 485 isRestrictedByAdmin()486 boolean isRestrictedByAdmin() { 487 return mAdmin != null; 488 } 489 isRestrictedByBase()490 boolean isRestrictedByBase() { 491 return mIsRestrictedByBase; 492 } 493 } 494 495 /** 496 * Provide this adapter to ListPopupWindow.setAdapter() to have a popup window menu, where 497 * any element can be restricted by admin (profile owner or device owner). 498 */ 499 private static final class RestrictedPopupMenuAdapter extends ArrayAdapter<RestrictedMenuItem> { RestrictedPopupMenuAdapter(Context context, List<RestrictedMenuItem> items)500 RestrictedPopupMenuAdapter(Context context, List<RestrictedMenuItem> items) { 501 super(context, R.layout.restricted_popup_menu_item, R.id.text, items); 502 } 503 504 @Override getView(int position, View convertView, ViewGroup parent)505 public View getView(int position, View convertView, ViewGroup parent) { 506 final View view = super.getView(position, convertView, parent); 507 final RestrictedMenuItem item = getItem(position); 508 final TextView text = (TextView) view.findViewById(R.id.text); 509 final ImageView image = (ImageView) view.findViewById(R.id.restricted_icon); 510 511 text.setEnabled(!item.isRestrictedByAdmin() && !item.isRestrictedByBase()); 512 image.setVisibility(item.isRestrictedByAdmin() && !item.isRestrictedByBase() 513 ? ImageView.VISIBLE : ImageView.GONE); 514 515 return view; 516 } 517 } 518 } 519