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