
最近一次更新APP时,Google Play提示了这个信息。。。
历史的欠账总归要补上,貌似有点麻烦。。。
一步一个脚印,记录targetSdkVersion从21升级到26遇到的问题吧。。。
官方SDK版本更新总览:https://developer.android.com/about/versions/marshmallow/android-6.0-changes#behavior-runtime-permissions
官方TargetSDK说明:https://developer.android.com/distribute/best-practices/develop/target-sdk
中文PDF版:Google Play 目标 API 等级(targetSdkVersion)重要变更要求 _ Android Developers
1、Runtime Permission(在运行时请求权限)
这是 Android 6.0(API 级别 23)引进的,运行时态才去检查权限,而不是安装的时候。权限划分为多个保护级别,第三方应用受到下面三个级别的影响:normal, signature, dangerous.
Normal permissions
权限被声明为Normal级别,任何应用都可以申请,在安装应用时,不会直接提示给用户,点击全部才会展示。
截至Android8.1,下面的权限都是正常许可:
ACCESS_LOCATION_EXTRA_COMMANDS
ACCESS_NETWORK_STATE
ACCESS_NOTIFICATION_POLICY
ACCESS_WIFI_STATE
BLUETOOTH
BLUETOOTH_ADMIN
BROADCAST_STICKY
CHANGE_NETWORK_STATE
CHANGE_WIFI_MULTICAST_STATE
CHANGE_WIFI_STATE
DISABLE_KEYGUARD
EXPAND_STATUS_BAR
GET_PACKAGE_SIZE
INSTALL_SHORTCUT
INTERNET
KILL_BACKGROUND_PROCESSES
MANAGE_OWN_CALLS
MODIFY_AUDIO_SETTINGS
NFC
READ_SYNC_SETTINGS
READ_SYNC_STATS
RECEIVE_BOOT_COMPLETED
REORDER_TASKS
REQUEST_COMPANION_RUN_IN_BACKGROUND
REQUEST_COMPANION_USE_DATA_IN_BACKGROUND
REQUEST_DELETE_PACKAGES
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
SET_ALARM
SET_WALLPAPER
SET_WALLPAPER_HINTS
TRANSMIT_IR
USE_FINGERPRINT
VIBRATE
WAKE_LOCK
WRITE_SYNC_SETTINGS |
ACCESS_LOCATION_EXTRA_COMMANDS
ACCESS_NETWORK_STATE
ACCESS_NOTIFICATION_POLICY
ACCESS_WIFI_STATE
BLUETOOTH
BLUETOOTH_ADMIN
BROADCAST_STICKY
CHANGE_NETWORK_STATE
CHANGE_WIFI_MULTICAST_STATE
CHANGE_WIFI_STATE
DISABLE_KEYGUARD
EXPAND_STATUS_BAR
GET_PACKAGE_SIZE
INSTALL_SHORTCUT
INTERNET
KILL_BACKGROUND_PROCESSES
MANAGE_OWN_CALLS
MODIFY_AUDIO_SETTINGS
NFC
READ_SYNC_SETTINGS
READ_SYNC_STATS
RECEIVE_BOOT_COMPLETED
REORDER_TASKS
REQUEST_COMPANION_RUN_IN_BACKGROUND
REQUEST_COMPANION_USE_DATA_IN_BACKGROUND
REQUEST_DELETE_PACKAGES
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
SET_ALARM
SET_WALLPAPER
SET_WALLPAPER_HINTS
TRANSMIT_IR
USE_FINGERPRINT
VIBRATE
WAKE_LOCK
WRITE_SYNC_SETTINGS
Signature permissions
权限被声明为Signature级别,只有和该apk(定义了这个权限的apk)用相同的私钥签名的应用才可以申请该权限。
Dangerous permissions
权限被声明为Dangerous级别,任何应用都可以申请,在安装应用时,会直接提示给用户。
android.permission.READ_CALENDAR
android.permission.WRITE_CALENDAR
android.permission.CAMERA
android.permission.READ_CONTACTS
android.permission.WRITE_CONTACTS
android.permission.GET_ACCOUNTS
android.permission.ACCESS_FINE_LOCATION
android.permission.ACCESS_COARSE_LOCATION
android.permission.RECORD_AUDIO
android.permission.READ_PHONE_STATE
android.permission.READ_PHONE_NUMBERS
android.permission.CALL_PHONE
android.permission.ANSWER_PHONE_CALLS
android.permission.READ_CALL_LOG
android.permission.WRITE_CALL_LOG
android.permission.ADD_VOICEMAIL
android.permission.USE_SIP
android.permission.PROCESS_OUTGOING_CALLS
android.permission.BODY_SENSORS
android.permission.SEND_SMS
android.permission.RECEIVE_SMS
android.permission.READ_SMS
android.permission.RECEIVE_WAP_PUSH
android.permission.RECEIVE_MMS
android.permission.READ_EXTERNAL_STORAGE
android.permission.WRITE_EXTERNAL_STORAGE
android.permission.SYSTEM_ALERT_WINDOW |
android.permission.READ_CALENDAR
android.permission.WRITE_CALENDAR
android.permission.CAMERA
android.permission.READ_CONTACTS
android.permission.WRITE_CONTACTS
android.permission.GET_ACCOUNTS
android.permission.ACCESS_FINE_LOCATION
android.permission.ACCESS_COARSE_LOCATION
android.permission.RECORD_AUDIO
android.permission.READ_PHONE_STATE
android.permission.READ_PHONE_NUMBERS
android.permission.CALL_PHONE
android.permission.ANSWER_PHONE_CALLS
android.permission.READ_CALL_LOG
android.permission.WRITE_CALL_LOG
android.permission.ADD_VOICEMAIL
android.permission.USE_SIP
android.permission.PROCESS_OUTGOING_CALLS
android.permission.BODY_SENSORS
android.permission.SEND_SMS
android.permission.RECEIVE_SMS
android.permission.READ_SMS
android.permission.RECEIVE_WAP_PUSH
android.permission.RECEIVE_MMS
android.permission.READ_EXTERNAL_STORAGE
android.permission.WRITE_EXTERNAL_STORAGE
android.permission.SYSTEM_ALERT_WINDOW
在manifest文件中查找需要添加权限获取的类型,目前项目中使用了下面几个权限分组:Camera、Storage、Location、Phone。
Camera权限分组:比较简单,在扫描二维码的activity启动之前验证权限即可,代码异步回调调用。
Storage权限分组:需要根据调用的文件路径来区分,如下图所示,外部存储和内部存储的区别

也就是说访问 Environment.getExternalStorageDirectory类型的,都需要加上权限判断(还包括getExternalStoragePublicDirectory函数指定的参数、各种公共目录如DCIM、Music、Pictures、Movies、Downloads、Documents等等)。
2、FileUriExposedException异常
发现以前代码有些用法不对,如果是为了保存文件,那就需要提示要求权限;如果是为了分享文件,则需要改用Android7.0提出的FileProvider,同时在intent中设置Uri的临时权限 FLAG_GRANT_READ_URI_PERMISSION ,并且该权限在对方app退出后自动失效(intent的stack内有效)。
另外选拍照图片是一个特例,原先传入的是DCIM下的文件路径,现在要改成用FileProvider转换成Uri,否则相机会抛出异常:
android.os.FileUriExposedException: file:///storage/emulated/0/DCIM/Camera/IMG_20180713_171929.jpg exposed beyond app through ClipData.Item.getUri()
at android.os.StrictMode.onFileUriExposed(StrictMode.java:1972)
at android.net.Uri.checkFileUriExposed(Uri.java:2371)
at android.content.ClipData.prepareToLeaveProcess(ClipData.java:963)
at android.content.Intent.prepareToLeaveProcess(Intent.java:10231)
at android.content.Intent.prepareToLeaveProcess(Intent.java:10216)
at android.app.Instrumentation.execStartActivity(Instrumentation.java:1661)
at android.app.Activity.startActivityForResult(Activity.java:4617)
at android.support.v4.app.BaseFragmentActivityJB.startActivityForResult(BaseFragmentActivityJB.java:48)
at android.support.v4.app.FragmentActivity.startActivityForResult(FragmentActivity.java:77)
at android.support.v4.app.ActivityCompatJB.startActivityForResult(ActivityCompatJB.java:26)
at android.support.v4.app.ActivityCompat.startActivityForResult(ActivityCompat.java:146)
at android.support.v4.app.FragmentActivity.startActivityFromFragment(FragmentActivity.java:937)
at android.support.v4.app.FragmentActivity$HostCallbacks.onStartActivityFromFragment(FragmentActivity.java:1046)
at android.support.v4.app.Fragment.startActivityForResult(Fragment.java:956)
at android.support.v4.app.Fragment.startActivityForResult(Fragment.java:945) |
android.os.FileUriExposedException: file:///storage/emulated/0/DCIM/Camera/IMG_20180713_171929.jpg exposed beyond app through ClipData.Item.getUri()
at android.os.StrictMode.onFileUriExposed(StrictMode.java:1972)
at android.net.Uri.checkFileUriExposed(Uri.java:2371)
at android.content.ClipData.prepareToLeaveProcess(ClipData.java:963)
at android.content.Intent.prepareToLeaveProcess(Intent.java:10231)
at android.content.Intent.prepareToLeaveProcess(Intent.java:10216)
at android.app.Instrumentation.execStartActivity(Instrumentation.java:1661)
at android.app.Activity.startActivityForResult(Activity.java:4617)
at android.support.v4.app.BaseFragmentActivityJB.startActivityForResult(BaseFragmentActivityJB.java:48)
at android.support.v4.app.FragmentActivity.startActivityForResult(FragmentActivity.java:77)
at android.support.v4.app.ActivityCompatJB.startActivityForResult(ActivityCompatJB.java:26)
at android.support.v4.app.ActivityCompat.startActivityForResult(ActivityCompat.java:146)
at android.support.v4.app.FragmentActivity.startActivityFromFragment(FragmentActivity.java:937)
at android.support.v4.app.FragmentActivity$HostCallbacks.onStartActivityFromFragment(FragmentActivity.java:1046)
at android.support.v4.app.Fragment.startActivityForResult(Fragment.java:956)
at android.support.v4.app.Fragment.startActivityForResult(Fragment.java:945)
我们来看看FileUriExposedException是个什么鬼。。。
The exception that is thrown when an application exposes a file:// Uri to another app.
This exposure is discouraged since the receiving app may not have access to the shared path. For example, the receiving app may not have requested the Manifest.permission.READ_EXTERNAL_STORAGE runtime permission, or the platform may be sharing the Uri across user profile boundaries.
Instead, apps should use content:// Uris so the platform can extend temporary permission for the receiving app to access the resource.
This is only thrown for applications targeting Build.VERSION_CODES.N or higher. Applications targeting earlier SDK versions are allowed to share file:// Uri, but it’s strongly discouraged.
总而言之,就是Android不再允许在app中把file://Uri暴露给其他app,包括但不局限于通过Intent或ClipData 等方法。
public static Uri fixFileProviderUri(Context context, File file, Intent action) {
// FileProvider修正文件uri
Uri uri = FileProvider.getUriForFile(context,
context.getString(R.string.file_provider_auth), file);
action.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
return uri;
} |
public static Uri fixFileProviderUri(Context context, File file, Intent action) {
// FileProvider修正文件uri
Uri uri = FileProvider.getUriForFile(context,
context.getString(R.string.file_provider_auth), file);
action.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
return uri;
}
通过FileProvider提供的getUriForFile,将file://路径转成content://,注意!这里需要加上FLAG_GRANT_WRITE_URI_PERMISSION,同时APP应该请求Camera和Storage权限。。。。。。
拍照和选图片搞定了,结果裁剪图片又出了问题,原来APP提供出去的路径,需要给裁剪APP赋值可写的权限:
for (ResolveInfo res : list) {
// API 24 需要赋予每一个裁剪APP访问文件的读写权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// 图片文件只读
context.getContext().grantUriPermission(res.activityInfo.packageName, imageFile,
Intent.FLAG_GRANT_READ_URI_PERMISSION);
// 裁剪输出文件需要可写
context.getContext().grantUriPermission(res.activityInfo.packageName, uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
} |
for (ResolveInfo res : list) {
// API 24 需要赋予每一个裁剪APP访问文件的读写权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// 图片文件只读
context.getContext().grantUriPermission(res.activityInfo.packageName, imageFile,
Intent.FLAG_GRANT_READ_URI_PERMISSION);
// 裁剪输出文件需要可写
context.getContext().grantUriPermission(res.activityInfo.packageName, uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
}
这里grantUriPermission如果传入的包名是自身app,等同于上面的addFlags添加的Intent.FLAG_GRANT_READ_URI_PERMISSION。
我滴妈呀,FileProvider的坑真多,Intent分享出去的Uri即要自己可读,又要给别的APP赋予可写的权限。。。。。。
参考文档:http://gelitenight.github.io/android/2017/01/29/solve-FileUriExposedException-caused-by-file-uri-with-FileProvider.html
链接打不开可以点PDF文档快照:使用FileProvider解决file___ URI引起的FileUriExposedException
如果FileProvider的坑到这里就结束了,我会灰常开森。。。
下面介绍由content://引申的又一个坑,由于Intent之间传递禁止了file://头,所以APP内部传递文件句柄,也必须经FileProvider的手,那么在onActivityResult得到的Uri也是content://,大家可能会想,以前拿到这种类型已经有开源的方法嘛,通过context.getContentResolver().query查询column = “_data”得到真实的文件路径。。。可惜抛出了异常:
Caused by: java.lang.IllegalArgumentException: column '_data' does not exist |
Caused by: java.lang.IllegalArgumentException: column '_data' does not exist
查阅官方对FileProvider的详细解释,它给出了标准的流程如下(Access the requested file):
/*
* When the Activity of the app that hosts files sets a result and calls
* finish(), this method is invoked. The returned Intent contains the
* content URI of a selected file. The result code indicates if the
* selection worked or not.
*/
@Override
public void onActivityResult(int requestCode, int resultCode,
Intent returnIntent) {
// If the selection didn't work
if (resultCode != RESULT_OK) {
// Exit without doing anything else
return;
} else {
// Get the file's content URI from the incoming Intent
Uri returnUri = returnIntent.getData();
/*
* Try to open the file for "read" access using the
* returned URI. If the file isn't found, write to the
* error log and return.
*/
try {
/*
* Get the content resolver instance for this context, and use it
* to get a ParcelFileDescriptor for the file.
*/
mInputPFD = getContentResolver().openFileDescriptor(returnUri, "r");
} catch (FileNotFoundException e) {
e.printStackTrace();
Log.e("MainActivity", "File not found.");
return;
}
// Get a regular file descriptor for the file
FileDescriptor fd = mInputPFD.getFileDescriptor();
...
}
} |
/*
* When the Activity of the app that hosts files sets a result and calls
* finish(), this method is invoked. The returned Intent contains the
* content URI of a selected file. The result code indicates if the
* selection worked or not.
*/
@Override
public void onActivityResult(int requestCode, int resultCode,
Intent returnIntent) {
// If the selection didn't work
if (resultCode != RESULT_OK) {
// Exit without doing anything else
return;
} else {
// Get the file's content URI from the incoming Intent
Uri returnUri = returnIntent.getData();
/*
* Try to open the file for "read" access using the
* returned URI. If the file isn't found, write to the
* error log and return.
*/
try {
/*
* Get the content resolver instance for this context, and use it
* to get a ParcelFileDescriptor for the file.
*/
mInputPFD = getContentResolver().openFileDescriptor(returnUri, "r");
} catch (FileNotFoundException e) {
e.printStackTrace();
Log.e("MainActivity", "File not found.");
return;
}
// Get a regular file descriptor for the file
FileDescriptor fd = mInputPFD.getFileDescriptor();
...
}
}
上面这段话的核心意思,就是通过getContentResolver().openFileDescriptor持有Intent传递过来的文件句柄,已经赋予了相应的读写权限。
So,到底怎么搞好呀?如果只是简单的拿文件,直接读数据倒简单;不过大部分代码应该是存储了文件路径,并且在APP关闭或者手机重启之后继续访问;
这个问题有点棘手,尽量在onActivityResult之后立即处理数据,否则拷贝文件带来性能开销、管理文件也需要额外处理。
唔,步子迈的太大会扯着蛋。。。考虑到APP内部跳转Intent携带的Uri路径是以本包名格式固定的,特别处理一下算了。
特别的举例:EXTERNAL_DIR,一般图省事,在res/xml/filepaths.xml中会定义外部存储的根路径
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- root-path/files-path/cache-path/external-path -->
<external-path path="/" name="EXTERNAL_DIR" />
</paths> |
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- root-path/files-path/cache-path/external-path -->
<external-path path="/" name="EXTERNAL_DIR" />
</paths>
那么转换Uri得到的路径是这样子的:
content://包名.fileprovider/EXTERNAL_DIR/xxxx |
content://包名.fileprovider/EXTERNAL_DIR/xxxx
So,我们通过Environment.getExternalStorageDirectory得到根路径,替代content://包名.fileprovider/EXTERNAL_DIR部分,就拿到了真实的文件路径。
哦,顺嘴提一句,上面这个函数虽然要求Storage权限,但是实际上无权限时,也能得到路径,只是不能访问读写文件而已。
注意,这样的替换只能针对APP内部Intent跳转,并且传递文件路径是在External Storage下的!!!
// APP内部Intent如果采用的EXTERNAL_DIR路径,可以直接转译成file://
public static final String EXTERNAL_DIR = "EXTERNAL_DIR";
@TargetApi(Build.VERSION_CODES.N)
public static Uri parseSelfUri(Uri uri) {
final String path = uri.getPath();
final String prefix = "/" + EXTERNAL_DIR;
if (path.startsWith(prefix)) {
final File sdcard = Environment.getExternalStorageDirectory();
return Uri.fromFile(new File(path.replace(prefix, sdcard.getPath())));
}
return uri;
}
@TargetApi(Build.VERSION_CODES.N)
public static boolean isSelfPackageUri(Context context, Uri uri) {
final String auth = uri.getAuthority();
final String selfAuth = context.getString(R.string.file_provider_auth);
if (auth != null && auth.equalsIgnoreCase(selfAuth)) {
return true;
}
return false;
} |
// APP内部Intent如果采用的EXTERNAL_DIR路径,可以直接转译成file://
public static final String EXTERNAL_DIR = "EXTERNAL_DIR";
@TargetApi(Build.VERSION_CODES.N)
public static Uri parseSelfUri(Uri uri) {
final String path = uri.getPath();
final String prefix = "/" + EXTERNAL_DIR;
if (path.startsWith(prefix)) {
final File sdcard = Environment.getExternalStorageDirectory();
return Uri.fromFile(new File(path.replace(prefix, sdcard.getPath())));
}
return uri;
}
@TargetApi(Build.VERSION_CODES.N)
public static boolean isSelfPackageUri(Context context, Uri uri) {
final String auth = uri.getAuthority();
final String selfAuth = context.getString(R.string.file_provider_auth);
if (auth != null && auth.equalsIgnoreCase(selfAuth)) {
return true;
}
return false;
}
自测了一下APP内部传递Uri.fromFile(File)数据的情况,只有Intent#setData(Uri)与Intent#setClipData(ClipData)这两种传递Uri类型的方式,才需要限定采用content//auth.fileprovider/ 这种格式。
注意我用的是“两种传递Uri类型”,查看Intent的method,setData类型包含:setData、setDataAndNormalize、setDataAndType、setDataAndTypeAndNormalize。
God bless me!
审视代码中用到的Uri.fromFile以及Intent#setData、Intent#setClipData,只有选图片文件、裁剪图片才采用了上述类型。于是修改Intent返回的Uri用FileProvider修复,在应答里采用上面的parseSelfUri还原。
至此,FileProvider应该算是全部搞定,下面继续爬坑。(总结一下,FileProvider强制将APP内文件变成沙盒,对外不可见;为了分享对外可见,又额外的增加了Intent flag权限;基本上所有的核心问题都围绕着这个,蛋疼的还要结合Storage Permissions)
3、后台执行限制
包括后台Service、后台广播、后台位置限制三部分
① StartService
该方法只能是应用在前台时调用,否则抛出ANR异常。对此,提供了新的startForgroundService,启动前台服务。在Service启动后,需要调用startForground,提供一个前台Notification说明。这部分将在下边与通知的变更一块说明。
② 隐式广播
③ 后台位置限制
4、通知渠道
android.app.Notification.Builder增加了一个String channelId的构造参数,它是由NotificationManager#createNotificationChannel创建;
注意Notification.Builder去掉了setDefaults(Notification.DEFAULT_SOUND),由NotificationChannel的importance属性决定;同时增加了enableVibration、enableLights,实现震动和闪烁的提醒。
另外,上文startForground的第一个参数id,也是Notification的通知id,一般是默认自增字段,需要大于0;第二个参数是Notification对象,使用默认的channelId创建即可。