美文网首页优化
Android 7.0 & 8.0 升级兼容

Android 7.0 & 8.0 升级兼容

作者: ICodeMan | 来源:发表于2018-12-13 21:20 被阅读0次

    一、7.0 问题记录

    参考

    1. 安装APK报错,FileUriExposedException

    2. 调取系统相机崩溃,FileUriExposedException

    二、8.0 问题记录

    参考

    1. 未知来源应用安装权限

    2. 针对通知的限制

    3. 运行时权限策略变化

    4. 针对顶级弹窗的限制

    三、7.0 修改记录

    版本: 24

    环境:三星S7-API24

    1. Uri.paurse(file) 无法进行外部应用调用

    已发现的场景

    • 拍照
    • 应用内升级
    • 打开/分享文件

    错误日志

    11-14 14:37:18.799 21548-21548/com.sangfor.pocket W/System.err: android.os.FileUriExposedException: file:///storage/emulated/0/Android/data/com.sangfor.pocket/cache/20181114_143718.jpg exposed beyond app through ClipData.Item.getUri()
    

    原因分析

    引用官方描述:

    对于面向 Android 7.0 的应用,Android 框架执行的 StrictMode API 政策禁止在您的应用外部公开 file://URI。如果一项包含文件 URI 的 intent 离开您的应用,则应用出现故障,并出现 FileUriExposedException 异常。
    
    要在应用间共享文件,您应发送一项 content:// URI,并授予 URI 临时访问权限。进行此授权的最简单方式是使用 FileProvider 类。如需了解有关权限和共享文件的详细信息,请参阅共享文件。
    

    通俗点就是Android 7.0不允许intent带有“file://”地址的URI离开自身的应用了,要不然会抛出FileUriExposedException,想要在自己应用和其他应用之间共享File数据,只能使用“content://”的地址。

    处理方法

    • 第一步: 在app下面的AndroidManifest.xml添加如下内容:
        <!--用来7.0以上手机的文件选择器的跳转-->
        <provider
            android:name="android.support.v4.content.FileProvider"
            android:authorities="com.sangfor.pocket"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>
    
    • 第二步: 在res/xml下添加文件 file_paths.xml :
    <paths xmlns:android="http://schemas.android.com/apk/res/android">
    
        <!--系统相机使用的path-->
        <!--图片文件 - 外部存储地址-->
        <external-path
            name="moa_out_pic_path"
            path="Android/data/com.sangfor.pocket/cache/"/>
        <!--图片文件 - 内部存储地址-->
        <files-path
            name="moa_in_pic_path"
            path=""/>
    
        <!--分享/打开文件使用到的path-->
        <!-- snagfor 附件文件 - 外部存储地址 -->
        <external-path
            name="moa_sangfor_attachment_path"
            path="sangfor/attachment/"/>
        <!-- snagfor_office 附件文件 - 外部存储地址 -->
        <external-path
            name="moa_sangfor_office_attachment_path"
            path="sangfor_office/attachment/"/>
    
    
        <!--自动更新使用到的path-->
        <!-- snagfor apk文件 - 外部存储地址 -->
        <external-path
            name="moa_sangfor_apk_path"
            path="sangfor/apk/"/>
        <!-- snagfor_office apk文件 - 外部存储地址 -->
        <external-path
            name="moa_sangfor_office_apk_path"
            path="sangfor_office/apk/"/>
    </paths>
    

    各个标签对应的路径如下表:

    tag path
    external-path Environment.getExternalStorageDirectory()
    files-path /data/user/0/com.xx.xx/files/
    cache-path /data/user/0/com.xx.xx/cache/
    external-files-path Context.getExternalFilesDir(null);
    /storage/emulated/0/Android/data/com.hm.camerademo/files/
    external-cache-path Context.getExternalCacheDir();
    /storage/emulated/0/Android/data/com.hm.camerademo/cache/
    external-media-path Context.getExternalMediaDirs()
    /storage/emulated/0/Android/data/com.hm.camerademo/median/
    • 第三步: 修改Intent的参数
        public static Intent getBaseCaptureIntent(Context context, File file){
            Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
            Uri contentUri = getFileUri(context, file);
            takePictureIntent
                    .putExtra(MediaStore.EXTRA_OUTPUT, contentUri);//告知系统相册,照片存储在那里
            takePictureIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
            takePictureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
            return takePictureIntent;
        }
        
        public static Uri getFileUri(Context context,File file){
            if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
                return FileProvider.getUriForFile(context, "com.sangfor.pocket", file);
            }else {
                return Uri.fromFile(file);
            }
        }
    

    其中 FileProvider.getUriForFile(context, "com.sangfor.pocket", file) 的目的,是将“file://”开头的链接地址转换成“content://”的地址,这个方法的第二个参数可以不是包名,但是必需要与AndroidManifest.xml中的authorities字段的值相同。

    另外,FLAG_GRANT_READ_URI_PERMISSION 和 FLAG_GRANT_WRITE_URI_PERMISSION两个标签用来赋予临时的访问权限。

    2. 下载文件时,进度有时会显示负数

    原因分析

    Response 中没有 Content-Length

    解决方法

    当发现进度为负数时,不显示进度。

    四、8.0 修改记录

    版本: 24

    环境:三星S7-API24

    1. 顶级弹窗 TYPE_SYSTEM_ALERT 报错

    出现场景

    所有使用了顶级弹窗的地方,窗口无法弹出。

    错误日志

    在 Android 8.0 的手机上,系统级弹窗会出现下面提示:

    android.view.WindowManager$BadTokenException: Unable to add window android.view.ViewRootImpl$W@45f97c5 -- permission denied for window type 2003
    

    原因分析

    引用官方描述:

    这些行为变更专门应用于针对 O 平台或更高平台版本的应用。针对 Android8.0 或更高平台版本进行编译,或将 targetSdkVersion设为 Android 8.0 或更高版本的应用开发者必须修改其应用以正确支持这些行为(如果适用)。
    
    提醒窗口使用SYSTEM_ALERT_WINDOW 权限的应用无法再使用以下窗口类型来在其他应用和系统窗口上方显示提醒窗口:
        • TYPE_PHONE
        • TYPE_PRIORITY_PHONE
        • TYPE_SYSTEM_ALERT
        • TYPE_SYSTEM_OVERLAY
        • TYPE_SYSTEM_ERROR
        
    相反,应用必须使用名为 TYPE_APPLICATION_OVERLAY 的新窗口类型。
        
    使用TYPE_APPLICATION_OVERLAY 窗口类型显示应用的提醒窗口时,请记住新窗口类型的以下特性:
        • 应用的提醒窗口始终显示在状态栏和输入法等关键系统窗口的下面。
        • 系统可以移动使用 TYPE_APPLICATION_OVERLAY窗口类型的窗口或调整其大小,以改善屏幕显示效果。
        • 通过打开通知栏,用户可以访问设置来阻止应用显示使用 TYPE_APPLICATION_OVERLAY 窗口类型显示的提醒窗口。
    

    上面的错误,就是由于在 Android 8.0 的手机上使用了TYPE_SYSTEM_OVERLAY这类权限(在源码中已经标记了Deprecated)。

    解决办法

    在所有使用到顶级弹窗的地方添加如下判断:

        /**
         * 检查是否能设置系统级弹窗,如果能,则为 Dialog 设置系统级弹窗属性
         *
         * @param context
         * @param dialog
         */
        public static void checkAndRequestSystemDialogConfig(Context context, Dialog dialog){
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                if (!Settings.canDrawOverlays(context)) {
                    // >= 6.0 没有权限的情况下,如果设置系统级弹窗,会导致崩溃
                    Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context.getPackageName()));
                    context.startActivity(intent);
                } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    // >= 8.0 顶层弹窗
                    dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
                } else {
                    // >= 6.0 && <8.0  顶层弹窗
                    dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
                }
            } else {
                // < 6.0 在 AndroidManifest.xml 中申请权限
                dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
            }
        }
    

    2. 申请权限的 requestCode 不能大于255

    出现场景

    在使用 255 以上的 requestCode 去申请权限的时候,App崩溃。

    错误日志

    在申请权限的时候,填写的 requestCode 比较大,会报如下错误:

    java.lang.IllegalArgumentException: Can only use lower 8 bits for requestCode
    

    原因分析
    在 Android 6.0 以上的系统上,requestPermissions() 方法的 requestCode 的个数只能在0-255之间,最终调用的 validateRequestPermissionsRequestCode() 具体源码如下:

    FragmentActivity.java
    
        @Override
        public final void validateRequestPermissionsRequestCode(int requestCode) {
            // We use 8 bits of the request code to encode the fragment id when
            // requesting permissions from a fragment. Hence, requestPermissions()
            // should validate the code against that but we cannot override it as
            // we can not then call super and also the ActivityCompat would call
            // back to this override. To handle this we use dependency inversion
            // where we are the validator of request codes when requesting
            // permissions in ActivityCompat.
            if (mRequestedPermissionsFromFragment) {
                mRequestedPermissionsFromFragment = false;
            } else if ((requestCode & 0xffffff00) != 0) {
                throw new IllegalArgumentException("Can only use lower 8 bits for requestCode");
            }
        }
    

    解决办法

    requestCode0-255 中取值。

    3. 无法发送通知没有反应

    出现场景

    所有使用了 Notification 的地方,通知全部无效

    错误日志

    E/NotificationService: No Channel found for pkg=com.icodeman.demo.testdemo, channelId=null, id=1, tag=null, opPkg=com.icodeman.demo.testdemo, callingUid=10206, userId=0, incomingUserId=0, notificationUid=10206, notification=Notification(channel=null pri=0 contentView=com.icodeman.demo.testdemo/0x7f090027 vibrate=null sound=null defaults=0x0 flags=0x20 color=0x00000000 vis=PRIVATE)
    

    注意: 该段错误日志是打印在系统日志里面,所以 Android StudioLogCat 要看到这段日志,需要选择 "No Filters"

    原因分析

    引用官方描述:

    Starting in Android 8.0 (API level 26), all notifications must be assigned to a channel. For each channel, you can set the visual and auditory behavior that is applied to all notifications in that channel. Then, users can change these settings and decide which notification channels from your app should be intrusive or visible at all.
    

    大白话就是,从8.0开始,所有的通知都必须被指定一个渠道,每个渠道可以设置不同的行为,这些行为作用于所有通过该渠道发送的通知。

    解决办法

    • 方案一: 使用下面方法统一替换全部使用notificationManager.notify()的地方
        public void notify(int id, Notification notification) {
            Notification.Builder builder = Notification.Builder.recoverBuilder(this,notification);
            final String CHANNEL_ID = "channel_icm";
            // Create the NotificationChannel, but only on API 26+ because
            // the NotificationChannel class is new and not in the support library
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                if(notificationManager.getNotificationChannel(CHANNEL_ID) != null) {
                    int importance = NotificationManager.IMPORTANCE_DEFAULT;
                    NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "moa_notify_icm", importance);
                    channel.setDescription("This is default notification from icm.");
                    // Register the channel with the system; you can't change the importance
                    // or other notification behaviors after this
                    notificationManager.createNotificationChannel(channel);
                }
                builder.setChannelId(CHANNEL_ID);
            }
            try {
                notificationManager.notify(id, notification);
            } catch (Exception |Error ex) {
                ex.printStackTrace();
            }
        }
    
    • 方案二: 定义不同渠道(主要区分渠道行为),分别调用
        public void notify1(int id, Notification notification) {
            NotificationChannel channel = new NotificationChannel("channel_icm_1", "icm_notify_1", NotificationManager.IMPORTANCE_DEFAULT);
            //todo:此处可以设置该渠道的行为
            channel.setDescription("This is default notification from icm.");
            notify(id, channel, notification);
        }
    
        public void notify2(int id, Notification notification) {
            NotificationChannel channel = new NotificationChannel("channel_icm_2", "icm_notify_2", NotificationManager.IMPORTANCE_DEFAULT);
            //todo:此处可以设置该渠道的行为
            channel.setDescription("This is default notification from icm.");
            notify(id, channel, notification);
        }
    
        public void notify(int id, NotificationChannel channel, Notification notification) {
            Notification.Builder builder = Notification.Builder.recoverBuilder(this, notification);
            if (channel != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                // Create the NotificationChannel, but only on API 26+ because
                // the NotificationChannel class is new and not in the support library
                if (notificationManager.getNotificationChannel(channel.getId()) != null) {
                    notificationManager.createNotificationChannel(channel);
                }
                builder.setChannelId(channel.getId());
            }
            try {
                notificationManager.notify(id, notification);
            } catch (Exception | Error ex) {
                ex.printStackTrace();
            }
        }
    

    4. 无法获取权限组内其它权限

    出现场景

    申请了 READ_EXTERNAL_STORAGE ,但是没有 WRITE_EXTERNAL_STORAGE 的权限,出现权限异常。(很奇怪7.0版本是可以的,所以,额,Google工程师的Bug)

    错误日志

    12-13 15:49:31.977 30244-30244/com.icodeman.demo.testdemo W/System.err: java.io.IOException: Permission denied
    12-13 15:49:31.979 30244-30244/com.icodeman.demo.testdemo W/System.err:     at java.io.UnixFileSystem.createFileExclusively0(Native Method)
            at java.io.UnixFileSystem.createFileExclusively(UnixFileSystem.java:281)
    12-13 15:49:31.980 30244-30244/com.icodeman.demo.testdemo W/System.err:     at java.io.File.createNewFile(File.java:1000)
    12-13 15:49:31.981 30244-30244/com.icodeman.demo.testdemo W/System.err:     at com.icodeman.demo.testdemo.MainActivity.onClick(MainActivity.java:35)
            at android.view.View.performClick(View.java:6304)
    12-13 15:49:31.982 30244-30244/com.icodeman.demo.testdemo W/System.err:     at android.view.View$PerformClick.run(View.java:24803)
            at android.os.Handler.handleCallback(Handler.java:790)
    12-13 15:49:31.983 30244-30244/com.icodeman.demo.testdemo W/System.err:     at android.os.Handler.dispatchMessage(Handler.java:99)
            at android.os.Looper.loop(Looper.java:164)
    12-13 15:49:31.984 30244-30244/com.icodeman.demo.testdemo W/System.err:     at android.app.ActivityThread.main(ActivityThread.java:6650)
            at java.lang.reflect.Method.invoke(Native Method)
    12-13 15:49:31.985 30244-30244/com.icodeman.demo.testdemo W/System.err:     at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
            at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:818)
    

    原因分析

    引用官方描述:

    在 Android 8.0 之前,如果应用在运行时请求权限并且被授予该权限,系统会错误地将属于同一权限组并且在清单中注册的其他权限也一起授予应用。
    
    对于针对 Android 8.0 的应用,此行为已被纠正。系统只会授予应用明确请求的权限。然而,一旦用户为应用授予某个权限,则所有后续对该权限组中权限的请求都将被自动批准。
    
    例如,假设某个应用在其清单中列出 READ_EXTERNAL_STORAGE 和 WRITE_EXTERNAL_STORAGE。应用请求 READ_EXTERNAL_STORAGE,并且用户授予了该权限。如果该应用针对的是 API 级别 24 或更低级别,系统还会同时授予 WRITE_EXTERNAL_STORAGE,因为该权限也属于同一 STORAGE 权限组并且也在清单中注册过。如果该应用针对的是 Android 8.0,则系统此时仅会授予 READ_EXTERNAL_STORAGE;不过,如果该应用后来又请求 WRITE_EXTERNAL_STORAGE,则系统会立即授予该权限,而不会提示用户。
    

    通俗的来说,就是在 API24(7.0) 以前申请权限 (包括24) ,会将整个权限组的权限都给你,API24 以上,只有你申请了的权限会给你。比如上面提到的 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 两个权限。

    解决方案

    • 方案一:只申请WRITE_EXTERNAL_STORAGE,就可以同时进行读写。(这里好像与文档说的不一样,给了写,就给了读,囧...)其它地方,比如 PHONE 组的相关权限,需要自己验证那些需要主动申请。
    • 方案二(推荐): 严格按照文档来,同时申请该组类你需要的所有权限。

    五、其他

    上面的部分是通过修改固定代码就可以完成的,下面的一些兼容,根据各个App的不同情况,修改方式千差万别,这里只做说明和基本的解决思路。

    1. 时间收割机: 8.0 针对广播的限制

    先看官方描述:

    如果应用注册为接收广播,则在每次发送广播时,应用的接收器都会消耗资源。 如果多个应用注册为接收基于系统事件的广播,这会引发问题;触发广播的系统事件会导致所有应用快速地连续消耗资源,从而降低用户体验。
    
    为了缓解这一问题,Android 7.0(API 级别 25)对广播施加了一些限制,如后台优化中所述。
    
    Android 8.0 让这些限制更为严格。
    
     - 针对 Android 8.0 的应用无法继续在其清单中为隐式广播注册广播接收器。 隐式广播是一种不专门针对该应用的广播。 例如,ACTION_PACKAGE_REPLACED 就是一种隐式广播,因为它将发送到注册的所有侦听器,让后者知道设备上的某些软件包已被替换。
    
     - 不过,ACTION_MY_PACKAGE_REPLACED 不是隐式广播,因为不管已为该广播注册侦听器的其他应用有多少,它都会只发送到软件包已被替换的应用。
    
     - 应用可以继续在它们的清单中注册显式广播。
    
     - 应用可以在运行时使用 Context.registerReceiver() 为任意广播(不管是隐式还是显式)注册接收器。
    
     - 需要签名权限的广播不受此限制所限,因为这些广播只会发送到使用相同证书签名的应用,而不是发送到设备上的所有应用。
     
    

    这段话的精髓就是:所有在 AndroidManifest.xml 里面注册的隐式广播,凡是没有在 App 中显示注册的,基本上全部没办法用了(即使有些还有能用,以后也会没用的)

    这导致的超级 操蛋 的问题,就是引用的 第三方包 中需要隐式注入的广播,基本上都得换掉,包括项目中的百度、阿里等大厂的广播相继扑街,直接表现在日志上就如下:

    12-12 14:48:47.915 1381-1482/? W/BroadcastQueue: Background execution not allowed: receiving Intent { act=TAOBAO_DELAY_START_LOGIN flg=0x10 } to com.taobao.taobao/com.taobao.login4android.monitor.DelayLoginReceiver
    12-12 14:58:14.626 1381-1482/? W/BroadcastQueue: Background execution not allowed: receiving Intent { act=android.intent.action.USER_PRESENT flg=0x24200010 } to com.eg.android.AlipayGphone/com.alipay.pushsdk.BroadcastActionReceiver
    12-12 14:58:14.628 1381-1482/? W/BroadcastQueue: Background execution not allowed: receiving Intent { act=android.intent.action.USER_PRESENT flg=0x24200010 } to com.baidu.searchbox/com.baidu.android.pushservice.PushServiceReceiver
    12-12 14:58:14.628 1381-1482/? W/BroadcastQueue: Background execution not allowed: receiving Intent { act=android.intent.action.USER_PRESENT flg=0x24200010 } to com.icm.pocket/.appservice.AppServiceRebootReceiver
      Background execution not allowed: receiving Intent { act=android.intent.action.USER_PRESENT flg=0x24200010 } to com.icm.pocket/.appservice.CoreReceivers$BootReceiver
      Background execution not allowed: receiving Intent { act=android.intent.action.USER_PRESENT flg=0x24200010 } to com.icm.pocket/com.baidu.android.pushservice.PushServiceReceiver
      Background execution not allowed: receiving Intent { act=android.intent.action.USER_PRESENT flg=0x24200010 } to com.taobao.taobao/com.taobao.accs.EventReceiver
    

    由于不确定去掉一些 action 之后,会不会导致其它更惨烈的问题,还需要边查边改,工作量能够想到有多大。所以,革命尚未成功,加班还得继续。

    推荐方案:
    老老实实检查每一个广播!

    2. 需求又苦恼了:8.0 针对定位的限制

    先看看官方描述:

    为降低功耗,无论应用的目标 SDK 版本为何,Android 8.0 都会对后台应用检索用户当前位置的频率进行限制。
    
    如果您的应用在后台运行时依赖实时提醒或运动检测,这一位置检索行为就显得特别重要,必须紧记。
    
    重要说明:作为起点,我们只允许后台应用每小时接收几次位置更新。我们将在整个预览版阶段继续根据系统影响和开发者的反馈优化位置更新间隔。
    
    系统会对前台应用和后台应用进行区分。应用满足以下任一条件即视为前台应用:
    
     - 它具有可见的 Activity,无论 Activity 处于启动还是暂停状态。
     - 它具有前台服务。
     - 另一个前台应用通过绑定到应用的其中一个服务或使用应用的其中一个内容提供程序与应用相连。
     
    如果以上所有条件均不满足,应用即视为后台应用。
    

    这段的精髓就是:如果你的 App 被切换到后台了,如果你不在桌面添加个悬浮窗,也不在通知栏显示你的 App 还在运行,那么你的 App 就会被限制访问手机的定位。(并且这个限制,不关注你 App 支持到了什么版本,只要用户用的系统是 8.0 的,你在后台访问位置的服务全部得扑街!)

    限制居然是每小时只能接收几次位置更新,想着当初为了 App 保活 做的艰苦奋战,一波回到解放前,心中 10000+只草泥马 飘过~~~

    由于一些 App 的特殊性,比如 地图、签到、外勤类的App ,需要在用户切换到后台后,还能实时的获取用户位置,需求会要求尽量让 App 少 对用户的其它行为造成影响( 并不是为了侵占隐私,也有的是为了员工切身利益,比如上班自动打卡 )。现在这些操作,如果没有开启前台服务,将变得非常困难。

    推荐方案:
    注册一个前台服务,显示在通知栏上。

    相关文章

      网友评论

        本文标题:Android 7.0 & 8.0 升级兼容

        本文链接:https://www.haomeiwen.com/subject/tjxihqtx.html