Android 沙箱机制

一、存储空间分类

1、内部存储,无需权限,卸载删除

getCacheDir(): /data/user/0/com.example.storagedemo/cache
getFilesDir(): /data/user/0/com.example.storagedemo/files

2、外部存储,无需权限,卸载删除

getExternalCacheDir(): /storage/emulated/0/Android/data/com.example.storagedemo/cache
getExternalFilesDir(Environment.DIRECTORY_PICTURES): /storage/emulated/0/Android/data/com.example.storagedemo/files/Pictures

3、外部存储,需要权限或通过MediaStore操作,卸载不删除

Environment.getExternalStorageDirectory(): /storage/emulated/0
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES): /storage/emulated/0/Pictures

二、存储空间使用

1、Android 11增加新的权限,允许管理所有文件,可以随意操作存储空间

android.permission.MANAGE_EXTERNAL_STORAGE

// 在AndroidManifest.xml中添加以下权限(Android 11,SDK_INT = 30增加)
<uses-permission
    android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
    tools:ignore="ScopedStorage" />

// 权限检测与申请
class MainActivity : AppCompatActivity(), View.OnClickListener {
    companion object {
        const val TAG = "StorageDemo"
    }

    private var mRequestManagePLauncher: ActivityResultLauncher<Intent>? = null

    private fun initView() {
        mRequestManagePLauncher =
            registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
                if (hasManageP()) {
                    Toast.makeText(this@MainActivity, "申请管理权限成功", Toast.LENGTH_SHORT).show()
                } else {
                    Toast.makeText(this@MainActivity, "申请管理权限失败,请重试", Toast.LENGTH_SHORT).show()
                }
            }
    }

    /**
     * 判断管理权限
     */
    private fun hasManageP(): Boolean {
        return Environment.isExternalStorageManager()
    }

    /**
     * 请求管理权限
     */
    private fun requestManagerP() {
        mRequestManagePLauncher?.launch(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
            .apply {
                if (this == null) {
                    Toast.makeText(this@MainActivity, "跳转异常,请稍后重试", Toast.LENGTH_SHORT).show()
                }
            }
    }

    /**
     * 如果有管理权限,则可以随意操作存储空间
     */
    private fun writeFileWithP() {
        if (hasManageP()) {
            val writeFile = File(
                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
                "write.txt"
            )
            var fOS: FileOutputStream? = null
            try {
                fOS = FileOutputStream(writeFile)
                fOS.write("我是测试内容".toByteArray())
                Toast.makeText(
                    this@MainActivity,
                    "写入成功:${writeFile.absolutePath}",
                    Toast.LENGTH_SHORT
                ).show()
            } catch (e: java.lang.Exception) {
                Toast.makeText(this@MainActivity, "写入失败:$e", Toast.LENGTH_SHORT).show()
            } finally {
                try {
                    fOS?.close()
                } catch (e: java.lang.Exception) {
                    Log.v(TAG, "error:$e")
                }
            }
        } else {
            requestManagerP()
        }
    }
}
2、如果不申请MANAGE_EXTERNAL_STORAGE权限的话,则需要通过MediaStore操作存储空间

通过MediaStore获取到的Uri主要有以下几种:
外部Uri
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
内部Uri
MediaStore.Video.Media.INTERNAL_CONTENT_URI
MediaStore.Audio.Media.INTERNAL_CONTENT_URI
MediaStore.Images.Media.INTERNAL_CONTENT_URI

共享文件,包括媒体文件和非媒体文件
MediaStore.Files.getContentUri("external") // 操作external.db数据库

(1)查询

需要注意:
1、当targetSdk 29时,如果想通过MediaStore获取公共媒体文件,则必须申请READ_EXTERNAL_STORAGE权限;
2、当targetSdk 30时,即使申请了READ_EXTERNAL_STORAGE权限,也可能无法获取到公共媒体文件(自己创建的除外),此时必须申请MANAGE_EXTERNAL_STORAGE才可以继续获取。

class MainActivity : AppCompatActivity(), View.OnClickListener {
    companion object {
        const val TAG = "StorageDemo"
        const val REQUEST_READP_CDOE = 10000
    }

    private fun queryPic() {
        if (hasReadP()) {
            val imageExternalUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
            // 查询哪几类
            val projection = arrayOf(
                MediaStore.MediaColumns._ID,
                MediaStore.MediaColumns.DATA,
                MediaStore.MediaColumns.RELATIVE_PATH,
                MediaStore.MediaColumns.DISPLAY_NAME
            )
            // 查询条件
            val selection =
                "${MediaStore.MediaColumns.RELATIVE_PATH} = ? "
            // 参数
            val selectionArgs = arrayOf(
                Environment.DIRECTORY_DCIM + "/Camera/"
            )
            // 排序条件
            val order = MediaStore.MediaColumns._ID
            val cursor = contentResolver.query(
                imageExternalUri,
                projection,
                selection,
                selectionArgs,
                order
            )
            cursor?.apply {
                val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
                val dataIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATA)
                val relativePathIndex = cursor.getColumnIndex(MediaStore.MediaColumns.RELATIVE_PATH)
                val displayNameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)
                while (cursor.moveToNext()) {
                    val log = """
                    id = ${cursor.getString(idIndex)}
                    data = ${cursor.getString(dataIndex)}
                    relativePath = ${cursor.getString(relativePathIndex)}
                    displayNameIndex = ${cursor.getString(displayNameIndex)}
                """.trimIndent()
                    Log.v(TAG, log)
                }
                cursor.close()
            }
        } else {
            requestReadP()
        }
    }

    /**
     * 判断读取权限
     */
    private fun hasReadP(): Boolean {
        return ContextCompat.checkSelfPermission(
            this,
            Manifest.permission.READ_EXTERNAL_STORAGE
        ) == PackageManager.PERMISSION_GRANTED
    }

    private fun requestReadP() {
        ActivityCompat.requestPermissions(
            this,
            arrayOf(
                Manifest.permission.READ_EXTERNAL_STORAGE
            ),
            REQUEST_READP_CDOE
        )
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        if (requestCode == REQUEST_READP_CDOE) {
            if (grantResults.isNotEmpty()) {
                if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    Toast.makeText(this@MainActivity, "读取权限申请成功", Toast.LENGTH_SHORT).show()
                } else {
                    Toast.makeText(this@MainActivity, "读取权限申请失败,请稍后重试", Toast.LENGTH_SHORT).show()
                }
            }
        }
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    }
}
(2)插入

需要注意:
1、如果插入的文件手动删除时,可能导致MediaStore不更新,再次插入报错:QLiteConstraintException: UNIQUE constraint failed: files._data
2、此时,可以通过改变每次插入的文件名称避免此问题;也可以在插入前先搜索是否有同样信息的文件,执行update或delete,防止出错。

    private fun insertPic() {
        val imageExternalUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI

        val values = ContentValues()
        values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/Camera/")
        values.put(
            MediaStore.MediaColumns.DISPLAY_NAME,
            "testPic_${System.currentTimeMillis()}.png"
        )
        values.put(MediaStore.MediaColumns.MIME_TYPE, "image/png")
        val resultUri = contentResolver.insert(imageExternalUri, values)
        resultUri?.apply {
            val lightOpenBitmap = BitmapFactory.decodeResource(resources, R.drawable.light_open)
            val oPS = contentResolver.openOutputStream(resultUri)
            try {
                lightOpenBitmap.compress(Bitmap.CompressFormat.PNG, 100, oPS)
                Toast.makeText(this@MainActivity, "插入成功", Toast.LENGTH_SHORT).show()
            } catch (e: java.lang.Exception) {
                Toast.makeText(this@MainActivity, "插入失败,请稍后重试", Toast.LENGTH_SHORT).show()
            }
            oPS?.apply {
                try {
                    oPS.close()
                } catch (e: java.lang.Exception) {
                    Log.v(TAG, "error:$e")
                }
            }
        }
    }
(3)更新

需要注意:
1、需要通过查询拿到文件的原始Uri,如果直接通过MediaStore.Images.Media.EXTERNAL_CONTENT_URI进行更新的话,会报错:java.lang.IllegalArgumentException: Movement of content://media/external/images/media which isn't part of well-defined collection not allowed
2、应用只能修改自己插入的文件,其他文件或者应用卸载前插入的文件,应用不再有权限修改,否则会报:android.app.RecoverableSecurityException: com.example.storatedemo1 has no access to content://media/external/images/media/1000014102
3、Android 11手机,申请了MANAGE_EXTERNAL_STORAGE,可随意操作

     /**
     * 更新
     */
    private fun updatePic() {
        if (hasReadP()) {
            val imageExternalUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI

            val values = ContentValues()
            values.put(
                MediaStore.MediaColumns.RELATIVE_PATH,
                Environment.DIRECTORY_DCIM + "/Camera/"
            )
            values.put(
                MediaStore.MediaColumns.DISPLAY_NAME,
                "updatePic_${System.currentTimeMillis()}.png"
            )
            values.put(MediaStore.MediaColumns.MIME_TYPE, "image/png")

            // 查询条件
            val selection =
                "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} like ?"
            // 参数
            val selectionArgs = arrayOf(
                Environment.DIRECTORY_DCIM + "/Camera/",
                "testPic_%.png"
            )
            val cursor =
                contentResolver.query(imageExternalUri, null, selection, selectionArgs, null)
            cursor?.apply {
                if (cursor.moveToNext()) {
                    val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
                    val imageUri =
                        ContentUris.withAppendedId(imageExternalUri, cursor.getLong(idIndex))
                    val count = contentResolver.update(imageUri, values, null, null)
                    if (count > 0) {
                        Toast.makeText(this@MainActivity, "更新成功", Toast.LENGTH_SHORT).show()
                    } else {
                        Toast.makeText(this@MainActivity, "更新失败", Toast.LENGTH_SHORT).show()
                    }
                } else {
                    Toast.makeText(this@MainActivity, "没有找到要更新的图片", Toast.LENGTH_SHORT).show()
                }
                cursor.close()
            }
        } else {
            requestReadP()
        }
    }
(4)删除

需要注意:
1、应用只能删除自己插入的文件,其他文件或者应用卸载前插入的文件,应用不再有权限删除
2、Android 11手机,申请了MANAGE_EXTERNAL_STORAGE,可随意操作

    /**
     * 删除图片
     */
    private fun deletePic() {
        if (hasReadP()) {
            val imageExternalUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
            // 查询条件
            val selection =
                "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} like ?"
            // 参数
            val selectionArgs = arrayOf(
                Environment.DIRECTORY_DCIM + "/Camera/",
                "testPic_%.png"
            )
            val deleteCount = contentResolver.delete(imageExternalUri, selection, selectionArgs)
            Toast.makeText(this@MainActivity, "删除了${deleteCount}行", Toast.LENGTH_SHORT).show()
        } else {
            requestReadP()
        }
    }
3、通过SAF操作文件

App可以通过Action来启动系统选择器,让用户做相关的操作:
ACTION_OPEN_DOCUMENT:打开用户选择的文件
ACTION_CREATE_DOCUMENT:在用户选择的位置创建文件
ACTION_OPEN_DOCUMENT_TREE:访问某个目录

class MainActivity : AppCompatActivity() {
    private var mSAFLauncher: ActivityResultLauncher<Intent>? = null

    private fun openFileSAF() {
        // 创建一个intent,并进行跳转
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = "*/*"
        }
        mSAFLauncher?.launch(intent)
    }

    private fun initView() {
        mSAFLauncher =
            registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
                if (it.resultCode == Activity.RESULT_OK) {
                    // 获取Uri,通过Uri获取文件信息
                    val uri = it.data?.data
                    uri?.apply {
                        val documentFile = DocumentFile.fromSingleUri(this@MainActivity, this)
                        documentFile?.apply {
                            val log = """
                            name = ${documentFile.name}
                            parentFile = ${documentFile.parentFile}
                            type = ${documentFile.type}
                        """.trimIndent()
                            Log.v(TAG, log)
                        }
                    }
                }
            }
    }
}

申请Uri的永久访问权限

val contentResolver = applicationContext.contentResolver
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
        Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(uri, takeFlags)

其他信息可查看官方文档

三、附录

1、Media库常用字段表
字段 描述
_id 主键,自增
_data 绝对路径
_size 大小,单位byte
_display_name 文件名,如:aa.jpg
mime_type 文件类型,如:image/jpeg
title 文件名,无扩展名,如:aa
bucket_display_name 直接包含该文件的文件夹名称
duration 时长
2、常用mime_type值
名称 MIME type
png image/png
gif image/gif
jpeg jpg jpe image/jpeg
txt text conf def list log in text/plain
mp4 mp4v mpg4 video/mp4
mpga mp2 mp2a mp3 m2a m3 audio/mpeg
json application/json
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容