blob: c20392a300e77e2cf24f6ee83ae4106fe8c761b7 [file] [log] [blame]
Oli Lanb4751122022-01-18 10:05:36 +00001/*
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
Anna Bauzae95044a2024-03-21 20:54:39 +000017package com.android.settingslib.avatarpicker;
Oli Lanb4751122022-01-18 10:05:36 +000018
19import android.app.Activity;
20import android.content.ClipData;
21import android.content.ContentResolver;
22import android.content.Context;
23import android.content.Intent;
Oli Lanb76141eb2022-08-25 18:03:48 +010024import android.content.pm.PackageManager;
25import android.content.pm.ResolveInfo;
Oli Lanb4751122022-01-18 10:05:36 +000026import android.graphics.Bitmap;
27import android.graphics.BitmapFactory;
28import android.graphics.Canvas;
29import android.graphics.Matrix;
30import android.graphics.Paint;
31import android.graphics.RectF;
32import android.media.ExifInterface;
33import android.net.Uri;
Oli Lanb4751122022-01-18 10:05:36 +000034import android.os.StrictMode;
Oli Lanb4751122022-01-18 10:05:36 +000035import android.provider.MediaStore;
36import android.util.EventLog;
37import android.util.Log;
38
Chris Antol319512b2023-10-19 00:23:46 +000039import androidx.annotation.Nullable;
Oli Lanb4751122022-01-18 10:05:36 +000040import androidx.core.content.FileProvider;
41
Chris Antol319512b2023-10-19 00:23:46 +000042import com.google.common.util.concurrent.FutureCallback;
43import com.google.common.util.concurrent.Futures;
44import com.google.common.util.concurrent.ListenableFuture;
45
Oli Lanb4751122022-01-18 10:05:36 +000046import libcore.io.Streams;
47
48import java.io.File;
Oli Lanb4751122022-01-18 10:05:36 +000049import java.io.FileOutputStream;
50import java.io.IOException;
51import java.io.InputStream;
52import java.io.OutputStream;
Oli Lanb76141eb2022-08-25 18:03:48 +010053import java.util.List;
Oli Lanb4751122022-01-18 10:05:36 +000054
55class AvatarPhotoController {
Oli Lane8e79f82022-02-24 15:52:13 +000056
57 interface AvatarUi {
58 boolean isFinishing();
59
60 void returnUriResult(Uri uri);
61
62 void startActivityForResult(Intent intent, int resultCode);
63
Oli Lanb76141eb2022-08-25 18:03:48 +010064 boolean startSystemActivityForResult(Intent intent, int resultCode);
Oli Lanf18aa112022-07-27 17:16:30 +000065
Oli Lanb76141eb2022-08-25 18:03:48 +010066 int getPhotoSize();
Oli Lane8e79f82022-02-24 15:52:13 +000067 }
68
69 interface ContextInjector {
70 File getCacheDir();
71
72 Uri createTempImageUri(File parentDir, String fileName, boolean purge);
73
74 ContentResolver getContentResolver();
Chris Antol319512b2023-10-19 00:23:46 +000075
76 Context getContext();
Oli Lane8e79f82022-02-24 15:52:13 +000077 }
78
Oli Lanb4751122022-01-18 10:05:36 +000079 private static final String TAG = "AvatarPhotoController";
80
Oli Lane8e79f82022-02-24 15:52:13 +000081 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;
Oli Lanb4751122022-01-18 10:05:36 +000084
Oli Lan56765462022-03-15 16:54:43 +000085 /**
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
Oli Lanb4751122022-01-18 10:05:36 +000090 private static final String IMAGES_DIR = "multi_user";
Oli Lanb76141eb2022-08-25 18:03:48 +010091 private static final String PRE_CROP_PICTURE_FILE_NAME = "PreCropEditUserPhoto.jpg";
Oli Lanb4751122022-01-18 10:05:36 +000092 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
Oli Lane8e79f82022-02-24 15:52:13 +000097 private final AvatarUi mAvatarUi;
98 private final ContextInjector mContextInjector;
Oli Lanb4751122022-01-18 10:05:36 +000099
100 private final File mImagesDir;
Oli Lanb76141eb2022-08-25 18:03:48 +0100101 private final Uri mPreCropPictureUri;
Oli Lanb4751122022-01-18 10:05:36 +0000102 private final Uri mCropPictureUri;
103 private final Uri mTakePictureUri;
104
Oli Lane8e79f82022-02-24 15:52:13 +0000105 AvatarPhotoController(AvatarUi avatarUi, ContextInjector contextInjector, boolean waiting) {
106 mAvatarUi = avatarUi;
107 mContextInjector = contextInjector;
Oli Lanb4751122022-01-18 10:05:36 +0000108
Oli Lane8e79f82022-02-24 15:52:13 +0000109 mImagesDir = new File(mContextInjector.getCacheDir(), IMAGES_DIR);
Oli Lanb4751122022-01-18 10:05:36 +0000110 mImagesDir.mkdir();
Oli Lanb76141eb2022-08-25 18:03:48 +0100111 mPreCropPictureUri = mContextInjector
112 .createTempImageUri(mImagesDir, PRE_CROP_PICTURE_FILE_NAME, !waiting);
Oli Lane8e79f82022-02-24 15:52:13 +0000113 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();
Oli Lanb4751122022-01-18 10:05:36 +0000118 }
119
120 /**
121 * Handles activity result from containing activity/fragment after a take/choose/crop photo
122 * action result is received.
123 */
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:
Oli Lane8e79f82022-02-24 15:52:13 +0000140 mAvatarUi.returnUriResult(pictureUri);
Oli Lanb4751122022-01-18 10:05:36 +0000141 return true;
142 case REQUEST_CODE_TAKE_PHOTO:
Oli Lanb4751122022-01-18 10:05:36 +0000143 if (mTakePictureUri.equals(pictureUri)) {
Oli Lanb76141eb2022-08-25 18:03:48 +0100144 cropPhoto(pictureUri);
Oli Lanb4751122022-01-18 10:05:36 +0000145 } else {
Oli Lan56765462022-03-15 16:54:43 +0000146 copyAndCropPhoto(pictureUri, false);
Oli Lanb4751122022-01-18 10:05:36 +0000147 }
148 return true;
Oli Lan56765462022-03-15 16:54:43 +0000149 case REQUEST_CODE_CHOOSE_PHOTO:
150 copyAndCropPhoto(pictureUri, true);
151 return true;
Oli Lanb4751122022-01-18 10:05:36 +0000152 }
153 return false;
154 }
155
156 void takePhoto() {
157 Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE_SECURE);
158 appendOutputExtra(intent, mTakePictureUri);
Oli Lane8e79f82022-02-24 15:52:13 +0000159 mAvatarUi.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO);
Oli Lanb4751122022-01-18 10:05:36 +0000160 }
161
162 void choosePhoto() {
163 Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES, null);
164 intent.setType("image/*");
Oli Lane8e79f82022-02-24 15:52:13 +0000165 mAvatarUi.startActivityForResult(intent, REQUEST_CODE_CHOOSE_PHOTO);
Oli Lanb4751122022-01-18 10:05:36 +0000166 }
167
Oli Lan56765462022-03-15 16:54:43 +0000168 private void copyAndCropPhoto(final Uri pictureUri, boolean delayBeforeCrop) {
Chris Antol319512b2023-10-19 00:23:46 +0000169 ListenableFuture<Uri> future = ThreadUtils.getBackgroundExecutor().submit(() -> {
170 final ContentResolver cr = mContextInjector.getContentResolver();
Anna Bauzae95044a2024-03-21 20:54:39 +0000171 try {
172 InputStream in = cr.openInputStream(pictureUri);
173 OutputStream out = cr.openOutputStream(mPreCropPictureUri);
Chris Antol319512b2023-10-19 00:23:46 +0000174 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) {
Oli Lane8e79f82022-02-24 15:52:13 +0000185 return;
Oli Lanb4751122022-01-18 10:05:36 +0000186 }
Oli Lan56765462022-03-15 16:54:43 +0000187 Runnable cropRunnable = () -> {
Oli Lane8e79f82022-02-24 15:52:13 +0000188 if (!mAvatarUi.isFinishing()) {
Oli Lanb76141eb2022-08-25 18:03:48 +0100189 cropPhoto(mPreCropPictureUri);
Oli Lane8e79f82022-02-24 15:52:13 +0000190 }
Oli Lan56765462022-03-15 16:54:43 +0000191 };
192 if (delayBeforeCrop) {
Chris Antol319512b2023-10-19 00:23:46 +0000193 mContextInjector.getContext().getMainThreadHandler()
194 .postDelayed(cropRunnable, DELAY_BEFORE_CROP_MILLIS);
Oli Lan56765462022-03-15 16:54:43 +0000195 } else {
Chris Antol319512b2023-10-19 00:23:46 +0000196 cropRunnable.run();
Oli Lan56765462022-03-15 16:54:43 +0000197 }
Chris Antol319512b2023-10-19 00:23:46 +0000198 }
Oli Lan56765462022-03-15 16:54:43 +0000199
Chris Antol319512b2023-10-19 00:23:46 +0000200 @Override
201 public void onFailure(Throwable t) {
202 Log.e(TAG, "Error performing copy-and-crop", t);
203 }
204 }, mContextInjector.getContext().getMainExecutor());
Oli Lanb4751122022-01-18 10:05:36 +0000205 }
206
Oli Lanb76141eb2022-08-25 18:03:48 +0100207 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;
Oli Lanb4751122022-01-18 10:05:36 +0000217 }
Oli Lanb76141eb2022-08-25 18:03:48 +0100218 } finally {
219 StrictMode.enableDeathOnFileUriExposure();
Oli Lanb4751122022-01-18 10:05:36 +0000220 }
Oli Lanb76141eb2022-08-25 18:03:48 +0100221 onPhotoNotCropped(pictureUri);
Oli Lanb4751122022-01-18 10:05:36 +0000222 }
223
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
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
241 private void onPhotoNotCropped(final Uri data) {
Chris Antol319512b2023-10-19 00:23:46 +0000242 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;
Oli Lanb4751122022-01-18 10:05:36 +0000261
Chris Antol319512b2023-10-19 00:23:46 +0000262 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);
Oli Lane8e79f82022-02-24 15:52:13 +0000277 }
Chris Antol319512b2023-10-19 00:23:46 +0000278 }
279
280 @Override
281 public void onFailure(Throwable t) {
282 Log.e(TAG, "Error performing internal crop", t);
283 }
284 }, mContextInjector.getContext().getMainExecutor());
Oli Lanb4751122022-01-18 10:05:36 +0000285 }
286
287 /**
288 * Reads the image's exif data and determines the rotation degree needed to display the image
289 * in portrait mode.
290 */
Oli Lane8e79f82022-02-24 15:52:13 +0000291 private int getRotation(Uri selectedImage) {
Oli Lanb4751122022-01-18 10:05:36 +0000292 int rotation = -1;
293 try {
Oli Lane8e79f82022-02-24 15:52:13 +0000294 InputStream imageStream =
295 mContextInjector.getContentResolver().openInputStream(selectedImage);
Oli Lanb4751122022-01-18 10:05:36 +0000296 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
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
Oli Lane8e79f82022-02-24 15:52:13 +0000325 static class AvatarUiImpl implements AvatarUi {
326 private final AvatarPickerActivity mActivity;
327
328 AvatarUiImpl(AvatarPickerActivity activity) {
329 mActivity = activity;
330 }
331
332 @Override
333 public boolean isFinishing() {
334 return mActivity.isFinishing() || mActivity.isDestroyed();
335 }
336
337 @Override
338 public void returnUriResult(Uri uri) {
339 mActivity.returnUriResult(uri);
340 }
341
342 @Override
343 public void startActivityForResult(Intent intent, int resultCode) {
344 mActivity.startActivityForResult(intent, resultCode);
345 }
346
347 @Override
Oli Lanb76141eb2022-08-25 18:03:48 +0100348 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;
Oli Lanb4751122022-01-18 10:05:36 +0000358 }
Oli Lanf18aa112022-07-27 17:16:30 +0000359
360 @Override
Oli Lanb76141eb2022-08-25 18:03:48 +0100361 public int getPhotoSize() {
362 return mActivity.getResources()
363 .getDimensionPixelSize(com.android.internal.R.dimen.user_icon_size);
Oli Lanf18aa112022-07-27 17:16:30 +0000364 }
Oli Lanb4751122022-01-18 10:05:36 +0000365 }
366
Oli Lane8e79f82022-02-24 15:52:13 +0000367 static class ContextInjectorImpl implements ContextInjector {
368 private final Context mContext;
369 private final String mFileAuthority;
370
371 ContextInjectorImpl(Context context, String fileAuthority) {
372 mContext = context;
373 mFileAuthority = fileAuthority;
Oli Lanb4751122022-01-18 10:05:36 +0000374 }
Oli Lane8e79f82022-02-24 15:52:13 +0000375
376 @Override
377 public File getCacheDir() {
378 return mContext.getCacheDir();
379 }
380
381 @Override
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
391 public ContentResolver getContentResolver() {
392 return mContext.getContentResolver();
393 }
Chris Antol319512b2023-10-19 00:23:46 +0000394
395 @Override
396 public Context getContext() {
397 return mContext;
398 }
Oli Lanb4751122022-01-18 10:05:36 +0000399 }
400}