Andriod 9.0适配
1.Apache HTTP client 相关类找不到
将 compileSdkVersion 升级到 28 之后,如果在项目中用到了 Apache HTTP client 的相关类,就会抛出找不到这些类的错误。
这是因为官方已经在 Android P 的启动类加载器中将其移除,如果仍然需要使用 Apache HTTP client,可以在 Manifest 文件中加入:
<uses-library android:name="org.apache.http.legacy" android:required="false"/>
2.Android P 限制了明文流量的网络请求,非加密的流量请求都会被系统禁止掉。
解决方案:
在资源文件新建xml目录,新建文件
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
清单文件配置:
android:networkSecurityConfig="@xml/network_security_config",但还是建议都使用https进行传输。
3.限制非Activity场景启动Activity
从Android P开始,只有当Intent flag中指定了FLAG_ACTIVITY_NEW_TASK,才允许在非Activity场景启动Activity。
如果不在Intent添加FLAG_ACTIVITY_NEW_TASK,将无法 通过非Activity的Context启动一个Activity,并且会抛异常。
例如: 在一个service中启动一个Activity:
private void openFeedbackActivity(CC cc) {
Context context = cc.getContext();
Intent intent = new Intent(context, FeedbackActivity.class);
if (!(context instanceof Activity)) {
//调用方没有设置context或app间组件跳转,context为application
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
context.startActivity(intent);
CC.sendCCResult(cc.getCallId(), CCResult.success());
}
4.必须要授予FOREGROUND_SERVICE权限,才能够使用前台服务,否则会抛出异常。
这是一个带Notification的简单前台服务, 如果我们没有在AndroidManifest中注册FOREGROUND_SERVICE权限,在Service启动的时候会抛出SecurityException异常。对此,我们只需要在AndroidManifest添加对应的权限即可,这个权限是普通权限,不需要动态申请。
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@Override
public void onCreate() {
super.onCreate();
String channelID = "1";
String channelName = "channel_name";
NotificationChannel channel = new NotificationChannel(channelID, channelName, NotificationManager.IMPORTANCE_HIGH);
NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
manager.createNotificationChannel(channel);
Intent intent = new Intent(this, ForegroundActivity.class);
PendingIntent pi = PendingIntent.getActivity(this, 0, intent, 0);
Notification notification = new NotificationCompat.Builder(this)
.setContentTitle("前台服务测试")
.setContentText("前台服务需要增加 FOREGROUND_SERVICE 权限")
.setChannelId(channelID)
.setWhen(System.currentTimeMillis())
.setSmallIcon(R.mipmap.ic_launcher)
.setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher))
.setContentIntent(pi)
.build();
startForeground(1, notification);
}
Android 8.0适配
1.非全屏透明页面不允许设置方向
解决方案:android:windowIsTranslucent设置为false
2.通知栏
Android 8.0 引入了通知渠道,其允许您为要显示的每种通知类型创建用户可自定义的渠道。用户界面将通知渠道称之为通知类别。
针对 8.0 的应用,创建通知前需要创建渠道,创建通知时需要传入 channelId,否则通知将不会显示。示例代码如下:
// 创建通知渠道
private void initNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
CharSequence name = mContext.getString(R.string.app_name);
NotificationChannel channel = new NotificationChannel(mChannelId, name, NotificationManager.IMPORTANCE_DEFAULT);
mNotificationManager.createNotificationChannel(channel);
}
}
// 创建通知传入channelId
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NotificationBarManager.getInstance().getChannelId());
3.允许安装未知来源应用
针对 8.0 的应用需要在 AndroidManifest.xml 中声明 REQUEST_INSTALL_PACKAGES 权限
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
4.自动升级 8.0安装需要的权限
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
5.后台服务限制
系统不允许后台应用创建后台服务。 因此,Android 8.0 引入了一种全新的方法,即 Context.startForegroundService(),以在前台启动新服务。
//开始定位
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { //android8.0以上通过startForegroundService启动service
startForegroundService(new Intent(MainActivity.this, LocationService.class));
} else {
startService(new Intent(MainActivity.this, LocationService.class));
}
6.对隐式广播进行了限制
隐式广播大部分(少部分可以用,个推暂时没问题)被禁止,在AndroidManifest中注册的Receiver将不能够生效,如果你的清单文件中有如下的监听器:
<receiver android:name="com.haha.receiver.UpdateReceiver">
<intent-filter>
<action android:name="com.haha.action.ACTION_UPDATE" />
</intent-filter>
</receiver>
问题1.android – 在清单中区分隐式广播接收器与显式广播接收器?
根据Google提供的 Android O迁移指南,大多数隐式广播意图不应在Manifest中注册(减去少数例外情况 here),
但明确的广播意图保持不变.
我们希望将任何所需的广播从清单中移除.但是我们如何识别接收器是否隐含?有一般规则吗?
我们应该只查看“action”标记,看看它是否被列入白名单以便将其保留在清单中?
<receiver
android:name=".receiver.ImageBroadcastReceiver"
android:enabled="true" >
<intent-filter>
<action android:name="android.hardware.action.NEW_PICTURE" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="image/*" />
</intent-filter>
</receiver>
<receiver
android:name=".receiver.InstallReferrerReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.android.vending.INSTALL_REFERRER" />
</intent-filter>
</receiver>
<receiver android:name=".receiver.JoinEventReceiver" >
<intent-filter>
<action android:name="JOIN_ACTION" />
<action android:name="CANCEL_ACTION" />
<action android:name="DECLINE_ACTION" />
</intent-filter>
</receiver>
例如,“com.android.vending.INSTALL_REFERRER”意图未列入白名单.我们应该在活动中注册吗?如果是这样,它不会被解雇,因为当我们注册它时,应用程序已经安装?当我试图理解广播接收器是隐式的还是显式的时,这让我感到困惑,因为我认为我只需要检查“动作”标签.
最佳答案
But how do we recognise if a receiver is implicit?
如果Intent具有ComponentName,则Intent是显式的.否则,它是隐含的。
ComponentName可以通过以下几种方式之一获得,包括:
>它可以直接放在Intent上(例如,新的Intent(this,TheReallyAwesomeReceiver.class)
>使用PackageManager和queryIntentReceivers()根据动作字符串等 找到正确的一个后,可以直接将它放在Intent上.
>它可以由系统从动作字符串等派生,再加上通过setPackage()定义的包
Should we look only at the “action” tag and see if it is whitelisted to keep it in the manifest?
不需要.您还需要考虑广播的性质:它是针对任何已注册的接收者,还是仅针对特定应用?
For example, the “com.android.vending.INSTALL_REFERRER” intent is not whitelisted. Should we register it in an Activity?
不.那个广播只会转到最近安装的应用程序,所以它必须是一个明确的意图.
动作字符串等可以帮助系统确定哪些已注册的接收器是相关的接收器。
与ACTION_PACKAGE_ADDED对比,这是向任何注册接收者广播的;它不会只是一个特定的应用程序.
因此,Intent必须是隐式的(否则它会有一个ComponentName标识特定应用程序中的特定接收者).并且,由于ACTION_PACKAGE_ADDED不在白名单中,因此假设您无法在Android 8.0的清单中注册此广播.
Android 7.0脱坑指南(api24)
1.安装时解析错误:
用户在收到提示更新并且下载完后,会自动打开安装页面让用户来去安装。这时就会出现安装错误的问题,这类的问题的可能性比较多。
比如较低版本的App想要覆盖已有的较高版本App会提示安装未完成,
不过7.0上常见的有以下两种情况。
1.应用间共享文件
在调用安装页面,或修改用户头像操作时,就会失败。那么就需要你去适配7.0或是将targetSdkVersion改为24以下(不推荐)。
适配的方法这里就不细讲,大家可以看鸿洋大神的 Android 7.0 行为变更 通过FileProvider在应用间共享文件吧 这篇文章。
2.APK signature scheme v2
Android 7.0 引入 新的应用签名方案 APK Signature Scheme v2,它能提供更快的应用安装时间和 更多 针对未授权 APK文件更改的保护。
在默认情况下,Android Studio 2.2 和 Android Plugin for Gradle 2.2 会使用 APK Signature Scheme v2 和 传统签名方案来签署您的应用。详细看安卓官方说明。
简单地说,就是 任何方式的篡改APK 文件,在利用了V2签名的apk上会失效。
可以看到默认是V1 和V2选中的。
1)只勾选v1签名就是传统方案签署,但是在7.0上不会使用V2安全的验证方式。
2)只勾选V2签名7.0以下会 显示未安装,7.0上则会使用了V2安全的验证方式。
3)同时勾选V1和V2则所有版本都没问题。
这里问题就来了,默认全部勾选,按道理所有版本是没有问题的。那么我们为什么还是安装错误?
其实是 因为我们项目采用了美团的快速生成渠道包方案。这种方案不适用于V2的签名方案。(因为实现思路就是给已有的apk文件中,添加空的渠道文件)
解决办法:
1.如果你的渠道较少, 可以用gradle方式的多渠道打包。 渠道多的话就不适用了。
2.毕竟V2不是强制的, 那么我们要用传统方案签署,可以打开,模块级build.gradle 文件,然后将 行v2SigningEnabled false 添加到您的版本签名配置中:
android {
...
defaultConfig { ... }
signingConfigs {
release {
storeFile file("myreleasekey.keystore")
storePassword "password"
keyAlias "MyReleaseKey"
keyPassword "password"
v2SigningEnabled false //<--这里
}
}
}
或者 将Gradle 升级为2.3以上。那么打包页面是这样。 我们可以不勾选V2选项。
3.前两种方法是比较快速的可以解决问题,但是一旦这种安全措施被强制(毕竟我们可以感受到安卓在安全方面的努力,比如权限控制、应用间共享文件),我们怎么办。其实美团早早发现了这个问题, 具体看这篇 , 新一代开源Android渠道包生成工具Walle。里面有深度的原理讲解,满满的干货。
2.后台优化
这些隐式广播可以做一些特定的功能,如,当手机网络变成WiFi时自动下载更新包等。
但,这些隐式广播 会在后台频繁启动已注册侦听这些广播的应用,从而带来很大的电量消耗,为缓解这一问题来提升设备性能和用户体验,
在Android 7.0中删除了三项隐式广播,以帮助优化内存使用和电量消耗。(connectivity_action, action_new_picture,)
在 Android 7.0上 应用不会收到 CONNECTIVITY_ACTION 广播,即使你在manifest清单文件中设置了请求接受这些事件的通知。
但,在前台运行的应用如果使用BroadcastReceiver 请求接收通知,则仍可以在主线程中侦听 CONNECTIVITY_CHANGE。
在 Android 7.0上应用无法发送或接收 ACTION_NEW_PICTURE 或ACTION_NEW_VIDEO 类型的广播。
应对策略:
Android 框架提供多个解决方案来缓解对这些隐式广播的需求。 例如,JobScheduler API
提供了一个稳健可靠的机制来安排满足指定条件(例如连入无线流量网络)时所执行的网络操作。 您甚至可以使用 JobScheduler API 来适应内容提供程序变化。
移动设备会经历频繁的连接变更,例如在 Wi-Fi 和移动数据之间切换时。目前,可在应用清单中注册一个接收器来侦听隐式 CONNECTIVITY_ACTION 广播,
让应用能够监控这些变更。由于很多应用会注册接收此广播,因此单次网络切换即会导致 所有应用被唤醒并 同时处理此广播。
3.PopupWindow位置不正确。7.0系统的手机上,PopupWindow弹出位置不正确。
有两种可能:
1.我们使用了update方法,同时设置了Gravity(Gravity.NO_GRAVITY没事)。
因为在update方法中有调用computeGravity方法去获取Gravity。(7.0以下没有获取Gravity进行更新判断)
public void update() {
// 省略部分代码
final int newGravity = computeGravity();
if (newGravity != p.gravity) {
p.gravity = newGravity;
update = true;
}
if (update) {
setLayoutDirectionFromAnchor();
mWindowManager.updateViewLayout(mDecorView, p);
}
}
Android 7.0 computeGravity方法源码
private int computeGravity() {
int gravity = Gravity.START | Gravity.TOP;
if (mClipToScreen || mClippingEnabled) {
gravity |= Gravity.DISPLAY_CLIP_VERTICAL;
}
return gravity;
}
Android 7.1 computeGravity方法
private int computeGravity() {
int gravity = mGravity == Gravity.NO_GRAVITY ? Gravity.START | Gravity.TOP : mGravity;
if (mIsDropdown && (mClipToScreen || mClippingEnabled)) {
gravity |= Gravity.DISPLAY_CLIP_VERTICAL;
}
return gravity;
}
很显然在7.0上我们设置的Gravity被覆盖了。 解决就很简单了,不使用update方法。如果你真的要使用可以参考这篇文章的方法。
2. PopupWindow高度为MATCH_PARENT, 在显示的时候调用 showAsLocation方法时,PopupWindow并没有在指定控件的下方显示。
如果使用showAsDropDown,会全屏显示。
PopupWindow的位置按照有无偏移分,可以分为偏移和无偏移两种;按照参照物的不同,可以分为相对于某个控件(Anchor锚)和相对于父控件。
* •showAsDropDown(View anchor):相对某个控件的位置(正左下方),无偏移
* •showAsDropDown(View anchor,int xoff,int yoff):相对某个控件的位置,有偏移
* •showAtLocation(View parent,int gravity,int x,int y):相对于父控件的位置
* (例如正中央Gravity.CENTER,下方Gravity.BOTTOM等),可以设置偏移或无偏移
解决方法:
1.最简单的解决方法就是指定 PopupWindow 的高度为 WRAP_CONTENT, 调用 showAsDropDown方法。
2.或者 弹出时做一下判断处理(代码来自PopupWindowCompat)
if (Build.VERSION.SDK_INT >= 24) { // Android 7.x中,PopupWindow高度为match_parent时,会出现兼容性问题,需要处理兼容性
int[] location = new int[2]; // 记录anchor在屏幕中的位置
anchor.getLocationOnScreen(location);
int offsetY = location[1] + anchor.getHeight();
if (Build.VERSION.SDK_INT >= 25) { // Android 7.1 ,8.0中,PopupWindow高度为 match_parent 时,会占据整个屏幕
//故而需要在 Android 7.1上再做特殊处理
int screenHeight = ScreenUtils.getScreenHeight(context); // 获取屏幕高度
popupWindow.setHeight(screenHeight - offsetY); // 重新设置 PopupWindow 的高度
}
popupWindow.showAtLocation(anchor, Gravity.NO_GRAVITY, 0, offsetY);
} else {
popupWindow.showAsDropDown(anchor);
}
5.通知栏适配
这里有一篇非常详细的通知栏介绍与适配,分享给大家:Android通知栏介绍与适配总结
6.WebView问题
Android 7.0 WebView 部分机型打不开:
Android 7.0 WebView 二级跳转后界面空白?
Android 7.0 WebView 部分机型打不开?webview 在https请求时候,有证书校验
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
if (error.getPrimaryError() == SslError.SSL_DATE_INVALID
|| error.getPrimaryError() == SslError.SSL_EXPIRED
|| error.getPrimaryError() == SslError.SSL_INVALID
|| error.getPrimaryError() == SslError.SSL_UNTRUSTED) {
handler.proceed();
} else {
handler.cancel();
}
super.onReceivedSslError(view, handler, error);
}
通过重写 onReceivedSslError 过滤掉,部分错误
SSL_DATE_INVALID 证书的日期是无效的。
SSL_EXPIRED 证书已经过期。SSL_INVALID 一个通用的错误发生。
SSL_UNTRUSTED 不受信任的证书颁发机构。
6.Toast导致的BadTokenException
同学,你的系统Toast可能需要修复一下
最后觉得不错,点个赞吧!
7.多语言特性
首先是官方的API指南:语言和语言区域
变化对比: Android 7.0多语言支持开发浅析
实现功能: Android 实现应用内置语言切换
————————————————
1.Android 7.0 行为变更 通过FileProvider在应用间共享文件吧
本文已在我的公众号hongyangAndroid原创首发。
转载请标明出处:
http://blog.csdn.net/lmj623565791/article/details/72859156
一、概述
之前项目的新特性适配工作都是同事在做,一直没有怎么太关注,不过类似这些适配的工作还是有必要做一些记录的。
对于Android 7.0,提供了非常多的变化,详细的可以阅读官方文档Android 7.0 行为变更,记得当时做了多窗口支持、FileProvider以及7.1的3D Touch的支持,
说必须要适配的就是去除项目中传递file://类似格式的uri了。
在官方7.0的以上的系统中,尝试传递 file://URI 可能会触发FileUriExposedException。
所以本文主要描述如何适配该问题,没什么难度,仅做记录。
注:本文targetSdkVersion 25 ,compileSdkVersion 25
二、拍照案例
手机拍照一定都不陌生,得到一张高清拍照图的时候,我们通过Intent会传递一个File的Uri给相机应用。
大致代码如下:
private static final int REQUEST_CODE_TAKE_PHOTO = 0x110;
private String mCurrentPhotoPath;
public void takePhotoNoCompress(View view) {
Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA)
.format(new Date()) + ".png";
File file = new File(Environment.getExternalStorageDirectory(), filename);
mCurrentPhotoPath = file.getAbsolutePath();
takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(file));
startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO);
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_TAKE_PHOTO) {
mIvPhoto.setImageBitmap(BitmapFactory.decodeFile(mCurrentPhotoPath));
}
// else tip?
}
未处理6.0权限,有需要的自行处理下,nexus系列如果未处理,需要手动在设置页开启存储权限。
此时如果我们使用Android 7.0或者以上的原生系统,再次运行一下,你会发现应用直接停止运行,抛出了android.os.FileUriExposedException:
Caused by: android.os.FileUriExposedException:
file:///storage/emulated/0/20170601-030254.png
exposed beyond app through ClipData.Item.getUri()
at android.os.StrictMode.onFileUriExposed(StrictMode.java:1932)
at android.net.Uri.checkFileUriExposed(Uri.java:2348)
所以如果你意识到自己写的代码,在7.0的原生系统的手机上直接就crash是不是很方~
原因在官网已经给了解释:
对于面向 Android 7.0 的应用,Android 框架执行的 StrictMode API 政策禁止在您的应用外部公开 file:// URI。如果一项包含文件 URI 的 intent 离开您的应用,则应用出现故障,并出现 FileUriExposedException 异常。
同样的,官网也给出了解决方案:
要在应用间共享文件,您应发送一项 content:// URI,并授予 URI 临时访问权限。进行此授权的最简单方式是使用 FileProvider 类。如需了解有关权限和共享文件的详细信息,请参阅共享文件。
https://developer.android.com/about/versions/nougat/android-7.0-changes.html#accessibility
那么下面就看看如何通过FileProvider解决此问题吧。
三、使用FileProvider兼容拍照
其实对于如何使用FileProvider,其实在FileProvider的API页面也有详细的步骤,有兴趣的可以看下。
https://developer.android.com/reference/android/support/v4/content/FileProvider.html
FileProvider实际上是ContentProvider的一个子类,它的作用也比较明显了,file:///Uri不给用,那么换个Uri为content://来替代。
下面我们看下整体的实现步骤,并考虑为什么需要怎么做?
(1)声明provider
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="com.zhy.android7.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
为什么要声明呢?因为FileProvider是ContentProvider子类哇~~
注意一点,他需要设置一个meta-data,里面指向一个xml文件。
(2)编写resource xml file
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<root-path name="root" path="" />
<files-path name="files" path="" />
<cache-path name="cache" path="" />
<external-path name="external" path="" />
<external-files-path name="name" path="path" />
<external-cache-path name="name" path="path" />
</paths>
在paths节点内部支持以下几个子节点,分别为:
<root-path/> 代表设备的根目录new File("/");
<files-path/> 代表context.getFilesDir()
<cache-path/> 代表context.getCacheDir()
<external-path/> 代表Environment.getExternalStorageDirectory()
<external-files-path>代表context.getExternalFilesDirs()
<external-cache-path>代表getExternalCacheDirs()
每个节点都支持两个属性:
name
path
path即为代表目录下的子目录,比如:
<external-path
name="external"
path="pics" />
代表的目录即为:Environment.getExternalStorageDirectory()/pics,其他同理。
当这么声明以后,代码可以使用你所声明的当前文件夹以及其子文件夹。
本例使用的是SDCard所以这么写即可:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="external" path="" />
</paths>
为了简单,我们直接使用SDCard根目录,所以path里面就不填写子目录了~
这里你可能会有疑问,为什么要写这么个xml文件,有啥用呀?
刚才我们说了,现在要使用content://uri替代file://uri,那么,content://的uri如何定义呢?总不能使用文件路径吧,那不是骗自己么~
所以,需要一个虚拟的路径对文件路径进行映射,所以需要编写个xml文件,通过path以及xml节点确定可访问的目录,通过name属性来映射真实的文件路径。
(3)使用FileProvider API
好了,接下来就可以通过FileProvider把我们的file转化为content://uri了~
public void takePhotoNoCompress(View view) {
Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA)
.format(new Date()) + ".png";
File file = new File(Environment.getExternalStorageDirectory(), filename);
mCurrentPhotoPath = file.getAbsolutePath();
Uri fileUri = FileProvider.getUriForFile(this, "com.zhy.android7.fileprovider", file);
takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri);
startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO);
}
}
核心代码就这一行了~
FileProvider.getUriForFile(this, "com.zhy.android7.fileprovider", file);
1
第二个参数就是我们配置的authorities,这个很正常了,总得映射到确定的ContentProvider吧~所以需要这个参数。
然后再看一眼我们生成的uri:
content://com.zhy.android7.fileprovider/external/20170601-041411.png
1
可以看到格式为:content://authorities/定义的name属性/文件的相对路径,即name隐藏了可存储的文件夹路径。
现在拿7.0的原生手机运行就正常啦~
不过事情到此并没有结束~~
打开一个4.4的模拟器,运行上述代码,你会发现又Crash啦,抛出了:Permission Denial~
Caused by: java.lang.SecurityException: Permission Denial: opening provider android.support.v4.content.FileProvider from ProcessRecord{52b029b8 1670:com.android.camera/u0a36} (pid=1670, uid=10036) that is not exported from uid 10052
at android.os.Parcel.readException(Parcel.java:1465)
at android.os.Parcel.readException(Parcel.java:1419)
at android.app.ActivityManagerProxy.getContentProvider(ActivityManagerNative.java:2848)
at android.app.ActivityThread.acquireProvider(ActivityThread.java:4399)
因为低版本的系统,仅仅是把这个当成一个普通的Provider在使用,而我们没有授权,contentprovider的export设置的也是false;导致Permission Denial。
那么,我们是否可以将export设置为true呢?
很遗憾是不能的。
在FileProvider的内部:
@Override
public void attachInfo(Context context, ProviderInfo info) {
super.attachInfo(context, info);
// Sanity check our security
if (info.exported) {
throw new SecurityException("Provider must not be exported");
}
if (!info.grantUriPermissions) {
throw new SecurityException("Provider must grant uri permissions");
}
mStrategy = getPathStrategy(context, info.authority);
}
确定了exported必须是false,grantUriPermissions必须是true ~~
所以唯一的办法就是授权了~
context提供了两个方法:
grantUriPermission(String toPackage, Uri uri,
int modeFlags)
revokeUriPermission(Uri uri, int modeFlags);
可以看到grantUriPermission需要传递一个包名,就是你给哪个应用授权,但是很多时候,比如分享,我们并不知道最终用户会选择哪个app,所以我们可以这样:
List<ResolveInfo> resInfoList = context.getPackageManager()
.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
for (ResolveInfo resolveInfo : resInfoList) {
String packageName = resolveInfo.activityInfo.packageName;
context.grantUriPermission(packageName, uri, flag);
}
根据Intent查询出的所以符合的应用,都给他们授权~~
恩,你可以在不需要的时候通过revokeUriPermission移除权限~
那么增加了授权后的代码是这样的:
public void takePhotoNoCompress(View view) {
Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA)
.format(new Date()) + ".png";
File file = new File(Environment.getExternalStorageDirectory(), filename);
mCurrentPhotoPath = file.getAbsolutePath();
Uri fileUri = FileProvider.getUriForFile(this, "com.zhy.android7.fileprovider", file);
List<ResolveInfo> resInfoList = getPackageManager()
.queryIntentActivities(takePictureIntent, PackageManager.MATCH_DEFAULT_ONLY);
for (ResolveInfo resolveInfo : resInfoList) {
String packageName = resolveInfo.activityInfo.packageName;
grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri);
startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO);
}
}
这样就搞定了,不过还是挺麻烦的,如果你仅仅是对旧系统做兼容,还是建议做一下版本校验即可,也就是说不要管什么授权了,直接这样获取uri
Uri fileUri = null;
if (Build.VERSION.SDK_INT >= 24) {
fileUri = FileProvider.getUriForFile(this, "com.zhy.android7.fileprovider", file);
} else {
fileUri = Uri.fromFile(file);
}
这样会比较方便~也避免导致一些问题。当然了,完全使用uri也有一些好处,比如你可以使用私有目录去存储拍摄的照片~
文章最后会给出快速适配的方案~~不需要这么麻烦~
好像,还有什么知识点没有提到,再看一个例子吧~
四、使用FileProvider兼容安装apk
正常我们在编写安装apk的时候,是这样的:
public void installApk(View view) {
File file = new File(Environment.getExternalStorageDirectory(), "testandroid7-debug.apk");
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(Uri.fromFile(file),
"application/vnd.android.package-archive");
startActivity(intent);
}
拿个7.0的原生手机跑一下,android.os.FileUriExposedException又来了~~
android.os.FileUriExposedException: file:///storage/emulated/0/testandroid7-debug.apk exposed beyond app through Intent.getData()
1
2
好在有经验了,简单修改下uri的获取方式。
if (Build.VERSION.SDK_INT >= 24) {
fileUri = FileProvider.getUriForFile(this, "com.zhy.android7.fileprovider", file);
} else {
fileUri = Uri.fromFile(file);
}
再跑一次,没想到还是抛出了异常(警告,没有Crash):
java.lang.SecurityException: Permission Denial:
opening provider android.support.v4.content.FileProvider
from ProcessRecord{18570a 27107:com.google.android.packageinstaller/u0a26} (pid=27107, uid=10026) that is not exported from UID 10004
可以看到是权限问题,对于权限我们刚说了一种方式为grantUriPermission,这种方式当然是没问题的啦~
加上后运行即可。
其实对于权限,还提供了一种方式,即:
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
我们可以在安装包之前加上上述代码,再次运行正常啦~
现在我有两个非常疑惑的问题:
问题1:为什么刚才拍照的时候,Android 7的设备并没有遇到Permission Denial的问题?
恩,之所以不需要权限,主要是因为Intent的action为ACTION_IMAGE_CAPTURE,当我们startActivity后,会辗转调用Instrumentation的execStartActivity方法,在该方法内部,会调用intent.migrateExtraStreamToClipData();方法。
该方法中包含:
if (MediaStore.ACTION_IMAGE_CAPTURE.equals(action)
|| MediaStore.ACTION_IMAGE_CAPTURE_SECURE.equals(action)
|| MediaStore.ACTION_VIDEO_CAPTURE.equals(action)) {
final Uri output;
try {
output = getParcelableExtra(MediaStore.EXTRA_OUTPUT);
} catch (ClassCastException e) {
return false;
}
if (output != null) {
setClipData(ClipData.newRawUri("", output));
addFlags(FLAG_GRANT_WRITE_URI_PERMISSION|FLAG_GRANT_READ_URI_PERMISSION);
return true;
}
}
可以看到将我们的EXTRA_OUTPUT,转为了setClipData,并直接给我们添加了WRITE和READ权限。
注:该部分逻辑应该是21之后添加的。
问题2:为什么刚才拍照案例的时候,Android 4.4设备遇到权限问题,不通过addFlags这种方式解决?
因为addFlags主要用于setData,setDataAndType以及setClipData(注意:4.4时,并没有将ACTION_IMAGE_CAPTURE转为setClipData实现)这种方式。
所以addFlags方式对于ACTION_IMAGE_CAPTURE在5.0以下是无效的,所以需要使用grantUriPermission,如果是正常的通过setData分享的uri,使用addFlags是没有问题的(可以写个简单的例子测试下,两个app交互,通过content://)。
五、总结下
终于将知识点都涵盖到了~
总结下,使用content://替代file://,主要需要FileProvider的支持,而因为FileProvider是ContentProvider的子类,所以需要在AndroidManifest.xml中注册;而又因为需要对真实的filepath进行映射,所以需要编写一个xml文档,用于描述可使用的文件夹目录,以及通过name去映射该文件夹目录。
对于权限,有两种方式:
方式一为Intent.addFlags,该方式主要用于针对intent.setData,setDataAndType以及setClipData相关方式传递uri的。
方式二为grantUriPermission来进行授权
相比来说方式二较为麻烦,因为需要指定目标应用包名,很多时候并不清楚,所以需要通过PackageManager进行查找到所有匹配的应用,全部进行授权。不过更为稳妥~
方式一较为简单,对于intent.setData,setDataAndType正常使用即可,但是对于setClipData,由于5.0前后Intent#migrateExtraStreamToClipData,代码发生变化,需要注意~
好了,看到现在是不是觉得适配7.0挺麻烦的,其实一点都不麻烦,下面给大家总结一种快速适配的方式。
六、快速完成适配
(1)新建一个module
创建一个library的module,在其AndroidManifest.xml中完成FileProvider的注册,代码编写为:
<application>
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="${applicationId}.android7.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
注意一点,android:authorities不要写死,因为该library最终可能会让多个项目引用,而android:authorities是不可以重复的,如果两个app中定义了相同的,则后者无法安装到手机中(authority conflict)。
同样的的编写file_paths~
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<root-path
name="root"
path="" />
<files-path
name="files"
path="" />
<cache-path
name="cache"
path="" />
<external-path
name="external"
path="" />
<external-files-path
name="external_file_path"
path="" />
<external-cache-path
name="external_cache_path"
path="" />
</paths>
最后再编写一个辅助类,例如:
public class FileProvider7 {
public static Uri getUriForFile(Context context, File file) {
Uri fileUri = null;
if (Build.VERSION.SDK_INT >= 24) {
fileUri = getUriForFile24(context, file);
} else {
fileUri = Uri.fromFile(file);
}
return fileUri;
}
public static Uri getUriForFile24(Context context, File file) {
Uri fileUri = android.support.v4.content.FileProvider.getUriForFile(context,
context.getPackageName() + ".android7.fileprovider",
file);
return fileUri;
}
public static void setIntentDataAndType(Context context,
Intent intent,
String type,
File file,
boolean writeAble) {
if (Build.VERSION.SDK_INT >= 24) {
intent.setDataAndType(getUriForFile(context, file), type);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
if (writeAble) {
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
} else {
intent.setDataAndType(Uri.fromFile(file), type);
}
}
}
可以根据自己的需求添加方法。
好了,这样我们的一个小库就写好了~~
(2)使用
如果哪个项目需要适配7.0,那么只需要这样引用这个库,然后只需要改动一行代码即可完成适配啦,例如:
拍照
public void takePhotoNoCompress(View view) {
Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA)
.format(new Date()) + ".png";
File file = new File(Environment.getExternalStorageDirectory(), filename);
mCurrentPhotoPath = file.getAbsolutePath();
Uri fileUri = FileProvider7.getUriForFile(this, file);
takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri);
startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO);
}
}
只需要改动
Uri fileUri = FileProvider7.getUriForFile(this, file);即可。
安装apk
同样的修改setDataAndType为:
FileProvider7.setIntentDataAndType(this,
intent, "application/vnd.android.package-archive", file, true);
即可。
ok,繁琐的重复性操作终于简化为一行代码啦~
源码地址:
https://github.com/hongyangAndroid/FitAndroid7