Android7.0以上图片裁剪问题

0.系统权限更改

为了提高私有文件的安全性,面向 Android 7.0 或更高版本的应用私有目录被限制访问(0700)。此设置可防止私有文件的元数据泄漏,如它们的大小或存在性。此权限更改有多重副作用:

来自 <https://developer.android.com/about/versions/nougat/android-7.0-changes.html>

在Android7.0以上版本系统中,如果应用尝试通过[file://]形式的uri直接访问其它应用程序目录下的文件;或者通过FileProvider,在未经授权、或被访问APP未提供授权访问功能的情况下,都将收到SecurityException异常。在使用过程中,会出现第三方APP会crash,或者无法得到文件数据等现象。
当然这些问题都是可以解决的。本文将一步一步描述如何写出兼容7.0以上系统的拍照->裁剪or选择图片->裁剪功能。

1.拍照

1.1.授权

private void judgePermission() {
try {
        if (ContextCompat.checkSelfPermission(
                this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED
                ||
                ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
                ||
                ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)
                    &&
                    ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
                    &&
                    ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_EXTERNAL_STORAGE)) {
                Snackbar.make(getWindow().getDecorView(), "需要相机和存储权限", Snackbar.LENGTH_INDEFINITE).setAction("确认", new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        ActivityCompat.requestPermissions(MainActivity.this,
                                new String[]{Manifest.permission.CAMERA, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE},
                                PERMISSIONS_REQUEST);
                    }
                }).show();
            } else {
                ActivityCompat.requestPermissions(this,
                        new String[]{Manifest.permission.CAMERA, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE},
                        PERMISSIONS_REQUEST);
            }
        } else {
            //TODO
           //capturePicture();//拍照
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

1.2.调用系统相机拍摄

private void capturePicture() {
    final Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
    if (intent.resolveActivity(getPackageManager()) != null) {//使用判断是否存在裁剪Activity
        File file = null;
        try {
            file = createImageFile();//创建文件
        } catch (IOException e) {
            e.printStackTrace();
        }
        if (file != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {//5.0以上用FileProvider比较好
            //高版本上使用FileProvider
            //为一个文件生成Content URI
            mUriForFile = FileProvider.getUriForFile(this, FILE_PROVIDER_AUTHORITY, file);
        } else {
            //5.0以下,不使用FileProvider,根据文件得到URI
            mUriForFile = Uri.fromFile(file);
        }
        intent.putExtra(MediaStore.EXTRA_OUTPUT, mUriForFile);//拍摄的照片输出路径uri
        startActivityForResult(intent, CAPTURE_IMAGE_REQUEST);
    }
}

创建介质文件的公用代码:

private File createImageFile() throws IOException {
    if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
        File dir = new File(IMAGE_HEAD_FILE_PATH);
        if (!dir.exists()) {
            dir.mkdirs();
        }
        final File nomedia = new File(IMAGE_HEAD_FILE_PATH + ".nomedia");
        if (!nomedia.exists()) {
            nomedia.createNewFile();
        }
        final File file = new File(IMAGE_HEAD_FILE_PATH + System.currentTimeMillis() + ".jpg");
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            //如果是低版本,则根据文件生成uri
            mUriForFile = Uri.fromFile(file);
        }
        return file;
    }
    return null;
}

2.从相册选择

启动一个action为Intent.ACTION_GET_CONTENT的Activity,type为”image/*”,这里的Intent.createChooser(intent,”选择图片”)作用是如果有多个图片选择器,
系统会弹出一个对话框供用户选择,对话框的标题为参数中传入的字符串。

public void pickPicture() {
         Intent intent = new Intent();
         intent.setType("image/*");
         intent.setAction(Intent.ACTION_GET_CONTENT);
         startActivityForResult(Intent.createChooser(intent,"选择图片"), PICK_IMAGE_REQUEST);
}

3.裁剪

3.1.取Activity返回数据

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    switch (requestCode) {
        case CAPTURE_IMAGE_REQUEST://拍照的Activity响应
            if (resultCode == RESULT_OK && mUriForFile != null) {
                cropImage(mUriForFile);
            }
            break;
        case PICK_IMAGE_REQUEST://本地选取的Activity响应
            if (resultCode == RESULT_OK && data != null && data.getData() != null) {
                Uri uri = data.getData();
                cropImage(uri);
            }
            break;
        case CROP_IMAGE_REQUEST://处理裁剪后的图片,这里是显示出来
            showImage(mUriForFile);
            break;
        default:
            break;
    }
}

3.2.对返回的图片进行裁剪

/**
* 裁剪
*
* @param fromUri 图片uri.
*/
private void cropImage(Uri fromUri) {
  if (fromUri == null) {
    return;
  }
  final Intent cropIntent = new Intent("com.android.camera.action.CROP");
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
  //5.0以下部分设备的没权限修改其它APP下的文件
  //如果不是程序自己的fileprovider,则把来源content uri的文件复制到自己fileprovider对应的目录下。
  // 因为部分应用不允许Intent.FLAG_GRANT_WRITE_URI_PERMISSION权限,强行申请该权限,会报安全异常。
  // 如果不拷贝文件,就需要自己实现一个文件裁剪器。自己的裁剪器裁剪的文件放在自己目录对应的包下,不会存在编辑时权限问题。
  if (!fromUri.getAuthority().equals(FILE_PROVIDER_AUTHORITY)) {
  try {
      final File copyTargetFile = createImageFile();//
      Uri targetUri = FileProvider.getUriForFile(this, FILE_PROVIDER_AUTHORITY, copyTargetFile);
      FileUtils.copyFileFromProviderToSelfProvider(getApplicationContext(), fromUri, targetUri);
      fromUri = targetUri;//替换fromUri,之后的裁剪工作,裁剪的是我们程序FileProvider中的文件。
  } catch (IOException e) {
      e.printStackTrace();
  }
  }
  }//如果是低版本则跳过上面这一步
  
         cropIntent.setDataAndType(fromUri, "image/*");//capture from camera
  
         cropIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
         cropIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
  
         cropIntent.putExtra("crop", "true");
         cropIntent.putExtra("aspectX", 1);
         cropIntent.putExtra("aspectY", 1);
         cropIntent.putExtra("outputX", 500);
         cropIntent.putExtra("outputY", 500);
         cropIntent.putExtra("scale", true);
         cropIntent.putExtra("return-data", false);
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
             File cropImage = null;
             try {
                 cropImage = createImageFile();//裁剪后的文件
             } catch (IOException e) {
                 e.printStackTrace();
             }
             if (cropImage != null) {
                 mUriForFile = FileProvider.getUriForFile(this, FILE_PROVIDER_AUTHORITY, cropImage);
                 //给所有具备裁剪功能的APP授权临时权限。
                 List<ResolveInfo> resInfoList = getPackageManager().queryIntentActivities(cropIntent, PackageManager.MATCH_DEFAULT_ONLY);
                 for (ResolveInfo resolveInfo : resInfoList) {
                     String packageName = resolveInfo.activityInfo.packageName;
                     //给每个APP授权
                     grantUriPermission(packageName, fromUri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
                     grantUriPermission(packageName, mUriForFile, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
                 }
             }
         } else {
             try {
                 createImageFile();//5.0以下直接创建文件,并赋值mUriForFile为新创建文件路径
             } catch (IOException e) {
                 e.printStackTrace();
             }
         }
         cropIntent.putExtra(MediaStore.EXTRA_OUTPUT, mUriForFile);//裁剪后的图片被输出的路径的uri
         cropIntent.putExtra("noFaceDetection", true);
         cropIntent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString());
         startActivityForResult(cropIntent, CROP_IMAGE_REQUEST);
     }

4.重点:FileProvider使用

FileProvider是解决7.0以上图片裁剪问题的核心。Google给出了FileProvider使用的几个步骤

4.1.在清单中定义FileProvider

<manifest>
    ...
    <application>
        ...
        <provider
            android:name="android.support.v4.content.FileProvider"
            android:exported="false"
            android:authorities="com.androidcycle.captureandcrop.capture.fileprovider"
            android:grantUriPermissions="true">

            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/catpure_image" />
        </provider>
        ...
    </application>
</manifest>

android:name属性可以为android.support.v4.content.FileProvider,或者自定义的FileProvider的子类。
android:authorities属性跟普通Provider的用法一样。
android:grantUriPermissions这里必须为true

4.2.指定可用文件

在res/xml目录下创建catpure_image.xml文件用来指定FileProvider可提供的路径(文件名随意指定,需要在manifest调用)。

<paths xmlns:android="http://schemas.android.com/apk/res/android">
 <external-path name="my_images" path="Android/data/com.androidcycle.captureandcrop/" />
</paths>

<paths>标签必须包含一下几种子标签之一:

<files-path name=“name” path=“path” /> 

内部存储的files目录,子目录跟Context.getFilesDir()一样

<cache-path name=“name” path=“path” />

内部存储的cache目录,子目录跟getCacheDir()一样

<external-path name=“name” path=“path” />

外置存储的根目录,子目录跟Environment.getExternalStorageDirectory()一样

<external-files-path name=“name” path=“path” />

外置存储的你的APP的存储区域,与Context.getExternalFilesDir(String)Context.getExternalFilesDir(null)的路径相同

<external-cache-path name=“name” path=“path” />

外置存储的你的APPcache区域,子目录跟Context.getExternalCacheDir()一样

name为被分享出去的隐含的子目录名称;
path为实际子目录路径。

4.3.为文件生成Content URI

File imagePath = new File("路径要跟xml文件中指定的一样");
File newFile = new File(imagePath, "default_image.jpg");
Uri contentUri = FileProvider.getUriForFile(getContext(), "跟manifest中一样的AUTHORITY", newFile);

4.4.为URI授予临时权限

调用 Context.grantUriPermission(package, Uri, mode_flags)方法:

//给所有具备裁剪功能的APP授权临时权限。
List<ResolveInfo> resInfoList = getPackageManager().queryIntentActivities(cropIntent, PackageManager.MATCH_DEFAULT_ONLY);
for (ResolveInfo resolveInfo : resInfoList) {
  String packageName = resolveInfo.activityInfo.packageName;
  //给每个APP授权
  grantUriPermission(packageName, fromUri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
  grantUriPermission(packageName, mUriForFile, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}

给Intent添加Flag

cropIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
cropIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

*注意:在低版本的设备上,试图使用.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)申请文件访问权限时,目标程序会crash。
因此在低版本上还是使用file://,既高版本用FileProvider.getUriForFile生成的Uri,低版本使用Uri.fromFile生成的Uri作为拍照和裁剪的输出目标路径。
*源码:https://github.com/gongshoudao/CaptureDemo