前言
Android7.0的文件系统权限更新:https://developer.android.google.cn/about/versions/nougat/android-7.0-changes?hl=en#permfilesys
在Android7.0的时候,系统禁止使用file:// 形式的URI,需要改为使用content:// 形式的URI,所以需要适配的就是把项目中的Uri.fromFile(file) 替换为FileProvider.getUriForFile(context, authority, file) ,因为前者会生成file:// 形式的URI,而后者会生成content:// 形式的URI。
ContentProvider是Android的四大组件之一,可用于共享内容,比如共享数据库中的数据,文件数据,服务器数据等等,如果需要共享的是文件数据,则可以不用实现ContentProvider,系统已经实现了一个:FileProvider,通过FileProvider即可共享文件给其它应用,且可以保证安全性。
关于共享文件的官方文档:
服务器端
这里的服务器端指的是提供文件服务的App。
假设应用的包名为:a.b.c
比如,我们希望将保存在应用的/data/data/a.b.c/files/images 目录中的图片共享给别的应用,共享时只能读不能写,这时就可以通过FileProvder 来实现。我们知道应用的私有目录其它应用是访问不了的,但是通过FileProvider 就可以访问。
首先,需要在清单文件中声明provider ,并指定需要共享的目录,比如我们要把/data/data/a.b.c/files/images 目录进行共享,示例如下:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="a.b.c.hello"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
file_paths.xml (位于res/xml 目录中)文件用于指定要共享的目录,如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<files-path path="images/" name="myimages" />
</paths>
</resources>
这里的files-path 就表示/data/data/a.b.c/files/ 目录,path=“images/" 中的images/ 路径表示是files-path 的子目录,这样完整的共享目录为:/data/data/a.b.c/files/images/ ,name="myimages" 的myimages 为完整的共享目录设设置了一个别名,这在生成文件URI的时候就会使用这个别名来代替真实路径,这样别人通过这个URI就无法知道访问的文件在什么位置,这样就比较安全。
在provider 中,我们配置了android:authorities 的值为a.b.c.hello ,并设置了共享的目录为myimages (这是一个别名,实际内容为/data/data/a.b.c/files/images ),假设在images 目录中有一个cat.jpg 的文件,则这个文件的URI 为:content://a.b.c.hello/myimages/cat.jpg ,这个URI 就可以提供给别的App 使用,别的App 通过这个URI 可以读取这张图片,但是它无法知道这张图片保存在哪里,而且只有读的权限,这样就保证了安全,这就是FileProvider 的作用。
基于上面的xml 配置,我们创建一个Activity ,用于接收客户端的获取文件请示,在此Activity 中将列出images 目录中的文件,并用让用户选择一个,然后把用户选择的文件的URI 返回给客户端,这样客户端就可以通过此URI来读取对应的文件了。
<activity
android:name=".FileSelectActivity"
android:label="File Selector"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PICK"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.OPENABLE"/>
<data android:mimeType="text/plain"/>
<data android:mimeType="image/*"/>
</intent-filter>
</activity>
这个FileSelectActivity 的功能是把images 目录中的文件列出来让用户选择,这里我们简单一点,就一个按钮,就选择一个文件,因为列出目录不是我们的重点,界面如下: 代码如下:
class FileSelectActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_file_select)
val button: Button = findViewById(R.id.button)
button.setOnClickListener {
val imagesDir = File(filesDir, "images")
if (!imagesDir.exists()) {
imagesDir.mkdir()
}
val imageFile = File(imagesDir, "hello.txt")
val imageUri = FileProvider.getUriForFile(this, "a.b.c.hello", imageFile)
val resultIntent = Intent("a.b.c.ACTION_RETURN_FILE")
resultIntent.setDataAndType(imageUri, contentResolver.getType(imageUri))
resultIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
setResult(Activity.RESULT_OK, resultIntent)
finish()
}
}
}
注:为了验证结果的简单性,这里我没有使用图片,而是使用了一个文本文件,我们同时给了读和写的权限。也可以只给读取权限,这样更安全。
OK,服务器端的代码就写好了,总结如下:
- 在代码清单中声明FileProvider并指定要共享的目录
- 创建一个可供用户选择共享目录中的文件的界面,当用户选择文件后,我们把文件对应的URI返回给用户
客户端
客户端界面也只有一个按钮,如下:
代码如下:
class MainActivity : AppCompatActivity() {
private val requestCode = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val button: Button = findViewById(R.id.button)
button.setOnClickListener {
val requestIntent = Intent(Intent.ACTION_PICK)
requestIntent.type = "text/plain"
requestIntent.resolveActivity(packageManager)?.let {
startActivityForResult(requestIntent, requestCode)
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == this.requestCode && resultCode == Activity.RESULT_OK) {
val fileUri = data!!.data!!
val cursor = contentResolver.query(fileUri, null, null, null, null)!!
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
val fileName = cursor.getString(nameIndex)
val fileSize = cursor.getLong(sizeIndex).toString()
var parcelFileDescriptor = contentResolver.openFileDescriptor(fileUri, "w")
var fileDescriptor = parcelFileDescriptor!!.fileDescriptor
FileOutputStream(fileDescriptor).use { fos -> fos.writer().use { it.write("你好世界!Hello World!") } }
parcelFileDescriptor = contentResolver.openFileDescriptor(fileUri, "r")
fileDescriptor = parcelFileDescriptor!!.fileDescriptor
val fileContent = FileInputStream(fileDescriptor).use { fis -> fis.reader().use { it.readText() } }
Log.i("MainActivity","URI = $fileUri\n" +
"URI IMEI = ${contentResolver.getType(fileUri)}\n" +
"fileName = $fileName\n" +
"fileSize = $fileSize\n" +
"fileContent = $fileContent")
cursor.close()
}
}
}
运行结果如下:
URI = content://a.b.c.hello/myimages/hello.txt
URI IMEI = text/plain
fileName = hello.txt
fileSize = 0
fileContent = 你好世界!Hello World!
OK,通过文件共享功能,我们在客户端App把数据写到了服务器端App的hello.txt 文件中,并读取到了写进去的文件的内容,而且客户端不需要读写权限。
这里需要注意的是:获取的文件大小为0,刚写完文件马上获取大小就会是0,属性系统Bug吧应该。还有contentResolver.openFileDescriptor(fileUri, "w") 中的模式可以使用rw ,但是我发现如果使用这个模式,也就是只使用同一个fileDescriptor来进行写和读的操作,则写进去之后立马就读,是读不出文件里面的内容的。所以我上面才分别用w 和r 进行写和读。
另外还需要注意的是:如果要往服务器端写入文件,则服务器端的目录必须存在,否则无法写入文件,所以需要在服务器端判断目录是否存在,不存在就创建目录,而文件是可以不存在的,因为在客户端写入数据的时候如果文件不存在就会自动创建。
可用的配置目录
假设应用的包名为:a.b.c
在xml中,有以下可以使用的path:
root-path :/ ,代表设备的根目录,对应:File("/") files-path :/data/data/a.b.c/files/ ,对应:Context.getFilesDir() cache-path :/data/data/a.b.c/cache/ ,对应: Context.getCacheDir() external-path :/sdcard/ ,这个路径的真实路径为/storage/emulated/0 ,当然不同版本手机可能会不一样,比如有的手机sdcard 的真实路径为:/data/user/0 ,对应:Environment.getExternalStorageDirectory() external-files-path :/sdcard/Android/data/a.b.c/files/ ,对应:Context.getExternalFilesDir(null) external-cache-path :/sdcard/Android/data/a.b.c/cache/ ,对应:Context.getExternalCacheDir() external-media-path :/sdcard/Android/media/a.b.c ,对应Context.getExternalMediaDirs() ,注:此目录仅在 API 21+ 设备上可用。
配置的目录是否对子目录生效的研究
假设应用的包名为:a.b.c
假设我们要共享Download目录:/sdcard/Android/data/a.b.c/files/Download/
则需要配置:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<external-files-path path="Download/" name="good"/>
</paths>
</resources>
此时我们在Download 目录下创建一个hello 目录,并在里面存入一个文件:test.txt ,然后在代码中使用该文件生成URI,代码如下:
val dir = File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "hello")
val file = File(dir, "test.txt")
val authority = "a.b.c.file_provider"
val uri = FileProvider.getUriForFile(this, authority, file)
Log.i("ABCD", "uri = $uri)
打印结果如下:
apkUri = content://a.b.c.file_provider/good/hello/test.txt
能生成URI,就说明配置的共享目录是包含子目录的。在生成的URI中可以看到test.txt 是保存在hello 目录中,这样就暴露了一点点的路径信息,所以如果为了更安全,不应该使用子目录,或者为子目录再配置一个路径,如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<external-files-path path="Download/" name="good"/>
<external-files-path path="Download/hello" name="nice"/>
</paths>
</resources>
再次运行代码,打印结果如下:
apkUri = content://a.b.c.file_provider/nice/test.txt
这样,子目录hello 就不再出现在URI中了,如果能把URI中的文件名也隐藏就更好了,可惜目前系统没有提供这样的实现。
此时,我们修改一下代码,想读取Documents(/sdcard/Android/data/a.b.c/files/Documents/ )中的文件:
val file = File(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "test.txt")
再次运行,程序将会崩溃,因为我们配置的共享目录是Download ,而这里的Documents 目录和Download 目录是同级目录,不是父子目录,所以需要为Documents 再进行配置,有两种方式,如下:
配置共享Documents 目录:
<external-files-path path="Documents/" name="aaa"/>
或者配置共享Documents 的整个父目录(即:/sdcard/Android/data/a.b.c/files/ ):
<external-files-path path="" name="bbb"/>
或:
<external-files-path path="." name="bbb"/>
这就代表共享整个/sdcard/Android/data/a.b.c/files/ 目录,所以files 下的Download 目录和Documents 目录都是包含在内的,这样的缺点我们之前也说了,即子目录中的文件生成的URI会包含有子目录的名称。
相同的,如果想要共享整个sdcard,则可以配置:
<external-path path="" name="ccc"/>
或:
<external-path path="." name="ccc"/>
还有一个更万能的,直接配置共享根目录,如下:
<root-path path="." name="yes"/>
则/sdcard/Android/data/a.b.c/files/Documents/test.txt 对应的URI为:
content://a.b.c.file_provider/yes/storage/emulated/0/Android/data/a.b.c/files/Documents/test.txt
如果我们访问的是应用的安装目录files中的test.txt,则URI为:
content://a.b.c.file_provider/yes/data/data/a.b.c/files/test.txt
这种方式的的缺点非常明显:它把文件的完整路径都暴露出来了,如果用户的手机有root权限,就可以很轻松的通过文件浏览器来获取到我们保存文件的目录中的所有文件了。
因为root-path 代表的是整个设备的根目录,即/ ,而且我们配置的是共享整个根目录,所以它下面的所有子目录都可以共享,那么所有这些子目录的路径都会出现在URI上。当我们需要共享的目录很多,我们又懒得去一个一个配置,而且也不考虑暴露文件路径问题的话,就可以直接配置一个根目录。当然,还需要提醒的是,你要共享的文件所在的目录,你需要拥有对应的读写权限才可以,比如sdcard的读写权限。
库项目FileProvider 冲突问题
一、file_paths.xml被覆盖的解决方案
在库项目中有file_paths.xml 文件,内容如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<files-path path="." name="lib"/>
</paths>
</resources>
库项目的清单文件配置如下:
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="a.b.c.file_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
此时写一个App依赖这个库,并在App代码中获取一个文件的URI,如下:
val file = File(filesDir, "app-release.apk")
val authority = "a.b.c.file_provider"
val apkUri = FileProvider.getUriForFile(this, authority, file)
Log.i("ABCD", "apkUri = $apkUri")
运行结果如下:
apkUri = content://a.b.c.file_provider/lib/app-release.apk
接下来把库项目中的file_paths.xml 复制到App项目中,然后修改文件内容如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<files-path path="." name="app"/>
</paths>
</resources>
这里只是简单的把name 属性由lib 改为app ,再次运行项目,输出结果如下:
apkUri = content://a.b.c.file_provider/app/app-release.apk
从输出的URI中可以看到,URI路径中的lib 变成了app ,这充分说明了使用的是App项目中的file_paths.xml 的配置,所以,为了预防库项目的配置被App覆盖,不要使用这种通用的名字,要起特殊一点的名字,比如Glide 库可以这样起名字:glide_file_paths.xml
二、FileProvider同名冲突解决方案一
创建一个库,并声明provider 和对应的xml 配置,如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="cn.android666.mylibrary">
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="a.b.c.file_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/glide_file_paths" />
</provider>
</application>
</manifest>
glide_file_paths.xml 文件内容如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<files-path path="." name="lib"/>
</paths>
</resources>
在这个库里面,配置了要共享应用的files 目录:/data/data/a.b.c/files ,创建一个App依赖这个库,并在App的代码中获取一个文件的URI,如下:
val apkFile = File(filesDir, "app-release.apk")
val authority = "a.b.c.file_provider"
val apkUri = FileProvider.getUriForFile(this, authority, apkFile)
Log.i("ABCD", "apkUri = $apkUri")
运行程序,一切正常。打印的URI如下:
apkUri = content://a.b.c.file_provider/lib/app-release.apk
此时我们直接把库项目中的provider 声明和glide_file_paths.xml 直接复制到App项目中,再次运行,还是一切正常,说明如果App和库中声明有一模一样的provider 时不会有任何影响。
此时我们把App中的glide_file_paths.xml 重命名为app_file_paths.xml ,并修改files-path 为external-files-path ,且把name的值由lib 改为app ,如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<external-files-path path="." name="app"/>
</paths>
</resources>
然后再修改App中provider 的authorities ,如下:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="d.e.f.file_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/app_file_paths" />
</provider>
此时我们运行App,编译报错如下: 这里有两个错误,错误提示说整合清单文件失败,因为在打包apk时,系统需要把App项目中的清单文件和库项目中的清单文件合并成一个,因为provider 用的都是FileProvider ,所以App和库中的需要合并为一个,此时系统发现App和库项目中的authorities 值不同,resource 值也不同,它建议我们可以使用tools:replace 属性来覆盖库项目中的设置,那我们就使用它的建议,修改后如下:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="d.e.f.file_provider"
tools:replace="android:authorities"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/app_file_paths"
tools:replace="android:resource" />
</provider>
再次运行App,编译就没有报错了,但是App会崩溃,因为在App的代码中,我们使用的authority 为a.b.c.file_provider ,但是我们现在在App中已经改为了d.e.f.file_provider ,且我们已经配置共享的目录为外部存储的files 目录,所以代码中也做对应的修改,如下:
val file = File(getExternalFilesDir(null), "app-release.apk")
val authority = "d.e.f.file_provider"
val apkUri = FileProvider.getUriForFile(this, authority, file)
Log.i("ABCD", "apkUri = $apkUri")
就可以正常运行了。此时打印的URI地址如下:
apkUri = content://d.e.f.file_provider/app/app-release.apk
可以看到,这完全是使用的App中的配置了,库中的配置被完全给覆盖了,那库中的配置就没用了,怎么解决这个问题呢?其实搞懂了FileProvider 的作用之后,解决起来就很简单了,我们把库中的配置在App中也进行配置就行了,我们找到库的清单文件,找到它的authorities ,复制它,把它也设置到App中,如下:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="d.e.f.file_provider;a.b.c.file_provider"
。。。
>
。。。
</provider>
可以看到,这里我们设置了两个authority,多个authority之间用分号(; )隔开,这样在代码中使用authority时,用哪一个authority都可以。
然后再找到库中的glide_file_paths.xml 的配置,复制到App项目的app_file_paths.xml 配置中,如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<external-files-path path="." name="app"/>
<files-path path="." name="lib"/>
</paths>
</resources>
OK,这样在我们的App项目中的provider 就包含有库项目中provider 的所有配置了,我们写代码验证一下,代码如下:
val file1 = File(filesDir, "app-release.apk")
val file2 = File(getExternalFilesDir(null), "app-release.apk")
val authority1 = "d.e.f.file_provider"
val authority2 = "a.b.c.file_provider"
val apkUri1 = FileProvider.getUriForFile(this, authority1, file1)
val apkUri2 = FileProvider.getUriForFile(this, authority2, file2)
Log.i("ABCD", "apkUri1 = $apkUri1")
Log.i("ABCD", "apkUri2 = $apkUri2")
运行结果如下:
apkUri1 = content://d.e.f.file_provider/lib/app-release.apk
apkUri2 = content://a.b.c.file_provider/app/app-release.apk
三、FileProvider同名冲突解决方案二
App 和库 同时声明FileProvider 的冲突解决方案还有一个更简单的办法,那就是使用自定义的FileProvider ,FileProvider 是ContentProvider 的子类,一个项目中是可以存在多个ContentProvider 的,只要它们是不同的类即可,示例如下:
class GlideFileProvider : FileProvider()
这里我们就简单继承FileProvider 即可,不需要重写任何功能,然后在清单文件中使用我们自己的GlideFileProvider ,即可,如下:
<application>
<provider
android:name="cn.android666.mylibrary.GlideFileProvider"
android:authorities="a.b.c.file_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/glide_file_paths" />
</provider>
</application>
这样我们的App项目中相当于拥有两个不一样的provider 了:FileProvider 、GlideFileProvider ,所以在清单文件中可以对这两个provider 分别设置,就不存在冲突的问题了,当然了,两个provider 的authorities 的值还是不能一样的,这个我是实验过了,如果一样的话,在运行时,它将只会使用第一个配置的provider ,第二个provider 就相当于是没用的,所以,在编辑器中不会提示错误,可以正常运行,但是当代码中文件的路径用到第二个provider 配置的目录时,将会报异常。
使用了自定义的provider 后,App项目中的以下两个replace 语句就可以删掉了:
tools:replace="android:authorities" tools:replace="android:resource"
这种解决方案是推荐的方案,因为App中很有可能会在清单文件中也声明使用FileProvider ,如果我们的库项目也使用FileProvider 的话,这是很容易出现冲突问题的。
四、手机不能安装多个拥有相同authorities 的App
前面的例子中,我们在库中声明的authorities 是写死的,假设有两个App都依赖了这个库,则这两个App就会拥有相同的authorities ,那么这两个App只能有一个安装到手机上,再安装第二个时就安装不上了。解决方案也很简单,在库中配置一个动态的包名即可,如下:
<provider
android:name="cn.android666.mylibrary.GlideFileProvider"
android:authorities="${applicationId}.GlideFileProvider"
。。。>
。。。
</provider>
这里,我们使用${applicationId} 这个变量来引用应用的包名,假设我们有两个App,分别叫A和B,包名分别如下:
- A:
com.example.hello - B:
com.example.world
则,当我们的裤项目应用到这两个App上时,authorities 的值将被替换为如下:
- A:
android:authorities="com.example.hello.GlideFileProvider" - B:
android:authorities="com.example.world.GlideFileProvider"
另外需要提醒的是,在库项目中的android:authorities 尽量不要设置那些常用的值,比如下面的:
android:authorities="${applicationId}.FileProvider"
因为这种方式的authorities 是比较常用的,别人在依赖我们的库之后,虽然我们的库中使用了自定义的GlideFileProvider ,但是别人还是有可能在应用的provider 中配置出一样的authorities 值。
五、应用细节
搞懂了以上原理之后,我有了一些思考,其实一个App上我们可以定义两个provider ,一个用于分享给系统的,一个用于分享给第三方App的,分享给第三方App的provider 配置的目录就要分的比较细,以更好的隐藏路径信息,而分享给系统的provider 配置就可以简单一些,比如用下面的配置:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<root-path path="." name="system" />
</paths>
</resources>
这里,我们使用一了一个最宽广的设备根目录,这样一个配置就包含了所有目录了,只是这样在获取文件的URI 时会暴露完整的路径信息,但是这无所谓啊,这是分享给系统的怕什么,比如安装App,把App的路径分享给系统进行安装,这没什么的呀!当然了,了解了原理之后,第三方的App也可以创建一个可以接收安装App请求的Activity ,这样我们的安装请求也可以选择使用第三方应用,这样第三方应用就能获取到我们的URI ,从而看到完整地址,所以这还是要看具体情况具体决策了,就比如我的一个工具库,里面有一个安装App的方法,我们公司的所有项目安装App的功能都依赖这个库来完成,但是我不确定使用我的库的同事会把apk下载保存在什么位置,所以我就可以配置直接使用根目录,这样的话apk保存在什么位置都可以安装,即便有用心不良的人要获取我apk下载的保存位置,但是我认为apk不是什么私密文件,让别人获取也没什么,所以我可以这样做。
另外,我们说xml 配置名为file_paths.xml 或filepaths.xml 容易被覆盖,所以我们在创建库项目中不要使用这些名称。其实不管是不是在库项目中,就算是在应用的项目中也不应该使用这些名字,假设我们的应用依赖了一个第三方库,如果第三方库正好也是这个名字,则会把第三方库的给覆盖了,这样运行报错时我们还不容易找出原因,且最好应用中的FileProvider 也使用自定义的,以免有些第三方库中的声明就是直接使用了FileProvider
FileProvider的其他应用
使用第三方应用完成拍照功能
files-path 代表应用的内部私有files目录,如:/data/data/a.b.c/files external-files-path 代表应用的外部私有files目录,如:/sdcard/Android/data/a.b.c/files
这次我们想要拍照功能,但是我们不想写关于拍照的功能,所以我们可以委托别的拍照应用来帮我们实现拍照的功能,我们要告诉第三方的拍照应用一个文件存储路径,即指明照片保存在哪里,为了安全,这个存储路径我们不能直接暴露给别人,所以此时可以采用FileProvider 来提供一个URI 的形式,这样第三方应用就可以通过这个URI 来存入图片,示例代码如下:
这次,我们希望图片保存在外部存储目录的私有目录中:/sdcard/Android/data/a.b.c/files/Pictures ,因为外部存储空间(即sdcard空间)一般内部存储空间大,所以配置的共享目录如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<external-files-path path="Pictures/" name="my_images" />
</paths>
</resources>
界面只有 一个按钮: 代码如下:
class MainActivity : AppCompatActivity() {
private val takePhotoButton: Button by lazy { findViewById(R.id.takePhotoButton) }
private lateinit var photoFile: File
private val requestCode = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
takePhotoButton.setOnClickListener {
takePhoto()
}
}
private fun takePhoto() {
val takePhotoIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
takePhotoIntent.resolveActivity(packageManager)?.let {
val photoDir: File = getExternalFilesDir(Environment.DIRECTORY_PICTURES)!!
val timeStamp = DateFormat.format("yyyyMMdd_HHmmss", Date())
photoFile = File(photoDir, "${timeStamp}.jpg")
val authority = "a.b.c.haha"
val photoURI: Uri = FileProvider.getUriForFile(this, authority, photoFile)
takePhotoIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI)
startActivityForResult(takePhotoIntent, requestCode)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == this.requestCode && resultCode == RESULT_OK) {
Log.i("MainActivity", "拍照成功:$photoFile")
}
}
}
运行结果如下:
拍照成功:/storage/emulated/0/Android/data/a.b.c/files/Pictures/20220307_142205.jpg
OK,这样我们就实现了拍照功能,而且我们的应用无需读写权限,也不需要摄像头权限,这里的关键就是要配置一个共享目录,然后通过URI的方式告诉第三方的拍照应用把拍到图片保存在我们指定的目录中。
使用第三方应用完成安装App功能
当我们的应用在做app更新的功能时,app的新版本下载后需要安装,但是我们不知道如何安装app,所以我们可以把安装app的工作交给系统,我们的apk文件可能存在一个私有的目录,所以只能通过URI的方式来把我们的文件共享给系统,让系统有权限读取我们的apk来进行安装操作。(其实不论我们的apk保存的目录是否是私有的目录中的,系统仅接受URI形式的apk路径)
假设我们下载的apk保存在外部的files私有目录的Download目录中(如:/sdcard/Android/data/a.b.c/files/Download ),则共享目录的配置如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<external-files-path path="Download/" name="hello"/>
</paths>
</resources>
当我们把下载的apk保存在Download 目录后,就需要通过URI的形式来告知系统我们的apk的位置,以便让系统实现安装apk的功能,代码如下:
class MainActivity : AppCompatActivity() {
@SuppressLint("MissingPermission")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val button: Button = findViewById(R.id.button)
button.setOnClickListener { installApk() }
}
fun installApk() {
val apkDir: File = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!
val apkFile = File(apkDir, "app-release.apk")
val authority = "a.b.c.file_provider"
val apkUri = FileProvider.getUriForFile(this, authority, apkFile)
val type = contentResolver.getType(apkUri)
val intent = Intent(Intent.ACTION_VIEW)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.setDataAndType(apkUri, type)
} else {
intent.setDataAndType(Uri.fromFile(apkFile), type)
}
startActivity(intent)
}
}
OK,这样我们就实现了apk的安装功能。
安装apk权限问题
好像是从Android8.0开始吧,需要加一个请求安装的权限,否则是不给安装的。反正不管什么版本,我们都加上这个权限就没问题了:<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
如果没有加入这个权限的话,则在安装时没有任何的响应,且程序也不会崩溃,也没有报错信息。
存储权限问题
在内部私有目录(如:/data/data/a.b.c/files )或者外部私有目录(如:/sdcard/Android/data/a.b.c/files )中的文件操作是不需要存储权限的。
当我们的apk保存在一个公共的目录中时(比如/sdcard/Download ),则我们的应用就没有对应的操作权限了,我们自己连权限都没有,又何谈把文件共享给别人操作呢?所以,首先我们需要有操作这个目录的权限,然后才能把这个目录中的文件共享给别人,所以,在这种情况下,我们需要申请sdcard的存储权限,且配置共享目录的地方也需要增加对应的目录,如下:
权限:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
配置共享目录:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<external-files-path path="Download/" name="hello"/>
<external-path path="Download/" name="world"/>
</paths>
</resources>
注:external-path 就代表了sdcard 的根目录,所以再配置上path="Download/" ,则完整的共享目录为:/sdcard/Download/
获取公共的Download 目录:
val apkDir: File = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
如果忘记了申请存储权限,则在安装时会报"解析软件包时出现问题。" 的异常,如下: 如果Apk本身有问题,或者Apk文件不存在,也会报上面的问题。
如果忘记了在xml中配置共享目录,则在运行时程序会直接崩溃,报如下异常:
E/AndroidRuntime: FATAL EXCEPTION: main
Process: cn.android666.apkinstalltest, PID: 14746
java.lang.IllegalArgumentException: Failed to find configured root that contains /storage/emulated/0/Download/app-release.apk
at androidx.core.content.FileProvider$SimplePathStrategy.getUriForFile(FileProvider.java:800)
at androidx.core.content.FileProvider.getUriForFile(FileProvider.java:442)
at cn.android666.apkinstalltest.MainActivity.installApk(MainActivity.kt:31)
at cn.android666.apkinstalltest.MainActivity.onCreate$lambda-0(MainActivity.kt:22)
at cn.android666.apkinstalltest.MainActivity.$r8$lambda$CKFhwXDHRAomH1WWe3PCDSwaagA(MainActivity.kt)
at cn.android666.apkinstalltest.MainActivity$$ExternalSyntheticLambda0.onClick(D8$$SyntheticClass)
at android.view.View.performClick(View.java:5703)
即在调用 FileProvider.getUriForFile(this, authority, apkFile) 函数时,系统就会检测你的文件是否在配置的共享目录中,如果没有就会抛出异常。
存储权限问题细节
在共享sdcard的相关位置时:
- 需要在清单文件中声明存储权限
- 在Android 6.0版本或更高,需要动态申请存储权限
- 在Android10版本中,动态申请权限还是不够的,还需要在清单文件的
application 节点中增加:android:requestLegacyExternalStorage="true" - 在Android11版本中,动态申请权限已经不管用了,即使设置了:
android:requestLegacyExternalStorage="true" 也不管用,需要申请<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/> 权限,且需要动态申请,具体申请方式可参考我的另一篇文章:https://blog.csdn.net/android_cai_niao/article/details/121956880
|