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