Google Q版本应用兼容性整改指导
1. 背景说明
Android Q的第一个beta版本已经在2019-3-13美国时间正式发布了,Q版本中伴随很多机制和新增特性的改变,对第三方应用带来了很多兼容性问题。本文档是基于谷歌安卓Q的beta1版本的变更输出的兼容性整改指导,如果后续beta版本有新的变更和新的特性,我们也会刷新文档的相关章节内容,请开发者持续关注。
1.1 Q版本计划
(1)Beta 1(初始版本,测试版)
(2)Beta 2(增量更新,测试版)
(3)Beta 3(增量更新,测试版)
(4)Beta 4(最终 API 和官方 SDK,在 Play 中发布,测试版)
(5)Beta 5(用于测试的候选版本)
(6)Beta 6(用于最终测试的候选版本)
(7)最终版本(面向 AOSP 和生态系统发布)
1.2 Q版本适配步骤建议
1、下载手机镜像,刷机。
2、开发环境和SDK:https://developer.android.com/studio/ 。
3、设置应用的targetSdkVersion和compileSDKVersion。
修改工程的build.gradle文件:
- android {
- compileSdkVersion 'android-Q'
- defaultConfig {
- targetSdkVersion 'Q'
- }
- ...
- }
4、阅读Q的应用行为变更,在应用中进行需要的调整。
5、阅读Q的新功能,在应用中合理使用。
1.3 刷机指导
1.3.1 版本下载
参考:https://developer.android.google.cn/preview/download.html,在该页面可以下载对应支持Q版本的手机版本。
1.3.2 刷机方法
刷手机的方法(刷机前需要退出之前登录的谷歌账号):
1、下载对应设备的压缩包并解压;
2、手机连上USB后,执行adb reboot bootloader命令;
3、等手机出现小机器人以后,并且显示‘unlock’字样时,执行压缩包中的bat脚本,脚本执行完成即可。
4、如果是“lock”字样,请参考:https://source.android.com/source/running#unlocking-the-bootloader,在刷机之前先对手机进行解锁操作:
(1)在设备上启用 OEM 解锁:
(a)在“设置”中,点按关于手机,然后点按版本号七次。
(b)当看到“您已处于开发者模式”这条消息后,点按返回按钮。
(c)点按开发者选项,然后启用 OEM 解锁和 USB 调试。(如果“OEM 解锁”处于停用状态,请连接到互联网,以便设备可以至少签到一次。如果“OEM 解锁”仍处于停用状态,则说明您的设备可能已被运营商锁定 SIM 卡,系统无法解锁引导加载程序。)
(2)重新启动进入引导加载程序,然后使用 fastboot 解锁。
(a)对于新款设备(2015 年及之后发布的设备):
- fastboot flashing unlock
(b)对于老款设备(2014 年及之前发布的设备):
- fastboot oem unlock
(3)在屏幕上确认解锁。
1.4 华为终端开放实验室Android Q 版本兼容性测试
开发者如果没有Pixel真机,也可以选择华为终端开放实验室提供的远程真机来进行Q版本测试和调试。请参考链接:华为终端开放实验室Android Q 版本兼容性测试上线。
2. 隐私和安全保护持续升级
2.1 存储空间的限制
2.1.1 背景介绍
在 Android Q 中,用户可以更好地管理应用对共享文件的访问权限。用户可通过新的运行时权限允许应用访问照片、视频或音频文件。此外,应用必需通过系统文件选择器才能访问下载文件,也就是说,访问权限完全由用户掌控。Android Q 还针对外部储存空间引入了一些变更,主要变更点总结:
1、Android Q为每个应用程序在外部存储设备提供了一个独立的存储沙箱,应用直接通过文件路径保存的文件都会保存在应用的沙箱目录,另外应用卸载的时候默认所有应用沙箱目录是会被删除。
2、共享集合:不希望应用卸载删除的文件,需要应用通过MediaProvider或者SAF的方式保存在公共共享集合目录,公共集合目录包括:多媒体文件集合(音频、视频和图片)以及下载文件集合。
3、权限变更:应用读写自己沙箱和共享集合目录中应用自己的文件是不需要申请任何权限的,但是如果应用需要读取其他应用生成的多媒体文件就需要申请权限:
(1)读取其他应用存放在共享集合的图片和视频文件,就需要分别申请READ_MEDIA_IMAGES和READ_MEDIA_VIDEO权限,具体要申请哪个权限取决于应用需要访问的文件类型;
(2)读取其他应用存放在共享集合的音乐类型文件,就需要申请READ_MEDIA_AUDIO权限;
(3)读取其他应用生成的多媒体文件,需要通过MediaProvider的接口读取,无法直接通过文件路径读取;
(4)系统只提供了多媒体文件的读权限,没有提供写权限,应用无法通过申请写权限修改其他应用生成的文件;
(5)下载目录的文件没有增加对应的权限,读取下载目录的文件需要通过SAF的方式读取。
4、写其他应用的多媒体文件,需要通过申请成为默认系统图库和音乐应用,或者让用户主动授权的方式实现。
5、需要读写指定的任意目录的文件只能通过SAF的方式实现。
6、谷歌提供的兼容性方案:
(1)权限兼容方案:应用的TargetSdkVersion < Q,并且申请了老的存储权限:READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGE权限,会自动转成新增加的三个权限,动态授权弹框提示的也是新的权限弹框。
(2)Fota升级兼容性方案:该特性只对新安装的应用生效,对于安卓Q之前的手机已经安装的应用,并且授予了老的存储权限,fota升级到Q版本之后,该应用不会受到该特性的影响.
7、特性开启:
(1)Qbeta1版本该特性没有默认开启,需要开发者通过命令开启:adb shell sm set-isolated-storage on。
(2)Qbeta2版本该特性已经默认开启。
2.1.2 兼容性影响
该特性影响很大,只要是涉及到需要读取其他应用的文件场景都可能存在问题,现在无法通过路径读写其他应用生成的所有文件,应用只能通过MediaProvider的接口和SAF的方式去读写其他应用的文件,需要应用重点排查,下面列举了一些可能出现问题的场景,请开发者参考。
2.1.2.1 场景1:文件共享场景
问题1:应用通过其他通信社交软件分享文件给好友,提示文件不存在;
问题2:应用使用其他应用打开文件,提示文件不存在。
1、问题原因分析:
原因1:分享方使用了file:// URI分享文件,该文件是保存在应用的沙箱目录,其他应用是没有权限直接通过文件路径访问其他应用的沙箱目录下的文件的,所以报错。
原因2:我们测试发现有一些分享场景,接收方不支持Content URI,也会导致分享有问题,需要文件接收方适配支持Content URI。
2、修改建议:
使用FileProvider适配,参考2.1.3的第1章节适配指导适配解决。
2.1.2.2 场景2:读取应用沙箱外非多媒体文件场景
问题1:文件管理器只能看到应用自己生成的文件,无法查看其他应用的文件;
问题2:社交类应用给好友分享本地文件出现本地文件找不到的问题。
1、原因分析:
应用通过路径只能访问应用沙箱目录的文件,其他沙箱外的任意文件是无法直接通过文件路径的方式访问。
2、修改建议:
使用SAF适配解决,参考2.1.3中第4章节适配指导适配解决。
2.1.2.3 场景3:读取应用沙箱外多媒体文件
问题1:读取本地多媒体文件为空问题;
问题2:修改用户头像失败。
1、原因分析:
这两个问题分析原因都是应用通过文件路径去读取其他应用生成的文件导致的,沙箱化之后应用无法直接通过文件路径读取其他应用生成的文件。
问题1:MediaProvider的“_data”返回值变更导致的问题,Q版本“_data”值不再是多媒体文件的真实路径,应用通过该返回值判断文件是否存在是有问题的;
问题2:SAF使用的方式有问题,我们发现有些应用通过SAF的方式更换用户头像失败的问题,失败的原因是应用通过SAF传回的Content Uri重新做了转换,转换成文件路径,通过转换后的文件路径去读取文件。
2、修改建议:
参考2.1.3的第3章节适配指导适配解决。
2.1.2.4 场景4:需要修改和删除其他应用生成的文件
问题1:三方图库应用无法删除其他应用保存的图片和视频文件;
问题2:手机管家垃圾文件无法删除和清理;
问题3:文件管理无法删除其他应用的文件。
1、原因分析:
老的存储权限READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGE废弃,替换成新的存储权限android.permission.READ_MEDIA_AUDIO、android.permission.READ_MEDIA_IMAGES和android.permission.READ_MEDIA_VIDEO,并且只提供了多媒体文件的读权限,未提供写权限,现在应用没有权限直接删除别的应用生成的任何文件,包括多媒体文件。
2、修改建议:
如果是需要修改多媒体文件,请参考2.1.3中的第3章节适配指导适配解决;如果是需要修改任意指定路径的文件,请参考2.1.3中的第4章节适配指导适配解决。
2.1.2.5 场景5:保存文件
问题1:应用保存文件到公共集合目录,路径变化问题,比如/sdcard/DCIM/;
问题2:应用保存文件到沙箱外任意指定目录,路径变化问题,比如sdcard根目录。
1、原因分析:
应用通过路径生成的文件默认都保存在应用自己的沙箱目录。
2、修改建议:
如果是需要在多媒体和下载公共的集合目录保存文件,建议参考2.1.3中的第3章节;如果是需要在任意指定的目录保存文件,则需要按照SAF的方式适配实现,可参考2.1.3中的第4章节。
2.1.2.6 场景6:应用卸载
问题1:应用卸载,用户主动保存的文件(比如保存的图片、文档和音乐等文件)被删除问题。
1、原因分析:
目前测试发现很多应用都会直接通过路径的方式将不希望卸载删除的文件保存在SD卡根目录下面应用自己创建的目录;但是在Q版本同样的方式创建的这些文件都会被保存在应用的沙箱目录,这部分文件在Q之前的版本应用卸载的时候是不会删除,但是在Q版本都会被删除,这样就会导致用户主动保存的一些文件全部被删除的问题。
2、修改建议:
请参考2.1.3的第5章节适配指导适配解决。
2.1.2.7 场景7:Fota升级
问题1:Fota升级到Q版本之后,应用卸载重新安装导致应用数据丢失的问题。
1、原因分析:
存储空间限制特性只对新安装的应用生效,对于安卓Q之前的手机已经安装的应用,并且授予了老的存储权限,fota升级到Q版本之后,该应用不会受到该特性的影响。所以如果是fota升级到Q版本的应用,不受影响,应用卸载之后,通过路径的方式保存在SD卡根目录的应用的这些文件不会被删除,和Q之前保持一致。但是如果应用重新安装这个应用,属于新安装的应用场景,又会受到该特性的影响,之前卸载未删除的这些文件,应用无法直接通过路径访问,需要应用适配解决。
2、修改建议:
应用修改保存文件的方式,直接通过路径把文件保存到SD卡根目录的方式切换成通过MediaProvider接口保存到公共的集合目录的方式,并且需要考虑用户的历史数据迁移(迁移只能在Q之前的版本做,也就是Fota之前做),通过MediaProvider的接口将应用的这些文件迁移到公共集合目录。假设用户Fota之前安装的是应用没有适配沙箱特性的应用老版本,应用数据来不及迁移,卸载重新安装之后,应用如果还需要读取这些文件就只能通过MediaProvider或者SAF的方式读取了,直接通过路径是无法读取的。
2.1.2.8 场景8:权限申请
TargetSdkVersion>=Q的应用,需要适配增加新的存储权限申请,否则会报权限问题。适配请参考2.1.3的第2章节。
2.1.3 适配指导
参考谷歌提供的适配指导:https://developer.android.google.cn/preview/privacy/scoped-storage;
特性开启:
(1)Q beta1版本该特性没有默认开启,需要开发者通过命令开启:adb shell sm set-isolated-storage on;
(2)Q beta2版本该特性已经默认开启。
2.1.3.1 文件共享适配指导
1、使用FileProvider的Content Uri替换File Uri。
2、参考谷歌提供的适配指导链接:https://developer.android.com/training/secure-file-sharing。
3、通过FileProvider分享大致的流程总结:
(1)指定应用的FileProvider
- < provider
- android: name = "android.support.v4.content.FileProvider"
- android: authorities = "com.huawei.qappcompatissues.fileprovider"
- android: exported = "false"
- android: grantUriPermissions = "true" >
- <
- meta - data
- android: name = "android.support.FILE_PROVIDER_PATHS"
- android: resource = "@xml/file_paths" / >
- <
- /provider>
(2)指定应用分享的文件路径,在res/xml/目录增加文件file_paths.xml文件:
- < ? xml version = "1.0"
- encoding = "utf-8" ? >
- <
- paths xmlns : android = "http://schemas.android.com/apk/res/android" >
- <
- external - path name = "external"
- path = "" / >
- <
- /paths>
(3)获得分享文件的Content Uri:
- fileUri = FileProvider.getUriForFile(
- this,
- "com.huawei.qappcompatissues.fileprovider",
- picFile);
(4)临时授予文件接收方的文件读写权限:
- // Grant temporary read permission to the content URI
- intent.addFlags(
- Intent.FLAG_GRANT_READ_URI_PERMISSION);
(5)分享文件完整代码:
- private void sharePicFile(File picFile) {
- try {
- // Use the FileProvider to get a content URI
- fileUri = FileProvider.getUriForFile(
- this,
- "com.huawei.qappcompatissues.fileprovider",
- picFile);
- Log.e(TAG, "fileUri:" + fileUri);
- if (fileUri != null) {
- Intent intent = new Intent(Intent.ACTION_SEND);
- // Grant temporary read permission to the content URI
- intent.addFlags(
- Intent.FLAG_GRANT_READ_URI_PERMISSION);
- // Put the Uri and MIME type in the result Intent
- intent.setDataAndType(
- fileUri,
- getContentResolver().getType(fileUri));
- startActivity(Intent.createChooser(intent, "test file share"));
- } else {
- Toast.makeText(this, "share file error", Toast.LENGTH_SHORT).show();
- }
- } catch (IllegalArgumentException e) {
- Log.e("File Selector",
- "The selected file can't be shared: " + picFile.toString());
- }
(6)接收方读取文件,比如接收图片文件:
AndroidManifest.xml文件中添加intent过滤器:
- < intent - filter >
- <
- action android: name = "android.intent.action.SEND" / >
- <
- category android: name = "android.intent.category.DEFAULT" / >
- <
- data android: mimeType = "image/*" / >
- <
- /intent-filter>
通过Intent读取图片,content uri:content://com.huawei.qappcompatissues.fileprovider/external/test.jpg
- ImageView imageView = findViewById(R.id.imageView);
- Intent intent = getIntent();
- String action = intent.getAction();
- String type = intent.getType();
- if (Intent.ACTION_SEND.equals(action) && type != null) {
- // Get the file's content URI from the incoming Intent
- Uri returnUri = intent.getData();
- if (type.startsWith("image/")) {
- Log.e(TAG, "open image file:" + returnUri);
- try {
- Bitmap bmp = getBitmapFromUri(returnUri);
- imageView.setImageBitmap(bmp);
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- }
通过Content Uri读取图片:
- public static Bitmap getBitmapFromUri(Context context, Uri uri) throws IOException {
- ParcelFileDescriptor parcelFileDescriptor =
- context.getContentResolver().openFileDescriptor(uri, "r");
- FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
- Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
- parcelFileDescriptor.close();
- return image;
- }
2.1.3.2 权限适配指导
1、应用读写自己生成的文件不需要申请任何权限。
2、应用如果需要读取其他应用保存的多媒体公共集合文件,就需要申请对应的权限:
(1)音乐文件:android.permission.READ_MEDIA_AUDIO
(2)照片文件:android.permission.READ_MEDIA_IMAGES
(3)视频文件:android.permission.READ_MEDIA_VIDEO
3、谷歌提供的兼容性方案:
(1)应用的targetSdkVersion < Q,只要应用动态申请了READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGE权限,系统会自动将该权限转成新增的:android.permission.READ_MEDIA_AUDIO、android.permission.READ_MEDIA_IMAGES和android.permission.READ_MEDIA_VIDEO权限。
4、适配指导:
TargetSdkVersion < Q的应用不适配也不会有问题,只有TargetSdkVersion >= Q的应用需要适配,否则会导致没有权限访问多媒体文件:
(1)需要在 AndroidManifest.xml 中新增 uses-permissions 声明 (不一定全要,请根据实际业务需要访问音频,图片还是视频选择必须的; 如果完全不需要访问媒体类的文件,只是访问普通下载文件,下列权限都是不需要申请的。)
- < uses - permission android: name = "android.permission.READ_MEDIA_AUDIO" / >
- <
- uses - permission android: name = "android.permission.READ_MEDIA_IMAGES" / >
- <
- uses - permission android: name = "android.permission.READ_MEDIA_VIDEO" / >
(2)在代码中使用权限前调用checkSelfPermission检查权限是否授权,未授权情况下调用requestPermission动态申请上述权限,让用户通过弹框确认。
(3)同时兼容Q和Q之前的安卓版本:
(a)在AndroidManifest.xml 中同时uses-permission声明新老权限;
(b)在代码中通过API level来区分,当API level低于Q时,运行P版本旧的权限的动态授权代码;大于等于Q时运行新的权限的动态授权代码。
- private void requestPermission() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- if (ContextCompat.checkSelfPermission(this,
- Manifest.permission.READ_MEDIA_IMAGES) !=
- PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(this,
- Manifest.permission.READ_MEDIA_AUDIO) !=
- PackageManager.PERMISSION_GRANTED) {
- ActivityCompat.requestPermissions(this,
- new String[] {
- Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_AUDIO
- },
- MY_PERMISSIONS_REQUEST_READ_MEDIA_IMAGES);
- }
- } else {
- // request old storage permission
- }
- }
2.1.3.3 本地多媒体文件读写适配指导
1、多媒体文件读取
(1)多媒体文件和下载文件读取接口:
文件类型 | 接口 |
图片 | MediaStore.Images |
视频 | MediaStore.Video |
音乐 | MediaStore.Audio |
下载 | MediaStore.Downloads |
(2)通过ContentProvider查询文件,获得需要读取的文件Uri:
- public static List < Uri > loadPhotoFiles(Context context) {
- Log.e(TAG, "loadPhotoFiles");
- List < Uri > photoUris = new ArrayList < Uri > ();
- Cursor cursor = context.getContentResolver().query(
- MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new String[] {
- MediaStore.Images.Media._ID
- }, null, null, null);
- Log.e(TAG, "cursor size:" + cursor.getCount());
- while (cursor.moveToNext()) {
- int id = cursor.getInt(cursor
- .getColumnIndex(MediaStore.Images.Media._ID));
- Uri photoUri = Uri.parse(MediaStore.Images.Media.EXTERNAL_CONTENT_URI.toString() + File.separator + id);
- Log.e(TAG, "photoUri:" + photoUri);
- photoUris.add(photoUri);
- }
- return photoUris;
- }
(3)通过Uri读取文件:
- public static Bitmap getBitmapFromUri(Context context, Uri uri) throws IOException {
- ParcelFileDescriptor parcelFileDescriptor =
- context.getContentResolver().openFileDescriptor(uri, "r");
- FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
- Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
- parcelFileDescriptor.close();
- return image;
- }
(4)MediaProvider变更适配指导:MediaProvider中的“_data”字段已经废弃掉了,开发者不能再认为该字段保存的是文件的真实路径,Q版本因为存储空间限制的变更,应用已经无法直接通过文件路径读取文件,需要使用文件的Content URI读取文件,目前发现有很多应用通过“_data”值作为文件的真实路径在加载显示图片之前判断文件是否存在,这样的做法在Q版本是有问题的,应用需要整改。
2、多媒体文件保存:应用只能在沙箱内通过文件路径的方式保存文件,如果需要保存文件到沙箱目录外,需要使用特定的接口实现,具体可参考:
(1)方式1:通过MediaStore.Images.Media.insertImage接口可以将图片文件保存到/sdcard/Pictures/,但是只有图片文件保存可以通过MediaStore的接口保存,其他类型文件无法通过该接口保存;
- public static void saveBitmapToFile(Context context, Bitmap bitmap, String title, String discription) {
- MediaStore.Images.Media.insertImage(context.getContentResolver(), bitmap, title, discription);
- }
(2)方式2:通过ContentResolver的insert方法将多媒体文件保存到多媒体的公共集合目录。
- /**
- * 保存多媒体文件到公共集合目录
- * @param uri:多媒体数据库的Uri
- * @param context
- * @param mimeType:需要保存文件的mimeType
- * @param displayName:显示的文件名字
- * @param description:文件描述信息
- * @param saveFileName:需要保存的文件名字
- * @param saveSecondaryDir:保存的二级目录
- * @param savePrimaryDir:保存的一级目录
- * @return 返回插入数据对应的uri
- */
- public static String insertMediaFile(Uri uri, Context context, String mimeType,
- String displayName, String description, String saveFileName, String saveSecondaryDir, String savePrimaryDir) {
- ContentValues values = new ContentValues();
- values.put(MediaStore.Images.Media.DISPLAY_NAME, displayName);
- values.put(MediaStore.Images.Media.DESCRIPTION, description);
- values.put(MediaStore.Images.Media.MIME_TYPE, mimeType);
- values.put(MediaStore.Images.Media.PRIMARY_DIRECTORY, savePrimaryDir);
- values.put(MediaStore.Images.Media.SECONDARY_DIRECTORY, saveSecondaryDir);
- Uri url = null;
- String stringUrl = null; /* value to be returned */
- ContentResolver cr = context.getContentResolver();
- try {
- url = cr.insert(uri, values);
- if (url == null) {
- return null;
- }
- byte[] buffer = new byte[BUFFER_SIZE];
- ParcelFileDescriptor parcelFileDescriptor = cr.openFileDescriptor(url, "w");
- FileOutputStream fileOutputStream =
- new FileOutputStream(parcelFileDescriptor.getFileDescriptor());
- InputStream inputStream = context.getResources().getAssets().open(saveFileName);
- while (true) {
- int numRead = inputStream.read(buffer);
- if (numRead == -1) {
- break;
- }
- fileOutputStream.write(buffer, 0, numRead);
- }
- fileOutputStream.flush();
- } catch (Exception e) {
- Log.e(TAG, "Failed to insert media file", e);
- if (url != null) {
- cr.delete(url, null, null);
- url = null;
- }
- }
- if (url != null) {
- stringUrl = url.toString();
- }
- return stringUrl;
- }
比如你需要把一个图片文件保存到/sdcard/dcim/test/下面,可以这样调用:
- SandboxTestUtils.insertMediaFile(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, this, "image/jpeg",
- "insert_test_img", "test img save use insert", "if_apple_2003193.png", "test", Environment.DIRECTORY_DCIM);
音频、视频文件和下载目录的文件也是可以通过这个方式进行保存,比如音频文件保存到/sdcard/Music/test/:
- SandboxTestUtils.insertMediaFile(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, this, "audio/mpeg",
- "insert_test_music", "test audio save use insert", "Never Forget You.mp3", "test", Environment.DIRECTORY_MUSIC);
可以通过PRIMARY_DIRECTORY和SECONDARY_DIRECTORY字段来设置一级目录和二级目录:
(a)一级目录必须是和MIME type的匹配的根目录下的Public目录,一级目录可以不设置,不设置时会放到默认的路径;
(b)二级目录可以不设置,不设置时直接保存在一级目录下;
(c)应用生成的文档类文件,代码里面默认不设置时,一级是Downloads目录,也可以设置为Documents目录,建议推荐三方应用把文档类的文件一级目录设置为Documents目录;
(d)一级目录MIME type,默认目录、允许的目录映射以及对应的读取权限如下表所示:
3、多媒体文件的编辑和修改:应用只有自己插入的多媒体文件的写权限,没有别的应用插入的多媒体文件的写权限,比如使用下面的代码删除别的应用的多媒体文件会因为权限问题导致删除失败:
- context.getContentResolver().delete(uri, null, null))
对于需要修改和删除别的应用保存的多媒体文件的适配建议:
(1)方式1:如果应用需要修改其他应用插入的多媒体文件,需要作为系统默认应用,比如作为系统默认图库,可以删除和修改其他应用的图片和视频文件;作为系统的默认音乐播放软件,可以删除和修改其他应用的音乐文件。
参考谷歌提供的适配指导:https://developer.android.google.cn/preview/features/roles#check-default-app
在AndroidManifest文件增加对应的权限和默认应用intent过滤器的申明:
- <activity
- android:name=".MainActivity">
- <intent-filter>
- <action android:name="android.intent.action.MAIN"/>
- <category android:name="android.intent.category.LAUNCHER"/>
- <category android:name="android.intent.category.APP_GALLERY" />
- <category android:name="android.intent.category.DEFAULT" />
- intent-filter>
- activity>
在启动应用页面里面增加是不是默认应用的判断:
- //设置默认应用
- RoleManager roleManager = getSystemService(RoleManager.class);
- if (roleManager.isRoleAvailable(RoleManager.ROLE_GALLERY)) {
- if (roleManager.isRoleHeld(RoleManager.ROLE_GALLERY)) {
- // This app is the default gallery app.
- Log.e(TAG, "This app is the default gallery app");
- } else {
- // This app isn't the default gallery app, but the role is available,
- // so request it.
- Log.e(TAG, "This app isn't the default gallery app");
- Intent roleRequestIntent = roleManager.createRequestRoleIntent(
- RoleManager.ROLE_GALLERY);
- startActivityForResult(roleRequestIntent, ROLE_REQUEST_CODE);
- }
- }
用户设置您的App为默认音乐应用之后,就有权限写其他应用保存的音乐文件了。另外其他的类型多媒体文件也可以按照同样的方式处理:
多媒体类型 | Intent过滤器 | Role类型 |
图片和视频 | android.intent.category.APP_GALLERY | RoleManager.ROLE_GALLERY |
音乐 | android.intent.category.APP_MUSIC | RoleManager.ROLE_MUSIC |
(2)方式2:使用ContentResolver对象查找文件并进行修改或者删除。执行修改或删除操作时,捕获RecoverableSecurityException,以便您可以请求用户授予您对多媒体文件的写入权限。(备注:目前这一块代码还未完全ready,目前的版本无法通过这个方式完成多媒体文件删除。)
2.1.3.4 需要在任意的指定目录读写文件适配指导
1、使用SAF方式适配;
2、参考谷歌提供的适配指导:https://developer.android.com/guide/topics/providers/document-provider;
3、参考实现代码:
(1)读取和修改文件
通过Intent传入ACTION_OPEN_DOCUMENT拉起DocumentUI:
- public void performFileSearch() {
- // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's file
- // browser.
- Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
- // Filter to only show results that can be "opened", such as a
- // file (as opposed to a list of contacts or timezones)
- intent.addCategory(Intent.CATEGORY_OPENABLE);
- // Filter to show only images, using the image MIME data type.
- // If one wanted to search for ogg vorbis files, the type would be "audio/ogg".
- // To search for all documents available via installed storage providers,
- // it would be "*/*".
- intent.setType("image/*");
- startActivityForResult(intent, READ_REQUEST_CODE);
- }
在拉起的DocumentUI中用户可以选择需要打开的图片文件:
DocumentUI会把用户选择的图片文件的Content Uri通过intent传回给应用,应用在onActivityResult回调中就可以拿到这个Uri,通过Uri读取或者修改文件,比如打开文件:
- if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
- // The document selected by the user won't be returned in the intent.
- // Instead, a URI to that document will be contained in the return intent
- // provided to this method as a parameter.
- // Pull that URI using resultData.getData().
- Uri uri = null;
- if (resultData != null) {
- uri = resultData.getData();
- Log.i(TAG, "Uri: " + uri.toString());
- showImage(uri);
- }
- }
- private void showImage(Uri uri) {
- try {
- ((ImageView) findViewById(R.id.imageView)).setImageBitmap(getBitmapFromUri(uri));
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- private Bitmap getBitmapFromUri(Uri uri) throws IOException {
- ParcelFileDescriptor parcelFileDescriptor =
- getContentResolver().openFileDescriptor(uri, "r");
- FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
- Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
- parcelFileDescriptor.close();
- return image;
- }
修改文件:
- try {
- ParcelFileDescriptor pfd = getContentResolver().
- openFileDescriptor(uri, "w");
- FileOutputStream fileOutputStream =
- new FileOutputStream(pfd.getFileDescriptor());
- fileOutputStream.write((
- System.currentTimeMillis() + " edit file by saf ").getBytes());
- // Let the document provider know you're done by closing the stream.
- fileOutputStream.close();
- pfd.close();
- } catch (FileNotFoundException e) {
- e.printStackTrace();
- } catch (IOException e) {
- e.printStackTrace();
- }
删除文件:
- Uri uri = null;
- if (resultData != null) {
- uri = resultData.getData();
- Log.i(TAG, "delete Uri: " + uri.toString());
- // showImage(uri);
- final int takeFlags = getIntent().getFlags() &
- (Intent.FLAG_GRANT_READ_URI_PERMISSION |
- Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
- // Check for the freshest data.
- getContentResolver().takePersistableUriPermission(uri, takeFlags);
- try {
- DocumentsContract.deleteDocument(getContentResolver(), uri);
- } catch (FileNotFoundException e) {
- e.printStackTrace();
- }
- }
(2)新建文件:
- private void createFile(String mimeType, String fileName) {
- Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
- // Filter to only show results that can be "opened", such as
- // a file (as opposed to a list of contacts or timezones).
- intent.addCategory(Intent.CATEGORY_OPENABLE);
- // Create a file with the requested MIME type.
- intent.setType(mimeType);
- intent.putExtra(Intent.EXTRA_TITLE, fileName);
- startActivityForResult(intent, WRITE_REQUEST_CODE);
- }
用户通过拉起的DocumentUI选择文件保存的目录,点击保存:
用户点击保存之后,DocumentUI就会把需要保存的文件的Content Uri通过intent传回给应用:
- if (requestCode == WRITE_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
- // The document selected by the user won't be returned in the intent.
- // Instead, a URI to that document will be contained in the return intent
- // provided to this method as a parameter.
- // Pull that URI using resultData.getData().
- Uri uri = null;
- if (resultData != null) {
- uri = resultData.getData();
- Log.i(TAG, "Uri: " + uri.toString());
- // showImage(uri);
- final int takeFlags = getIntent().getFlags() &
- (Intent.FLAG_GRANT_READ_URI_PERMISSION |
- Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
- // Check for the freshest data.
- getContentResolver().takePersistableUriPermission(uri, takeFlags);
- writeFile(uri);
- }
- }
写文件:
- private void writeFile(Uri uri) {
- try {
- ParcelFileDescriptor pfd = getContentResolver().
- openFileDescriptor(uri, "w");
- FileOutputStream fileOutputStream =
- new FileOutputStream(pfd.getFileDescriptor());
- fileOutputStream.write(("Overwritten by MyCloud at " +
- System.currentTimeMillis() + " ").getBytes());
- // Let the document provider know you're done by closing the stream.
- fileOutputStream.close();
- pfd.close();
- } catch (FileNotFoundException e) {
- e.printStackTrace();
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
(3)文件管理器和手机管家垃圾文件清理适配指导 :
谷歌提供的适配指导:https://developer.android.com/preview/privacy/scoped-storage#browse-external-storage
开发者如果需要像文件管理器应用程序那样获得对外部存储中目录的广泛访问权限,请使用ACTION_OPEN_DOCUMENT_TREE的intent,拉起DocumentUI让用户主动授权的方式获取,获得用户主动授权之后,应用就可以临时获得该目录下面的所有文件和目录的读写权限。参考实现代码:
(a)通过intent拉起DocumentUI:
- Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
- startActivityForResult(intent, REQUEST_CODE_OPEN_DIRECTORY);
(b)引导用户选择需要授予读写权限的文件目录:
(c)用户授权之后,应用获得目录的读写权限,文件管理器或者是手机管家可以引导用户授予外部存储的根目录的读写权限来实现自己的核心功能。
(4)保存权限:
谷歌提供的适配指导:https://developer.android.com/guide/topics/providers/document-provider#permissions
当您的应用打开文件进行读取或写入时,系统会为您的应用提供针对该文件的 URI 授权,该授权将一直持续到用户设备重启时。应用可以通过保存权限来永久的获取该权限,不需要每次重启手机之后又要重新让用户主动授权,参考代码:
本地保存用户授权的目录对应的ContentUri:
(a)本地保存用户授权的Uri,并保存权限:
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- if (requestCode == REQUEST_CODE_OPEN_DIRECTORY && resultCode == Activity.RESULT_OK) {
- Log.d(TAG, String.format("Open Directory result Uri : %s", data.getData()));
- Uri uriTree = data.getData();
- SharedPreferences sharedPreferences = getSharedPreferences("data", Context.MODE_PRIVATE);
- SharedPreferences.Editor editor = sharedPreferences.edit();
- editor.putString("uri", uriTree.toString());
- editor.commit();
- final int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
- getContentResolver().takePersistableUriPermission(uriTree, takeFlags);
- updateDatas(uriTree);
- }
- }
(b)读写目录文件的时候,通过getContentResolver().takePersistableUriPermission(uri, takeFlags)是否抛异常检查应用是否有这个Uri的访问权限,如果有权限直接访问即可,如果抛异常,表示没有权限,需要重新让用户主动授权再访问:
- SharedPreferences sharedPreferences = getSharedPreferences("data", Context.MODE_PRIVATE);
- String uriStr = sharedPreferences.getString("uri", "");
- if (!TextUtils.isEmpty(uriStr)) {
- try {
- Uri uri = Uri.parse(uriStr);
- final int takeFlags = getIntent().getFlags() &
- (Intent.FLAG_GRANT_READ_URI_PERMISSION |
- Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
- // Check for the freshest data.
- getContentResolver().takePersistableUriPermission(uri, takeFlags);
- updateDatas(uri);
- } catch (SecurityException e) {
- e.printStackTrace();
- Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
- startActivityForResult(intent, REQUEST_CODE_OPEN_DIRECTORY);
- }
- } else {
- Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
- startActivityForResult(intent, REQUEST_CODE_OPEN_DIRECTORY);
- }
用户是可以在系统设置里面取消应用的访问权限的,所以应用需要增加这个权限检测来决定是否需要用户主动授权。
2.1.3.5 应用卸载之后应用文件删除问题适配指导
应用通过路径生成的文件都是存放在应用的沙箱目录下面,应用卸载的时候,应用不做适配,应用的整个沙箱目录都会被直接删除,如果应用有一些用户主动保存的文件不希望在应用被卸载的时候删除需要如何做呢?有两个方法:
1、推荐方法:把不希望删除的文件通过MediaProvider或者SAF的接口保存在公共集合目录下面,具体可以参考前面的适配章节内容,保存在公共集合目录的文件在应用卸载的时候默认会弹框提示用户是否删除这些文件,对应下面弹框的第一个勾选,默认保留,勾选删除,谷歌后续的版本计划把这个勾选去掉,意味着应用保存到公共集合目录的文件卸载的时候不会提示用户删除。
2、方法2:在应用的AndroidManifest.xml文件增加:,应用不增加该属性的话,应用能卸载的时候应用保存在沙箱目录的文件会直接被删除,不会弹框提示。只有应用增加了这个属性,并且设置的值是为true,在应用被卸载的时候,才会弹框提示,对应的是上面图中的第2个勾选,默认删除,勾选保留。
2.2 禁止应用读取Device ID
2.2.1 背景介绍
Q版本将限制应用访问不可重设的设备识别码,如 IMEI、序列号等,所有获取设备识别码的接口都增加了新的权限:READ_PRIVILEGED_PHONE_STATE,该权限需要系统签名的应用才能申请,意味着三方应用无法获取设备识别码。
2.2.2 兼容性影响
2.2.2.1 影响范围
90%+的应用都通过申请老的READ_PHONE_STATE权限获取device id,这些应用都需要适配该变更。
2.2.2.2 兼容性问题
(1)TargetSdkVersion < Q并且没有申请READ_PHONE_STATE权限,或者TargetSdkVersion>=Q,获取device id会抛异常SecurityException;
(2)TargetSdkVersion < Q并且申请了READ_PHONE_STATE,通过getDeviceId接口读取的值为Null;
(3)无法获取到device id,会对应用依赖device id的功能产生影响。
2.2.2.3 问题和场景
数据统计、推荐、用户历史数据记录、广告、用户画像等等。
2.2.3 适配指导
1、谷歌提供的适配指导文档。
唯一标识符最佳做法:https://developer.android.google.cn/training/articles/user-data-ids
官方文档:https://developer.android.google.cn/preview/privacy/data-identifiers
2、开发者可以尝试使用Android ID替换Device ID。
(1)获取代码:Settings.System.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
(2)Android ID和Device ID的区别:
(a)手机恢复出厂设置,Android ID可以重置,但是Device ID无法重置;
(b)对于安装在运行 Android 8.0 的设备上的应用,ANDROID_ID 的值现在将根据应用签署密钥和用户确定作用域。应用签署密钥、用户和设备的每个组合都具有唯一的 ANDROID_ID 值。因此,在相同设备上运行但具有不同签署密钥的应用将不会再看到相同的 Android ID(即使对于同一用户来说,也是如此),具体可参考O版本的变更:https://developer.android.com/about/versions/oreo/android-8.0-changes#privacy-all。
3、接入华为提供的ID
目前华为的OAID和ODID接口还在开发中,开发完成之后再补充适配信息:
(1)OAID(Open Anonymous ID ):开放匿名ID,只能用于广告场景(禁止使用在其他场景),用户可以在手机上进行关闭和重置Open Anonymous ID,重置之后OAID值会变化。OAID采用uuid作为OAID的值,第三方开发者也可以获取。
(2)ODID(Open Device ID):开放的设备标识 ,用于开放给开发者的设备标识,同一个开发者不同应用获取设备标识是相同的。
(a)Open Device ID取值规则:
-
同一设备上运行的同一个供应商的应用程序,取值相同;
-
同一设备上不同供应商的应用程序,取值不同;
-
不同设备上同一个供应商的应用程序,取值不同;
-
不同设备上不同供应商的应用程序,取值不同。
(b)重置规则:
手机恢复出厂设置,所有供应商的取值全部重置;
同一设备上同一供应商的应用全部卸载后重新安装时,对该供应商的取值重置。
4、老的READ_PHONE_STATE权限整改
由于获取device id权限的变更,通过申请老的READ_PHONE_STATE已经无法获取device id信息,所以如果应用申请敏感权限READ_PHONE_STATE的目的仅仅是为了获取device id,建议代码中增加版本判断,判断如果仅当运行的手机版本是Q以前的版本,才申请用户动态授权,否则申请的权限没有用,反而对用户产生打扰,具体代码参考:
- if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
- if (ContextCompat.checkSelfPermission(this,
- Manifest.permission.READ_PHONE_STATE) !=
- PackageManager.PERMISSION_GRANTED) {
- Log.e(TAG, "not have READ_PHONE_STATE");
- ActivityCompat.requestPermissions(this,
- new String[] {
- Manifest.permission.READ_PHONE_STATE
- },
- MY_PERMISSIONS_REQUEST_READ_PHONE_STATE);
- } else {
- Log.e(TAG, "have READ_PHONE_STATE");
- }
- }
2.3 Mac地址随机化
2.3.1 背景介绍
Android Q 默认启用 MAC 地址随机化功能,当设备连接到不同的 Wi-Fi 网络时,系统会随机生成不同的 MAC 地址;在 Android 9 P版本中,该特性为附加功能,开发者可自行选择是否启用。
2.3.2 兼容性影响
该特性影响很大,大多数应用都有读取Mac地址的行为,应用获取的Mac地址不再是真实不变的Mac地址,所以如果应用将获取到的Mac地址作为设备唯一标示使用就会有问题,需要应用排查和整改。
2.3.3 适配指导
参考:2.2.3章节
2.4 禁止应用后台启动Activity
2.4.1 背景介绍
Q版本限制了应用后台启动Activity,该变更的目的是最大限度减少后台弹页面对用户的打扰,用户能更好的控制屏幕上显示的内容,在Android Q上运行的应用只有在满足以下一个或多个条件时才能启动Activity:
-
应用处于前台;
-
桌面widget点击启动Activity;
-
由桌面点击启动应用;
-
多任务切换场景(即recent中启动应用);
-
前台界面A所在的应用启动后台界面B;
-
临时白名单机制,不拦截通过通知拉起的应用。
(a)应用通过通知,在pendingintent中启动activity;
(b)应用通过通知,在pendingintent中发送广播,接收广播后启动activity,加入临时白名单不拦截;
(c)应用通过通知,在pendingintent中启动service,在service中启动activity,加入临时白名单不拦截。
备注:应用无法通过前台服务让应用处于前台豁免该管控。
2.4.2 兼容性影响
该特性对所有的三方应用都生效,需要所有的应用进行排查和整改,如果发现问题无法整改可以将问题反馈给谷歌:https://developer.android.google.cn/preview/feedback。
测试发现一些合理的应用场景都有影响,请开发者参考:
-
锁屏
-
接电话(语音和视频电话)
-
闹钟提醒
-
其他
2.4.3 适配指导
谷歌在Q的第一个beta版本并未真正打开该管控限制,但是如果应用的页面存在被管控的场景,系统会通过一个Toast告警提示,提示开发者需要整改,否则应用的某些页面在谷歌的后续版本会被拦截,具体的告警文字内容:
- This background activity start from package-name will be blocked in future Q builds.
如果开发者想要开启该特性,需要到开发者选项中将“允许系统执行后台活动”设置项关闭:
2.4.3.1 适配建议
几乎在所有情况下,建议开发者通过系统通知的方式提醒用户,而不是直接启动Activity的方式。如果应用的特定场景下,需要马上引起用户的注意,例如电话、闹钟提醒,可以通过下面的步骤适配解决。
1、创建一个高优先级的全屏通知,参考代码:
先要创建一个NotificationChannel:CHANNEL_ID。
然后通过高优先级全屏通知适配:
- private void showHighNotification() {
- Intent fullScreenIntent = new Intent(this, DownloadProviderTestActivity.class);
- PendingIntent fullScreenPendingIntent = PendingIntent.getActivity(this, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT);
- Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
- .setSmallIcon(R.drawable.if_apple_2003193)
- .setContentTitle("Incoming call")
- .setContentText("(919) 555-1234")
- .setPriority(NotificationCompat.PRIORITY_HIGH)
- .setCategory(NotificationCompat.CATEGORY_CALL)
- .setFullScreenIntent(fullScreenPendingIntent, true).build();
- NotificationManager mNotificationManager =
- (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
- mNotificationManager.notify(1, notification);
- }
2、如果您的通知是正在进行的通知(例如来电),请将通知与前台服务相关联。以下代码段显示了如何显示与前台服务关联的通知:
- // Provide a unique integer for the "notificationId" of each notification.
- startForeground(notificationId, notification)
3、备注:如果用户正在使用手机,系统会把全屏通知改成横幅通知提醒用户;在灭屏状态下则可以通过全屏通知直接拉起Activity提醒用户,不过开发者需要设置拉起的Activity显示在锁屏之上,并且启动Activity的时候可以点亮屏幕:
- getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED |
- WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
比如音乐锁屏、电话和闹钟场景都可以这样在灭屏状态下提醒用户。
2.5 后台地理位置权限
2.5.1 背景介绍
特性介绍:
1、Q之前只有ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION;
2、Q新增加了后台定位权限:ACCESS_BACKGROUND_LOCATION,该权限对应始终允许;老的权限:ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION代表仅前台使用允许;
3、应用的targetSdkVersion < Q,谷歌提供了兼容性方案,只要应用申请了老的位置权限ACCESS_FINE_LOCATION或者ACCESS_COARSE_LOCATION,会默认请求ACCESS_BACKGROUND_LOCATION权限,动态授权弹框参考下面第一个图。
4、应用的TargetSdkVersion >= Q,如果应用必须要始终定位,可以只申请ACCESS_BACKGROUND_LOCATION即可,权限弹框参考下面第三个图;如果应用只需要申请前台定位,则只需要申请老的定位权限即可,具体授权弹框参考第二个图;如果都申请则出现三态权限弹框,参考下面第一个图。
5、如果用户选择仅前台使用允许,应用的页面退后台,通过启动前台服务让应用处于前台状态,必须把前台服务标为:foregroundServiceType=“location”,才能获取位置信息。
2.5.2 兼容性影响
地图类应用的后台导航功能需要测试和适配。
2.5.3 适配指导
谷歌提供的适配指导文档:https://developer.android.google.cn/preview/privacy/device-location
2.5.3.1 地图类应用后台导航场景适配指导
如果应用需要后台导航:因为O版本的“后台位置限制”管控,建议地图类的应用还是通过前台服务让应用处于前台状态,不受“后台位置限制”管控,并且前台服务增加foregroundServiceType=“location”,不受Q版本前台服务位置限制,请按照下面的步骤适配。
1、权限适配,可以不用申请新的ACCESS_BACKGROUND_LOCATION权限,只要申请老的位置权限即可:
- < uses - permission android: name = "android.permission.ACCESS_FINE_LOCATION" / >
权限弹框存在两种可能:
(a)应用的TargetSdkVersion < Q:用户有两种选择,仅前台或者始终。
(b)应用的TargetSdkVersion >= Q:用户只能设置仅前台使用。
2、用户选择始终允许,和Q之前一样没有差异,不会有问题;但是如果用户选择仅在前台使用还需要应用适配前台服务的变更:
(a)设置前台服务的foregroundServiceType为“location”:
- < service
- android: name = "MyNavigationService"
- android: foregroundServiceType = "location"... >
- ...
- <
- /service>
(b)在启动前台服务记得检查是否有前台定位的权限:
- boolean permissionAccessCoarseLocationApproved =
- ActivityCompat.checkSelfPermission(this,
- permission.ACCESS_COARSE_LOCATION) ==
- PackageManager.PERMISSION_GRANTED;
- if (permissionAccessCoarseLocationApproved) {
- // App has permission to access location in the foreground. Start your
- // foreground service that has a foreground service type of "location".
- } else {
- // Make a request for foreground-only location access.
- ActivityCompat.requestPermissions(this, new String[] {
- Manifest.permission.ACCESS_COARSE_LOCATION
- },
- your - permission - request - code);
- }
2.5.3.2 定期检查用户的位置场景适配指导
您的应用可能有一个用例,需要始终访问用户的位置。比如:与朋友和家人进行地理围栏和位置共享。
如果这些条件适用于您的应用,只要用户授予您的应用对其所在位置的全天候访问权限,您就可以继续请求位置更新而无需任何更改。
1、权限适配:建议应用增加新的后台定位权限ACCESS_BACKGROUND_LOCATION申请,并且检查用户是否主动授予应用始终允许获取位置:
(a)新增权限申请:
- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
(b)前台服务不需要设置foregroundServiceType为“location”。
(c)需要做好权限校验,看看用户是否设置的是始终允许,如果用户设置的是仅前台允许,那就会有问题了,需要应用弹出来始终需要定位的理由说明对话框,让用户授予始终允许定位:
参考代码:
- boolean permissionAccessCoarseLocationApproved =
- ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) ==
- PackageManager.PERMISSION_GRANTED;
- if (permissionAccessCoarseLocationApproved) {
- boolean backgroundLocationPermissionApproved =
- ActivityCompat.checkSelfPermission(this,
- Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
- PackageManager.PERMISSION_GRANTED;
- if (backgroundLocationPermissionApproved) {
- // App can access location both in the foreground and in the background.
- // Start your service that doesn't have a foreground service type
- // defined.
- } else {
- // App can only access location in the foreground. Display a dialog
- // warning the user that your app must have all-the-time access to
- // location in order to function properly. Then, request background
- // location.
- ActivityCompat.requestPermissions(this, new String[] {
- Manifest.permission.ACCESS_BACKGROUND_LOCATION
- },
- BACKGROUND_LOCATION_PERMISSION_REQUEST_CODE);
- }
- } else {
- // App doesn't have access to the user's location at all. Make full request
- // for permission.
- ActivityCompat.requestPermissions(this, new String[] {
- Manifest.permission.ACCESS_COARSE_LOCATION,
- Manifest.permission.ACCESS_BACKGROUND_LOCATION
- },
- LOCATION_PERMISSION_REQUEST_CODE);
- }
2.5.3.3 其他只需要前台获取位置信息场景适配指导
不适配也不会有问题,最佳实现:建议应用升级TargetSdkVersion到Q,只申请老的位置权限,不要申请ACCESS_BACKGROUND_LOCATION权限。这样系统弹框只有允许前台使用的选项:
2.6 其他隐私和安全相关的变更
2.6.1 安装应用接口废弃
Q版本禁止应用通过file:// URI拉起系统安装器进行应用安装。因为Q之前如果TargetSdkVersion >= 24的应用通过file:// URI的方式安装会抛FileUriExposedException异常;所以Q版本的该变更对TargetSdkVersion < 24的应用影响比较大,需要重点排查。
1、适配指导
(a)需要申请android.permission.REQUEST_INSTALL_PACKAGES权限。
(b)通过FileProvider使用content:// URI安装,代码如下:
- Uri installUri = FileProvider.getUriForFile(getApplicationContext(), "com.huawei.qappcompatissues.fileprovider", apkFile);
- Intent intent = new Intent(Intent.ACTION_INSTALL_PACKAGE).setData(installUri);
- intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
- startActivity(intent);
2.6.2 访问所有相机信息需要获得权限
Android Q更改了默认情况下getCameraCharacteristics()方法返回的信息的广度。特别是,您的应用必须具有CAMERA权限才能访问此方法的返回值中包含的潜在设备特定元数据。
如果您的应用没有CAMERA权限,则无法访问以下字段:
-
ANDROID_LENS_POSE_ROTATION
-
ANDROID_LENS_POSE_TRANSLATION
-
ANDROID_LENS_INTRINSIC_CALIBRATION
-
ANDROID_LENS_RADIAL_DISTORTION
-
ANDROID_LENS_POSE_REFERENCE
-
ANDROID_LENS_DISTORTION
-
ANDROID_LENS_INFO_HYPERFOCAL_DISTANCE
-
ANDROID_LENS_INFO_MINIMUM_FOCUS_DISTANCE
-
ANDROID_SENSOR_REFERENCE_ILLUMINANT1
-
ANDROID_SENSOR_REFERENCE_ILLUMINANT2
-
ANDROID_SENSOR_CALIBRATION_TRANSFORM1
-
ANDROID_SENSOR_CALIBRATION_TRANSFORM2
-
ANDROID_SENSOR_COLOR_TRANSFORM1
-
ANDROID_SENSOR_COLOR_TRANSFORM2
-
ANDROID_SENSOR_FORWARD_MATRIX1
-
ANDROID_SENSOR_FORWARD_MATRIX2
- CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);
- try {
- CameraCharacteristics cameraCharacteristics = manager.getCameraCharacteristics(manager.getCameraIdList()[0]);
- Log.e(TAG, "test getCameraCharacteristics:" + cameraCharacteristics.get(CameraCharacteristics.LENS_POSE_ROTATION));
- } catch (CameraAccessException e) {
- e.printStackTrace();
- }
如果没有CAMERA权限,获取到的cameraCharacteristics.get(CameraCharacteristics.LENS_POSE_ROTATION)值为null。
2.6.3 启用和禁用Wi-Fi的限制
1、在Android Q上运行的应用无法通过WifiManager.setWifiEnabled()接口启用或停用Wi-Fi,WifiManager.setWifiEnabled()方法始终返回false。
2、如果需要,请使用设置面板提示用户启用和禁用Wi-Fi。需要应用排查和整改,整改代码请参考代码:
- Intent panelIntent = new Intent(Settings.Panel.ACTION_INTERNET_CONNECTIVITY);
- startActivityForResult(panelIntent, 100);
2.6.4 电话,Wi-Fi,蓝牙API所需的精确位置权限
1、应用的targetSdkVersion>=Q:除非应用具有ACCESS_FINE_LOCATION权限,否则在Android Q上运行时,应用无法在Wi-Fi,Wi-Fi Aware或蓝牙API中使用多种方法。
2、应用的targetSdkVersion < Q:不受影响,只需要申请ACCESS_COARSE_LOCATION或者ACCESS_FINE_LOCATION即可
3、具体影响的接口有:
模块 | 类 | 接口 |
Telephony | TelephonyManager |
getCellLocation() getAllCellInfo() requestNetworkScan() requestCellInfoUpdate() getAvailableNetworks() getServiceStateForSubscriber getServiceState() |
TelephonyScanManager | requestNetworkScan() | |
PhoneStateListener |
onCellLocationChanged() onCellInfoChanged() onServiceStateChanged() |
|
NetworkScanCallback | onResults() | |
Wi-Fi | WifiScanner | startScan() |
WifiManager |
startScan() getScanResults() |
|
WifiAwareManager | / | |
WifiP2pManager | / | |
WifiRTTManager | / | |
Bluetooth | BluetoothAdapter |
startDiscovery() startLeScan() LeScanCallback() |
2.6.5 Wi-Fi网络列表的手动配置限制
为了保护用户隐私,现在将Wi-Fi网络列表的手动配置限制在系统应用程序中。如果您的应用针对Android Q(TargetSdkVersion>=Q),则以下方法不再返回有用数据:
(1)getConfiguredNetworks()方法始终返回一个空列表;
(2)返回整数值的每个网络操作方法:addNetwork()和updateNetwork(),始终返回-1;
(3)返回布尔值的每个网络操作:removeNetwork(),reassociate(),enableNetwork(),disableNetwork(),reconnect()和disconnect(),始终返回false。
2.6.6 应用无法后台访问剪切板数据
除非应用程序是默认输入法编辑器(IME)或当前具有焦点的应用程序,否则应用程序无法访问剪贴板数据。如果应用有后台读取剪切板内容的行为需要整改。
2.6.7 USB序列号增加新的敏感权限
(1)对targetSdkVersion>=Q的应用有影响;
(2)新增权限控制:android.permission.MANAGE_USB,该权限需要系统签名才能申请意味着三方应用无法获取USB序列号。
2.6.8 权限变更
1、如果应用的TargetSdkVersion<=22,则用户首次在Android Q上运行您的应用时会看到权限页面,如下图所示。此页面使用户有机会撤消那些以前在安装时授予您应用的所有权限。
2、新增敏感权限:ACTIVITY_RECOGNITION,Android Q为需要检测用户移动的应用程序(例如步行,骑自行车或车辆)引入了新的ACTIVITY_RECOGNITION运行时权限。注意:除非用户已授予您的应用此权限,否则Google Play服务中的某些库(例如活动识别API)不会提供结果。该特性只影响TargetSdkVersion>=Q的应用。
3. 非SDK接口管控
3.1 背景介绍
3.1.1 非SDK接口的定义
1、SDK接口:https://developer.android.com/reference/packages,谷歌这个网站能查到的接口都是SDK接口;
2、非SDK接口:除了谷歌开放的SDK接口之外的其他JAVA接口都是非SDK接口。
3.1.2 应用滥用非SDK接口的危害
这些非SDK接口在大版本之间的变化可能很频繁,带来兼容性问题。
3.1.3 非SDK接口管控名单
3.1.3.1 名单说明
名单类型 | 影响 |
greylist | targetSDK>=P时,警告。 |
greylist-max-o | targetSDK=P时,不允许调用。 |
greylist-max-p | targetSDK=Q时,不允许调用。 |
blacklist | 所有三方应用不允许调用。 |
3.1.3.2 名单查看
该工具目录下面的hiddenapi-flags.csv文件就是所有的SDK和非SDK接口,已经对应的名单类型,开发者可以下载下来查看。
3.1.3.3 名单变化
(1)新增P版本的109810个接口被加入了目前Q版本的黑名单;
(2)P版本中一共有10642个接口(非SDK+SDK)在Q版本中删除。
3.2 兼容性影响
3.2.1 影响范围
所有三方应用都可能会受到影响,从名单变化来看,黑名单接口大大增加,Q版本有很多非SDK接口被删除,都会导致应用出现兼容性问题,影响评估很大,需要所有应用排查和整改。
1、QBeta1版本接口和P版本接口数据对比:
从接口分布的变化可以看出,Q版本新增了很多黑名单接口!
2、我们扫描发现下面的这些被删的接口还有应用正在使用,需要开发者排查一下是否还在使用这些被删除的接口:
- Landroid / app / INotificationManager; - > enqueueToast(Ljava / lang / String; Landroid / app / ITransientNotification; I) V
- Landroid / graphics / drawable / StateListDrawable; - > getStateDrawableIndex([I) I Landroid / hardware / fingerprint / Fingerprint; - > getFingerId() I Landroid / media / MediaFile; - > isWMAEnabled() Z Landroid / media / MediaFile; - > isWMVEnabled() Z Landroid / media / MediaFile; - > sMimeTypeMap: Ljava / util / HashMap; Landroid / media / ThumbnailUtils$SizedThumbnailBitmap; - > < init > (Landroid / media / ThumbnailUtils$1;) V Landroid / os / ServiceManager; - > sCache: Ljava / util / HashMap; Landroid / telephony / SignalStrength; - > mCdmaDbm: I Landroid / telephony / SignalStrength; - > mCdmaEcio: I Landroid / telephony / SignalStrength; - > mEvdoDbm: I Landroid / telephony / SignalStrength; - > mEvdoEcio: I Landroid / telephony / SignalStrength; - > mEvdoSnr: I Landroid / telephony / SignalStrength; - > mGsmBitErrorRate: I Landroid / telephony / SignalStrength; - > mGsmSignalStrength