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