Android 不申请权限储存、删除相册图片

3,034 阅读9分钟

Android 不申请权限储存、删除相册图片

后续更新

支持Android 12,全版本保存图片到相册方案

最近发现有别人比我写得好,写得详细,可以先参考下别人的优秀文章!这里我看到这个不用申请权限是要Android10的,前面的版本还得乖乖申请权限。还有个问题就是Android10以下目录不会自动创建,代码已更新。

再更新

最近花了点时间把拍照、裁切的功能整理了下,并解决了下Android11上裁切闪退的问题、相册裁切闪退问题,就不多写一篇文章了,可以看我github的demo:

TakePhotoFragment.kt

BitmapFileUtil.kt

前言

最近重新看了下安卓的储存适配,并结合之前做的拍照、裁切demo,小小实验了一下。Android 6.0增加了动态文件权限申请; Android 7.0需要使用FileProvider来获取Uri,不能直接使用file获得; Android 10.0引入了分区储存,并在Android 11.0中强制使用。

Java的File变得越来越难使用,那安卓提供的 SAF(存储访问框架)和 Uri(ContentProvider)是不是得学起来?SAF通过intent的GET_CONTENT action使用系统界面选择文件,Uri则是ContentProvider提供的路径。

开发的时候不知道读者有没有很迷惑到底什么地方应该开启储存权限,不开启储存权限能否储存、删除相册图片呢?下面我们结合代码试试。

知识储备

前面我已经发了一篇关于拍照、裁切的博文,里面可以方便地获得bitmap,下面内容里就不详细叙述了,博文链接如下:

安卓拍照、裁切、选取图片实践

关于文件权限适配的可以看下面几篇文章:

Android 存储基础

Android 10、11 存储完全适配(上)

Android 10、11 存储完全适配(下)

导出图片到相册

上篇文章我们通过拍照获得了新图片,但是也仅仅是保存在外部储存的私有目录里,如果删除了应用,图片也随之被删除了,那这样是不行的,应该考虑如何把图片保存到系统相册去。要是以前,我就直接申请权限,直接Environment拿到根目录,使用File直接保存过去就行了,但是这样好吗?想想我们手机里面一大堆的目录,这个文件的随便使用,是不是有问题?是不是违背了Android的规范?那应该怎么做呢?请看下面代码:

    // 保存到外部储存-公有目录-Picture内,并且无需储存权限
    private fun insert2Pictures() {
        binding.image.drawable?.let {
            val bitmap = it.toBitmap()
            val baos = ByteArrayOutputStream()
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos)
            val bais = ByteArrayInputStream(baos.toByteArray())
            insert2Album(bais, "Media")
            showToast("导出到相册成功")
        }
    }

    // 使用MediaStore方式将流写入相册
    @Suppress("SameParameterValue")
    private fun insert2Album(inputStream: InputStream, type: String) {
        val fileName = "${type}_${System.currentTimeMillis()}.jpg"
        val contentValues = ContentValues()
        contentValues.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, fileName)
        // Android 10,路径保存在RELATIVE_PATH
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            //RELATIVE_PATH 字段表示相对路径,Fundark为相册下专有目录
            contentValues.put(
                MediaStore.Images.ImageColumns.RELATIVE_PATH,
                Environment.DIRECTORY_PICTURES + File.separator + "Fundark"
            )
        } else {
            val dstDir = StringBuilder().let { sb->
                sb.append(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!.path)
                sb.append(File.separator)
                sb.append("Fundark")
                sb.toString()
            }
            val dstPath = dstDir + (File.separator) + fileName
            File(dstDir).also {
                if (!it.exists()) it.mkdirs()
            }

            //DATA字段在Android 10.0 之后已经废弃(Android 11又启用了,但是只读)
            contentValues.put(MediaStore.Images.ImageColumns.DATA, dstPath)
        }

        // 插入相册
        val uri =  requireContext().contentResolver
            .insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)

        // 写入文件
        uri?.let {
            write2File(it, inputStream)
        }
    }

    private fun write2File(uri: Uri, inputStream: InputStream) {
        // 从Uri构造输出流
        requireContext().contentResolver.openOutputStream(uri)?.use { outputStream->
            val byteArray = ByteArray(1024)
            var len: Int
            do {
                //从输入流里读取数据
                len = inputStream.read(byteArray)
                if (len != -1) {
                    outputStream.write(byteArray, 0, len)
                    outputStream.flush()
                }
            } while (len != -1)
        }
    }

这里的代码也只是示例,关于Exception的部分没做,但是关键在于我们没有申请权限就把图片保存到相册去了,现在打开你的相册就能发现你保存的图片,点开详细信息就能看到它的实际位置:

pic

删除相册图 片

刚开始我也觉得这里可能需要申请系统的储存权限,不然怎么可以去删除外部相册的图片呢?实际上,这里也是有限制的,你可以删除你自己创建的图片,这样一说是不是又很合理了。下面试试:

    private fun clearAppPictures() {
        val selection: String
        val selectionArgs: String
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            selection = "${MediaStore.Images.ImageColumns.RELATIVE_PATH} like ?"
            selectionArgs = "%" + Environment.DIRECTORY_PICTURES + File.separator + "Fundark" + "%"
        } else {
            val dstPath = StringBuilder().let { sb->
                sb.append(requireContext()
                    .getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!.path)
                sb.append(File.separator)
                sb.append("Fundark")
                sb.toString()
            }
            selection = "${MediaStore.Images.ImageColumns.DATA} like ?"
            selectionArgs = "%$dstPath%"
        }
        val num = requireContext().contentResolver.delete(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            selection,
            arrayOf(selectionArgs)
        )

        showToast("删除本应用相册图片${num}张")
    }

还是调用ContentProvider处理的,稍微处理下条件,就能完成删除。不过有意思的是,这里删除图片会被系统拦截,荣耀10提示删除了相册图片,并会将图片放到系统回收站去,实际这样也还行。

完整代码

上篇博客的代码和这篇博客的代码放一起,希望对读者有用:

import android.Manifest
import android.app.Activity.RESULT_OK
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toBitmap
import androidx.core.util.Consumer
import com.silencefly96.module_base.base.BaseFragment
import com.silencefly96.module_base.base.IPermissionHelper
import com.silencefly96.module_hardware.databinding.FragmentTakePhotoBinding
import java.io.*


class TakePhotoFragment : BaseFragment() {

    companion object{
        const val REQUEST_CAMERA_CODE = 1
        const val REQUEST_ALBUM_CODE = 2
        const val REQUEST_CROP_CODE = 3

        const val MAX_WIDTH = 480
        const val MAX_HEIGHT = 720
    }

    private var _binding: FragmentTakePhotoBinding? = null
    private val binding get() = _binding!!

    // 文件路径
    private var picturePath: String = ""

    // 裁切路径
    private var cropPicPath: String = ""

    // 启用裁切
    private var enableCrop: Boolean = true

    // 绑定布局
    override fun bindView(inflater: LayoutInflater, container: ViewGroup?): View {
        _binding = FragmentTakePhotoBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun doBusiness(context: Context?) {
        binding.takePhoto.setOnClickListener {
            requestPermission { openCamera() }
        }

        binding.pickPhoto.setOnClickListener {
            openAlbum()
        }

        binding.insertPictures.setOnClickListener {
            insert2Pictures()
        }

        binding.clearCache.setOnClickListener {
            clearCachePictures()
        }

        binding.clearPictures.setOnClickListener {
            clearAppPictures()
        }

        binding.cropSwitch.setOnCheckedChangeListener { _, isChecked -> enableCrop = isChecked}
    }

    private fun requestPermission(consumer: Consumer<Boolean>) {
        // 动态申请权限,使用的外部私有目录无需申请权限
        requestRunTimePermission(requireActivity(), arrayOf(
            Manifest.permission.CAMERA,
//            Manifest.permission.WRITE_EXTERNAL_STORAGE
        ),
            object : IPermissionHelper.PermissionListener {
                override fun onGranted() {
                    consumer.accept(true)
                }

                override fun onGranted(grantedPermission: List<String>?) {
                    consumer.accept(false)
                }

                override fun onDenied(deniedPermission: List<String>?) {
                    consumer.accept(false)
                }
            })
    }

    private fun openCamera() {
        val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
        // 应用外部私有目录:files-Pictures
        val picFile = createFile("Camera")
        val photoUri = getUriForFile(picFile)
        // 保存路径,不要uri,读取bitmap时麻烦
        picturePath = picFile.absolutePath
        // 给目标应用一个临时授权
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        //android11以后强制分区存储,外部资源无法访问,所以添加一个输出保存位置,然后取值操作
        intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
        startActivityForResult(intent, REQUEST_CAMERA_CODE)
    }

    private fun createFile(type: String): File {
        // 在相册创建一个临时文件
        val picFile = File(requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES),
                "${type}_${System.currentTimeMillis()}.jpg")
        try {
            if (picFile.exists()) {
                picFile.delete()
            }
            picFile.createNewFile()
        } catch (e: IOException) {
            e.printStackTrace()
        }

        // 临时文件,后面会加long型随机数
//        return File.createTempFile(
//            type,
//            ".jpg",
//            requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
//        )

        return picFile
    }

    private fun getUriForFile(file: File): Uri {
        // 转换为uri
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            //适配Android 7.0文件权限,通过FileProvider创建一个content类型的Uri
            FileProvider.getUriForFile(
                requireActivity(),
                "com.silencefly96.module_hardware.fileProvider", file
            )
        } else {
            Uri.fromFile(file)
        }
    }

    private fun openAlbum() {
        val intent = Intent()
        intent.type = "image/*"
        intent.action = "android.intent.action.GET_CONTENT"
        intent.addCategory("android.intent.category.OPENABLE")
        startActivityForResult(intent, REQUEST_ALBUM_CODE)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (resultCode == RESULT_OK) {
            when(requestCode) {
                REQUEST_CAMERA_CODE -> {
                    // 通知系统文件更新
//                    requireContext().sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
//                        Uri.fromFile(File(picturePath))))
                    if (!enableCrop) {
                        val bitmap = getBitmap(picturePath)
                        bitmap?.let {
                            // 显示图片
                            binding.image.setImageBitmap(it)
                        }
                    }else {
                        cropImage(picturePath)
                    }
                }
                REQUEST_ALBUM_CODE -> {
                    data?.data?.let { uri ->
                        if (!enableCrop) {
                            val bitmap = getBitmap("", uri)
                            bitmap?.let {
                                // 显示图片
                                binding.image.setImageBitmap(it)
                            }
                        }else {
                            cropImage(uri)
                        }
                    }
                }
                REQUEST_CROP_CODE -> {
                    val bitmap = getBitmap(cropPicPath)
                    bitmap?.let {
                        // 显示图片
                        binding.image.setImageBitmap(it)
                    }
                }
            }
        }
    }

    private fun getBitmap(path: String, uri: Uri? = null): Bitmap? {
        var bitmap: Bitmap?
        val options = BitmapFactory.Options()
        // 先不读取,仅获取信息
        options.inJustDecodeBounds = true
        if (uri == null) {
            BitmapFactory.decodeFile(path, options)
        }else {
            val input = requireContext().contentResolver.openInputStream(uri)
            BitmapFactory.decodeStream(input, null, options)
        }

        // 预获取信息,大图压缩后加载
        val width = options.outWidth
        val height = options.outHeight
        Log.d("TAG", "before compress: width = " +
                options.outWidth + ", height = " + options.outHeight)

        // 尺寸压缩
        var size = 1
        while (width / size >= MAX_WIDTH || height / size >= MAX_HEIGHT) {
            size *= 2
        }
        options.inSampleSize = size
        options.inJustDecodeBounds = false
        bitmap = if (uri == null) {
            BitmapFactory.decodeFile(path, options)
        }else {
            val input = requireContext().contentResolver.openInputStream(uri)
            BitmapFactory.decodeStream(input, null, options)
        }
        Log.d("TAG", "after compress: width = " +
                options.outWidth + ", height = " + options.outHeight)

        // 质量压缩
        val baos = ByteArrayOutputStream()
        bitmap!!.compress(Bitmap.CompressFormat.JPEG, 80, baos)
        val bais = ByteArrayInputStream(baos.toByteArray())
        options.inSampleSize = 1
        bitmap = BitmapFactory.decodeStream(bais, null, options)

        return bitmap
    }

    private fun cropImage(path: String) {
        cropImage(getUriForFile(File(path)))
    }

    private fun cropImage(uri: Uri) {
        val intent = Intent("com.android.camera.action.CROP")
        // Android 7.0需要临时添加读取Url的权限
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
//        intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
        intent.setDataAndType(uri, "image/*")
        // 使图片处于可裁剪状态
        intent.putExtra("crop", "true")
        // 裁剪框的比例(根据需要显示的图片比例进行设置)
//        if (Build.MANUFACTURER.contains("HUAWEI")) {
//            //硬件厂商为华为的,默认是圆形裁剪框,这里让它无法成圆形
//            intent.putExtra("aspectX", 9999)
//            intent.putExtra("aspectY", 9998)
//        } else {
//            //其他手机一般默认为方形
//            intent.putExtra("aspectX", 1)
//            intent.putExtra("aspectY", 1)
//        }

        // 设置裁剪区域的形状,默认为矩形,也可设置为圆形,可能无效
        // intent.putExtra("circleCrop", true);
        // 让裁剪框支持缩放
        intent.putExtra("scale", true)
        // 属性控制裁剪完毕,保存的图片的大小格式。太大会OOM(return-data)
//        intent.putExtra("outputX", 400)
//        intent.putExtra("outputY", 400)

        // 生成临时文件
        val cropFile = createFile("Crop")
        // 裁切图片时不能使用provider的uri,否则无法保存
//        val cropUri = getUriForFile(cropFile)
        val cropUri = Uri.fromFile(cropFile)
        intent.putExtra(MediaStore.EXTRA_OUTPUT, cropUri)
        // 记录临时位置
        cropPicPath = cropFile.absolutePath

        // 设置图片的输出格式
        intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString())

        // return-data=true传递的为缩略图,小米手机默认传递大图, Android 11以上设置为true会闪退
        intent.putExtra("return-data", false)

        startActivityForResult(intent, REQUEST_CROP_CODE)
    }

    // 保存到外部储存-公有目录-Picture内,并且无需储存权限
    private fun insert2Pictures() {
        binding.image.drawable?.let {
            val bitmap = it.toBitmap()
            val baos = ByteArrayOutputStream()
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos)
            val bais = ByteArrayInputStream(baos.toByteArray())
            insert2Album(bais, "Media")
            showToast("导出到相册成功")
        }
    }

    // 使用MediaStore方式将流写入相册
    @Suppress("SameParameterValue")
    private fun insert2Album(inputStream: InputStream, type: String) {
        val fileName = "${type}_${System.currentTimeMillis()}.jpg"
        val contentValues = ContentValues()
        contentValues.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, fileName)
        // Android 10,路径保存在RELATIVE_PATH
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            //RELATIVE_PATH 字段表示相对路径,Fundark为相册下专有目录
            contentValues.put(
                MediaStore.Images.ImageColumns.RELATIVE_PATH,
                Environment.DIRECTORY_PICTURES + File.separator + "Fundark"
            )
        } else {
            val dstPath = StringBuilder().let { sb->
                sb.append(requireContext()
                    .getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!.path)
                sb.append(File.separator)
                sb.append("Fundark")
                sb.append(File.separator)
                sb.append(fileName)
                sb.toString()
            }

            //DATA字段在Android 10.0 之后已经废弃(Android 11又启用了,但是只读)
            contentValues.put(MediaStore.Images.ImageColumns.DATA, dstPath)
        }

        // 插入相册
        val uri =  requireContext().contentResolver
            .insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)

        // 写入文件
        uri?.let {
            write2File(it, inputStream)
        }
    }

    private fun write2File(uri: Uri, inputStream: InputStream) {
        // 从Uri构造输出流
        requireContext().contentResolver.openOutputStream(uri)?.use { outputStream->
            val byteArray = ByteArray(1024)
            var len: Int
            do {
                //从输入流里读取数据
                len = inputStream.read(byteArray)
                if (len != -1) {
                    outputStream.write(byteArray, 0, len)
                    outputStream.flush()
                }
            } while (len != -1)
        }
    }

    private fun clearCachePictures() {
        // 外部储存-私有目录-files-Pictures目录
        requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)?.let { dir->
            // 删除其中的图片
            try {
                val pics = dir.listFiles()
                pics?.forEach { pic ->
                    pic.delete()
                }
                showToast("清除缓存成功")
            }catch (e: Exception) {
                e.printStackTrace()
                showToast("清除缓存失败")
            }
        }
    }

    private fun clearAppPictures() {
        val selection: String
        val selectionArgs: String
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            selection = "${MediaStore.Images.ImageColumns.RELATIVE_PATH} like ?"
            selectionArgs = "%" + Environment.DIRECTORY_PICTURES + File.separator + "Fundark" + "%"
        } else {
            val dstPath = StringBuilder().let { sb->
                sb.append(requireContext()
                    .getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!.path)
                sb.append(File.separator)
                sb.append("Fundark")
                sb.toString()
            }
            selection = "${MediaStore.Images.ImageColumns.DATA} like ?"
            selectionArgs = "%$dstPath%"
        }
        val num = requireContext().contentResolver.delete(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            selection,
            arrayOf(selectionArgs)
        )

        showToast("删除本应用相册图片${num}张")
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

}

里面的BaseActivity读者自己改下。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".camera.TakePhotoFragment">

    <Button
        android:id="@+id/takePhoto"
        android:text="@string/take_photo_by_system"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginTop="10dp"
        />

    <Button
        android:id="@+id/pickPhoto"
        android:text="@string/pick_photo_by_system"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/takePhoto"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginTop="10dp"
        />

    <Button
        android:id="@+id/insertPictures"
        android:text="@string/insert_photo_to_pictures"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/pickPhoto"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginTop="10dp"
        />

    <Button
        android:id="@+id/clearCache"
        android:text="@string/clear_Cache"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/insertPictures"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toLeftOf="@+id/clearPictures"
        android:layout_marginTop="10dp"
        />

    <Button
        android:id="@+id/clearPictures"
        android:text="@string/clear_this_pictures"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/insertPictures"
        app:layout_constraintLeft_toRightOf="@+id/clearCache"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginTop="10dp"
        />

    <TextView
        android:id="@+id/enable_crop_text"
        android:textSize="18sp"
        android:text="@string/enable_crop"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="@id/cropSwitch"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toLeftOf="@id/cropSwitch"
        app:layout_constraintBottom_toBottomOf="@id/cropSwitch"
        />

    <androidx.appcompat.widget.SwitchCompat
        android:id="@+id/cropSwitch"
        android:checked="true"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="10dp"
        app:layout_constraintLeft_toRightOf="@id/enable_crop_text"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/clearPictures"
        />

    <ImageView
        android:id="@+id/image"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/cropSwitch"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginTop="10dp"
        tools:ignore="ContentDescription"
        />

</androidx.constraintlayout.widget.ConstraintLayout>