Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit e8e79f89 authored by Oli Lan's avatar Oli Lan
Browse files

Add tests for AvatarPhotoController.

To facilitate adding tests, the class is modified to allow the
Activity to be mocked.

A small amount of clean-up is also performed, to remove AsyncTask
usages and remove some unnecessary code.

Bug: 221223842
Test: atest AvatarPhotoControllerTest
Change-Id: I70f3e8dcd371f48c780b29fada002dbd2f174c2d
parent d62c6e6c
Loading
Loading
Loading
Loading
+136 −84
Original line number Original line Diff line number Diff line
@@ -21,7 +21,6 @@ import android.content.ClipData;
import android.content.ContentResolver;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Context;
import android.content.Intent;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Canvas;
@@ -30,15 +29,15 @@ import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.RectF;
import android.media.ExifInterface;
import android.media.ExifInterface;
import android.net.Uri;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.StrictMode;
import android.os.StrictMode;
import android.provider.ContactsContract;
import android.provider.MediaStore;
import android.provider.MediaStore;
import android.util.EventLog;
import android.util.EventLog;
import android.util.Log;
import android.util.Log;


import androidx.core.content.FileProvider;
import androidx.core.content.FileProvider;


import com.android.settingslib.utils.ThreadUtils;

import libcore.io.Streams;
import libcore.io.Streams;


import java.io.File;
import java.io.File;
@@ -47,16 +46,35 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStream;
import java.util.concurrent.ExecutionException;


class AvatarPhotoController {
class AvatarPhotoController {

    interface AvatarUi {
        boolean isFinishing();

        void returnUriResult(Uri uri);

        void startActivityForResult(Intent intent, int resultCode);

        int getPhotoSize();

        boolean canCropPhoto();
    }

    interface ContextInjector {
        File getCacheDir();

        Uri createTempImageUri(File parentDir, String fileName, boolean purge);

        ContentResolver getContentResolver();
    }

    private static final String TAG = "AvatarPhotoController";
    private static final String TAG = "AvatarPhotoController";


    private static final int REQUEST_CODE_CHOOSE_PHOTO = 1001;
    static final int REQUEST_CODE_CHOOSE_PHOTO = 1001;
    private static final int REQUEST_CODE_TAKE_PHOTO = 1002;
    static final int REQUEST_CODE_TAKE_PHOTO = 1002;
    private static final int REQUEST_CODE_CROP_PHOTO = 1003;
    static final int REQUEST_CODE_CROP_PHOTO = 1003;
    // in rare cases we get a null Cursor when querying for DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI
    // so we need a default photo size
    private static final int DEFAULT_PHOTO_SIZE = 500;


    private static final String IMAGES_DIR = "multi_user";
    private static final String IMAGES_DIR = "multi_user";
    private static final String CROP_PICTURE_FILE_NAME = "CropEditUserPhoto.jpg";
    private static final String CROP_PICTURE_FILE_NAME = "CropEditUserPhoto.jpg";
@@ -64,22 +82,24 @@ class AvatarPhotoController {


    private final int mPhotoSize;
    private final int mPhotoSize;


    private final AvatarPickerActivity mActivity;
    private final AvatarUi mAvatarUi;
    private final String mFileAuthority;
    private final ContextInjector mContextInjector;


    private final File mImagesDir;
    private final File mImagesDir;
    private final Uri mCropPictureUri;
    private final Uri mCropPictureUri;
    private final Uri mTakePictureUri;
    private final Uri mTakePictureUri;


    AvatarPhotoController(AvatarPickerActivity activity, boolean waiting, String fileAuthority) {
    AvatarPhotoController(AvatarUi avatarUi, ContextInjector contextInjector, boolean waiting) {
        mActivity = activity;
        mAvatarUi = avatarUi;
        mFileAuthority = fileAuthority;
        mContextInjector = contextInjector;


        mImagesDir = new File(activity.getCacheDir(), IMAGES_DIR);
        mImagesDir = new File(mContextInjector.getCacheDir(), IMAGES_DIR);
        mImagesDir.mkdir();
        mImagesDir.mkdir();
        mCropPictureUri = createTempImageUri(activity, CROP_PICTURE_FILE_NAME, !waiting);
        mCropPictureUri =
        mTakePictureUri = createTempImageUri(activity, TAKE_PICTURE_FILE_NAME, !waiting);
                mContextInjector.createTempImageUri(mImagesDir, CROP_PICTURE_FILE_NAME, !waiting);
        mPhotoSize = getPhotoSize(activity);
        mTakePictureUri =
                mContextInjector.createTempImageUri(mImagesDir, TAKE_PICTURE_FILE_NAME, !waiting);
        mPhotoSize = mAvatarUi.getPhotoSize();
    }
    }


    /**
    /**
@@ -102,16 +122,12 @@ class AvatarPhotoController {


        switch (requestCode) {
        switch (requestCode) {
            case REQUEST_CODE_CROP_PHOTO:
            case REQUEST_CODE_CROP_PHOTO:
                mActivity.returnUriResult(pictureUri);
                mAvatarUi.returnUriResult(pictureUri);
                return true;
                return true;
            case REQUEST_CODE_TAKE_PHOTO:
            case REQUEST_CODE_TAKE_PHOTO:
            case REQUEST_CODE_CHOOSE_PHOTO:
            case REQUEST_CODE_CHOOSE_PHOTO:
                if (mTakePictureUri.equals(pictureUri)) {
                if (mTakePictureUri.equals(pictureUri)) {
                    if (PhotoCapabilityUtils.canCropPhoto(mActivity)) {
                    cropPhoto();
                    cropPhoto();
                    } else {
                        onPhotoNotCropped(pictureUri);
                    }
                } else {
                } else {
                    copyAndCropPhoto(pictureUri);
                    copyAndCropPhoto(pictureUri);
                }
                }
@@ -123,49 +139,47 @@ class AvatarPhotoController {
    void takePhoto() {
    void takePhoto() {
        Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE_SECURE);
        Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE_SECURE);
        appendOutputExtra(intent, mTakePictureUri);
        appendOutputExtra(intent, mTakePictureUri);
        mActivity.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO);
        mAvatarUi.startActivityForResult(intent, REQUEST_CODE_TAKE_PHOTO);
    }
    }


    void choosePhoto() {
    void choosePhoto() {
        Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES, null);
        Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES, null);
        intent.setType("image/*");
        intent.setType("image/*");
        mActivity.startActivityForResult(intent, REQUEST_CODE_CHOOSE_PHOTO);
        mAvatarUi.startActivityForResult(intent, REQUEST_CODE_CHOOSE_PHOTO);
    }
    }


    private void copyAndCropPhoto(final Uri pictureUri) {
    private void copyAndCropPhoto(final Uri pictureUri) {
        // TODO: Replace AsyncTask
        try {
        new AsyncTask<Void, Void, Void>() {
            ThreadUtils.postOnBackgroundThread(() -> {
            @Override
                final ContentResolver cr = mContextInjector.getContentResolver();
            protected Void doInBackground(Void... params) {
                final ContentResolver cr = mActivity.getContentResolver();
                try (InputStream in = cr.openInputStream(pictureUri);
                try (InputStream in = cr.openInputStream(pictureUri);
                     OutputStream out = cr.openOutputStream(mTakePictureUri)) {
                     OutputStream out = cr.openOutputStream(mTakePictureUri)) {
                    Streams.copy(in, out);
                    Streams.copy(in, out);
                } catch (IOException e) {
                } catch (IOException e) {
                    Log.w(TAG, "Failed to copy photo", e);
                    Log.w(TAG, "Failed to copy photo", e);
                    return;
                }
                }
                return null;
                ThreadUtils.postOnMainThread(() -> {
            }
                    if (!mAvatarUi.isFinishing()) {

            @Override
            protected void onPostExecute(Void result) {
                if (!mActivity.isFinishing() && !mActivity.isDestroyed()) {
                        cropPhoto();
                        cropPhoto();
                    }
                    }
                });
            }).get();
        } catch (InterruptedException | ExecutionException e) {
            Log.e(TAG, "Error performing copy-and-crop", e);
        }
        }
        }.execute();
    }
    }


    private void cropPhoto() {
    private void cropPhoto() {
        if (mAvatarUi.canCropPhoto()) {
            // TODO: Use a public intent, when there is one.
            // TODO: Use a public intent, when there is one.
            Intent intent = new Intent("com.android.camera.action.CROP");
            Intent intent = new Intent("com.android.camera.action.CROP");
            intent.setDataAndType(mTakePictureUri, "image/*");
            intent.setDataAndType(mTakePictureUri, "image/*");
            appendOutputExtra(intent, mCropPictureUri);
            appendOutputExtra(intent, mCropPictureUri);
            appendCropExtras(intent);
            appendCropExtras(intent);
        if (intent.resolveActivity(mActivity.getPackageManager()) != null) {
            try {
            try {
                StrictMode.disableDeathOnFileUriExposure();
                StrictMode.disableDeathOnFileUriExposure();
                mActivity.startActivityForResult(intent, REQUEST_CODE_CROP_PHOTO);
                mAvatarUi.startActivityForResult(intent, REQUEST_CODE_CROP_PHOTO);
            } finally {
            } finally {
                StrictMode.enableDeathOnFileUriExposure();
                StrictMode.enableDeathOnFileUriExposure();
            }
            }
@@ -192,24 +206,22 @@ class AvatarPhotoController {
    }
    }


    private void onPhotoNotCropped(final Uri data) {
    private void onPhotoNotCropped(final Uri data) {
        // TODO: Replace AsyncTask to avoid possible memory leaks and handle configuration change
        try {
        new AsyncTask<Void, Void, Bitmap>() {
            ThreadUtils.postOnBackgroundThread(() -> {
            @Override
            protected Bitmap doInBackground(Void... params) {
                // Scale and crop to a square aspect ratio
                // Scale and crop to a square aspect ratio
                Bitmap croppedImage = Bitmap.createBitmap(mPhotoSize, mPhotoSize,
                Bitmap croppedImage = Bitmap.createBitmap(mPhotoSize, mPhotoSize,
                        Bitmap.Config.ARGB_8888);
                        Bitmap.Config.ARGB_8888);
                Canvas canvas = new Canvas(croppedImage);
                Canvas canvas = new Canvas(croppedImage);
                Bitmap fullImage;
                Bitmap fullImage;
                try {
                try {
                    InputStream imageStream = mActivity.getContentResolver()
                    InputStream imageStream = mContextInjector.getContentResolver()
                            .openInputStream(data);
                            .openInputStream(data);
                    fullImage = BitmapFactory.decodeStream(imageStream);
                    fullImage = BitmapFactory.decodeStream(imageStream);
                } catch (FileNotFoundException fe) {
                } catch (FileNotFoundException fe) {
                    return null;
                    return;
                }
                }
                if (fullImage != null) {
                if (fullImage != null) {
                    int rotation = getRotation(mActivity, data);
                    int rotation = getRotation(data);
                    final int squareSize = Math.min(fullImage.getWidth(),
                    final int squareSize = Math.min(fullImage.getWidth(),
                            fullImage.getHeight());
                            fullImage.getHeight());
                    final int left = (fullImage.getWidth() - squareSize) / 2;
                    final int left = (fullImage.getWidth() - squareSize) / 2;
@@ -222,29 +234,27 @@ class AvatarPhotoController {
                    matrix.setRectToRect(rectSource, rectDest, Matrix.ScaleToFit.CENTER);
                    matrix.setRectToRect(rectSource, rectDest, Matrix.ScaleToFit.CENTER);
                    matrix.postRotate(rotation, mPhotoSize / 2f, mPhotoSize / 2f);
                    matrix.postRotate(rotation, mPhotoSize / 2f, mPhotoSize / 2f);
                    canvas.drawBitmap(fullImage, matrix, new Paint());
                    canvas.drawBitmap(fullImage, matrix, new Paint());
                    return croppedImage;
                    saveBitmapToFile(croppedImage, new File(mImagesDir, CROP_PICTURE_FILE_NAME));
                } else {
                    // Bah! Got nothin.
                    return null;
                }
            }


            @Override
                    ThreadUtils.postOnMainThread(() -> {
            protected void onPostExecute(Bitmap bitmap) {
                        mAvatarUi.returnUriResult(mCropPictureUri);
                saveBitmapToFile(bitmap, new File(mImagesDir, CROP_PICTURE_FILE_NAME));
                    });
                mActivity.returnUriResult(mCropPictureUri);
                }
            }).get();
        } catch (InterruptedException | ExecutionException e) {
            Log.e(TAG, "Error performing internal crop", e);
        }
        }
        }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null);
    }
    }


    /**
    /**
     * Reads the image's exif data and determines the rotation degree needed to display the image
     * Reads the image's exif data and determines the rotation degree needed to display the image
     * in portrait mode.
     * in portrait mode.
     */
     */
    private int getRotation(Context context, Uri selectedImage) {
    private int getRotation(Uri selectedImage) {
        int rotation = -1;
        int rotation = -1;
        try {
        try {
            InputStream imageStream = context.getContentResolver().openInputStream(selectedImage);
            InputStream imageStream =
                    mContextInjector.getContentResolver().openInputStream(selectedImage);
            ExifInterface exif = new ExifInterface(imageStream);
            ExifInterface exif = new ExifInterface(imageStream);
            rotation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1);
            rotation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1);
        } catch (IOException exception) {
        } catch (IOException exception) {
@@ -274,24 +284,66 @@ class AvatarPhotoController {
        }
        }
    }
    }


    private static int getPhotoSize(Context context) {
    static class AvatarUiImpl implements AvatarUi {
        try (Cursor cursor = context.getContentResolver().query(
        private final AvatarPickerActivity mActivity;
                ContactsContract.DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI,

                new String[]{ContactsContract.DisplayPhoto.DISPLAY_MAX_DIM}, null, null, null)) {
        AvatarUiImpl(AvatarPickerActivity activity) {
            if (cursor != null) {
            mActivity = activity;
                cursor.moveToFirst();
        }
                return cursor.getInt(0);

            } else {
        @Override
                return DEFAULT_PHOTO_SIZE;
        public boolean isFinishing() {
            return mActivity.isFinishing() || mActivity.isDestroyed();
        }

        @Override
        public void returnUriResult(Uri uri) {
            mActivity.returnUriResult(uri);
        }

        @Override
        public void startActivityForResult(Intent intent, int resultCode) {
            mActivity.startActivityForResult(intent, resultCode);
        }

        @Override
        public int getPhotoSize() {
            return mActivity.getResources()
                    .getDimensionPixelSize(com.android.internal.R.dimen.user_icon_size);
        }
        }

        @Override
        public boolean canCropPhoto() {
            return PhotoCapabilityUtils.canCropPhoto(mActivity);
        }
        }
    }
    }


    private Uri createTempImageUri(Context context, String fileName, boolean purge) {
    static class ContextInjectorImpl implements ContextInjector {
        final File fullPath = new File(mImagesDir, fileName);
        private final Context mContext;
        private final String mFileAuthority;

        ContextInjectorImpl(Context context, String fileAuthority) {
            mContext = context;
            mFileAuthority = fileAuthority;
        }

        @Override
        public File getCacheDir() {
            return mContext.getCacheDir();
        }

        @Override
        public Uri createTempImageUri(File parentDir, String fileName, boolean purge) {
            final File fullPath = new File(parentDir, fileName);
            if (purge) {
            if (purge) {
                fullPath.delete();
                fullPath.delete();
            }
            }
        return FileProvider.getUriForFile(context, mFileAuthority, fullPath);
            return FileProvider.getUriForFile(mContext, mFileAuthority, fullPath);
        }

        @Override
        public ContentResolver getContentResolver() {
            return mContext.getContentResolver();
        }
    }
    }
}
}
+3 −1
Original line number Original line Diff line number Diff line
@@ -94,7 +94,9 @@ public class AvatarPickerActivity extends Activity {
        restoreState(savedInstanceState);
        restoreState(savedInstanceState);


        mAvatarPhotoController = new AvatarPhotoController(
        mAvatarPhotoController = new AvatarPhotoController(
                this, mWaitingForActivityResult, getFileAuthority());
                new AvatarPhotoController.AvatarUiImpl(this),
                new AvatarPhotoController.ContextInjectorImpl(this, getFileAuthority()),
                mWaitingForActivityResult);
    }
    }


    private void setUpButtons() {
    private void setUpButtons() {
+10 −1
Original line number Original line Diff line number Diff line
@@ -25,10 +25,19 @@
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
    <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />



    <application>
    <application>
        <uses-library android:name="android.test.runner" />
        <uses-library android:name="android.test.runner" />
        <activity android:name=".drawer.SettingsDrawerActivityTest$TestActivity"/>
        <activity android:name=".drawer.SettingsDrawerActivityTest$TestActivity"/>

        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="com.android.settingslib.test"
            android:grantUriPermissions="true"
            android:exported="false">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>
    </application>
    </application>


    <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
    <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+20 −0
Original line number Original line Diff line number Diff line
<!--
  ~ Copyright (C) 2022 The Android Open Source Project
  ~
  ~ Licensed under the Apache License, Version 2.0 (the "License");
  ~ you may not use this file except in compliance with the License.
  ~ You may obtain a copy of the License at
  ~
  ~      http://www.apache.org/licenses/LICENSE-2.0
  ~
  ~ Unless required by applicable law or agreed to in writing, software
  ~ distributed under the License is distributed on an "AS IS" BASIS,
  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
  -->

<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Offer access to files under Context.getCacheDir() -->
    <cache-path name="my_cache" />
</paths>
+295 −0

File added.

Preview size limit exceeded, changes collapsed.