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