• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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