Android 7.0 & 8.0 升級兼容

一、7.0 問題記錄

參考

1. 安裝APK報錯,F(xiàn)ileUriExposedException

2. 調(diào)取系統(tǒng)相機崩潰,F(xiàn)ileUriExposedException

二、8.0 問題記錄

參考

1. 未知來源應用安裝權限

2. 針對通知的限制

3. 運行時權限策略變化

4. 針對頂級彈窗的限制

三、7.0 修改記錄

版本: 24

環(huán)境:三星S7-API24

1. Uri.paurse(file) 無法進行外部應用調(diào)用

已發(fā)現(xiàn)的場景

  • 拍照
  • 應用內(nèi)升級
  • 打開/分享文件

錯誤日志

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 框架執(zhí)行的 StrictMode API 政策禁止在您的應用外部公開 file://URI。如果一項包含文件 URI 的 intent 離開您的應用,則應用出現(xiàn)故障,并出現(xiàn) FileUriExposedException 異常。

要在應用間共享文件,您應發(fā)送一項 content:// URI,并授予 URI 臨時訪問權限。進行此授權的最簡單方式是使用 FileProvider 類。如需了解有關權限和共享文件的詳細信息,請參閱共享文件。

通俗點就是Android 7.0不允許intent帶有“file://”地址的URI離開自身的應用了,要不然會拋出FileUriExposedException,想要在自己應用和其他應用之間共享File數(shù)據(jù),只能使用“content://”的地址。

處理方法

  • 第一步: 在app下面的AndroidManifest.xml添加如下內(nèi)容:
    <!--用來7.0以上手機的文件選擇器的跳轉(zhuǎn)-->
    <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">

    <!--系統(tǒng)相機使用的path-->
    <!--圖片文件 - 外部存儲地址-->
    <external-path
        name="moa_out_pic_path"
        path="Android/data/com.sangfor.pocket/cache/"/>
    <!--圖片文件 - 內(nèi)部存儲地址-->
    <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的參數(shù)
    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);//告知系統(tǒng)相冊,照片存儲在那里
        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://”開頭的鏈接地址轉(zhuǎn)換成“content://”的地址,這個方法的第二個參數(shù)可以不是包名,但是必需要與AndroidManifest.xml中的authorities字段的值相同。

另外,FLAG_GRANT_READ_URI_PERMISSION 和 FLAG_GRANT_WRITE_URI_PERMISSION兩個標簽用來賦予臨時的訪問權限。

2. 下載文件時,進度有時會顯示負數(shù)

原因分析

Response 中沒有 Content-Length

解決方法

當發(fā)現(xiàn)進度為負數(shù)時,不顯示進度。

四、8.0 修改記錄

版本: 24

環(huán)境:三星S7-API24

1. 頂級彈窗 TYPE_SYSTEM_ALERT 報錯

出現(xiàn)場景

所有使用了頂級彈窗的地方,窗口無法彈出。

錯誤日志

在 Android 8.0 的手機上,系統(tǒng)級彈窗會出現(xiàn)下面提示:

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

原因分析

引用官方描述:

這些行為變更專門應用于針對 O 平臺或更高平臺版本的應用。針對 Android8.0 或更高平臺版本進行編譯,或?qū)?targetSdkVersion設為 Android 8.0 或更高版本的應用開發(fā)者必須修改其應用以正確支持這些行為(如果適用)。

提醒窗口使用SYSTEM_ALERT_WINDOW 權限的應用無法再使用以下窗口類型來在其他應用和系統(tǒng)窗口上方顯示提醒窗口:
    ? TYPE_PHONE
    ? TYPE_PRIORITY_PHONE
    ? TYPE_SYSTEM_ALERT
    ? TYPE_SYSTEM_OVERLAY
    ? TYPE_SYSTEM_ERROR
    
相反,應用必須使用名為 TYPE_APPLICATION_OVERLAY 的新窗口類型。
    
使用TYPE_APPLICATION_OVERLAY 窗口類型顯示應用的提醒窗口時,請記住新窗口類型的以下特性:
    ? 應用的提醒窗口始終顯示在狀態(tài)欄和輸入法等關鍵系統(tǒng)窗口的下面。
    ? 系統(tǒng)可以移動使用 TYPE_APPLICATION_OVERLAY窗口類型的窗口或調(diào)整其大小,以改善屏幕顯示效果。
    ? 通過打開通知欄,用戶可以訪問設置來阻止應用顯示使用 TYPE_APPLICATION_OVERLAY 窗口類型顯示的提醒窗口。

上面的錯誤,就是由于在 Android 8.0 的手機上使用了TYPE_SYSTEM_OVERLAY這類權限(在源碼中已經(jīng)標記了Deprecated)。

解決辦法

在所有使用到頂級彈窗的地方添加如下判斷:

    /**
     * 檢查是否能設置系統(tǒng)級彈窗,如果能,則為 Dialog 設置系統(tǒng)級彈窗屬性
     *
     * @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 沒有權限的情況下,如果設置系統(tǒng)級彈窗,會導致崩潰
                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

出現(xiàn)場景

在使用 255 以上的 requestCode 去申請權限的時候,App崩潰。

錯誤日志

在申請權限的時候,填寫的 requestCode 比較大,會報如下錯誤:

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

原因分析
在 Android 6.0 以上的系統(tǒng)上,requestPermissions() 方法的 requestCode 的個數(shù)只能在0-255之間,最終調(diào)用的 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. 無法發(fā)送通知沒有反應

出現(xiàn)場景

所有使用了 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)

注意: 該段錯誤日志是打印在系統(tǒng)日志里面,所以 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開始,所有的通知都必須被指定一個渠道,每個渠道可以設置不同的行為,這些行為作用于所有通過該渠道發(fā)送的通知。

解決辦法

  • 方案一: 使用下面方法統(tǒng)一替換全部使用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();
        }
    }
  • 方案二: 定義不同渠道(主要區(qū)分渠道行為),分別調(diào)用
    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. 無法獲取權限組內(nèi)其它權限

出現(xiàn)場景

申請了 READ_EXTERNAL_STORAGE ,但是沒有 WRITE_EXTERNAL_STORAGE 的權限,出現(xiàn)權限異常。(很奇怪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 之前,如果應用在運行時請求權限并且被授予該權限,系統(tǒng)會錯誤地將屬于同一權限組并且在清單中注冊的其他權限也一起授予應用。

對于針對 Android 8.0 的應用,此行為已被糾正。系統(tǒng)只會授予應用明確請求的權限。然而,一旦用戶為應用授予某個權限,則所有后續(xù)對該權限組中權限的請求都將被自動批準。

例如,假設某個應用在其清單中列出 READ_EXTERNAL_STORAGE 和 WRITE_EXTERNAL_STORAGE。應用請求 READ_EXTERNAL_STORAGE,并且用戶授予了該權限。如果該應用針對的是 API 級別 24 或更低級別,系統(tǒng)還會同時授予 WRITE_EXTERNAL_STORAGE,因為該權限也屬于同一 STORAGE 權限組并且也在清單中注冊過。如果該應用針對的是 Android 8.0,則系統(tǒng)此時僅會授予 READ_EXTERNAL_STORAGE;不過,如果該應用后來又請求 WRITE_EXTERNAL_STORAGE,則系統(tǒng)會立即授予該權限,而不會提示用戶。

通俗的來說,就是在 API24(7.0) 以前申請權限 (包括24) ,會將整個權限組的權限都給你,API24 以上,只有你申請了的權限會給你。比如上面提到的 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 兩個權限。

解決方案

  • 方案一:只申請WRITE_EXTERNAL_STORAGE,就可以同時進行讀寫。(這里好像與文檔說的不一樣,給了寫,就給了讀,囧...)其它地方,比如 PHONE 組的相關權限,需要自己驗證那些需要主動申請。
  • 方案二(推薦): 嚴格按照文檔來,同時申請該組類你需要的所有權限。

五、其他

上面的部分是通過修改固定代碼就可以完成的,下面的一些兼容,根據(jù)各個App的不同情況,修改方式千差萬別,這里只做說明和基本的解決思路。

1. 時間收割機: 8.0 針對廣播的限制

先看官方描述:

如果應用注冊為接收廣播,則在每次發(fā)送廣播時,應用的接收器都會消耗資源。 如果多個應用注冊為接收基于系統(tǒng)事件的廣播,這會引發(fā)問題;觸發(fā)廣播的系統(tǒng)事件會導致所有應用快速地連續(xù)消耗資源,從而降低用戶體驗。

為了緩解這一問題,Android 7.0(API 級別 25)對廣播施加了一些限制,如后臺優(yōu)化中所述。

Android 8.0 讓這些限制更為嚴格。

 - 針對 Android 8.0 的應用無法繼續(xù)在其清單中為隱式廣播注冊廣播接收器。 隱式廣播是一種不專門針對該應用的廣播。 例如,ACTION_PACKAGE_REPLACED 就是一種隱式廣播,因為它將發(fā)送到注冊的所有偵聽器,讓后者知道設備上的某些軟件包已被替換。

 - 不過,ACTION_MY_PACKAGE_REPLACED 不是隱式廣播,因為不管已為該廣播注冊偵聽器的其他應用有多少,它都會只發(fā)送到軟件包已被替換的應用。

 - 應用可以繼續(xù)在它們的清單中注冊顯式廣播。

 - 應用可以在運行時使用 Context.registerReceiver() 為任意廣播(不管是隱式還是顯式)注冊接收器。

 - 需要簽名權限的廣播不受此限制所限,因為這些廣播只會發(fā)送到使用相同證書簽名的應用,而不是發(fā)送到設備上的所有應用。
 

這段話的精髓就是:所有在 AndroidManifest.xml 里面注冊的隱式廣播,凡是沒有在 App 中顯示注冊的,基本上全部沒辦法用了(即使有些還有能用,以后也會沒用的)

這導致的超級 操蛋 的問題,就是引用的 第三方包 中需要隱式注入的廣播,基本上都得換掉,包括項目中的百度、阿里等大廠的廣播相繼撲街,直接表現(xiàn)在日志上就如下:

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 之后,會不會導致其它更慘烈的問題,還需要邊查邊改,工作量能夠想到有多大。所以,革命尚未成功,加班還得繼續(xù)。

推薦方案:
老老實實檢查每一個廣播!

2. 需求又苦惱了:8.0 針對定位的限制

先看看官方描述:

為降低功耗,無論應用的目標 SDK 版本為何,Android 8.0 都會對后臺應用檢索用戶當前位置的頻率進行限制。

如果您的應用在后臺運行時依賴實時提醒或運動檢測,這一位置檢索行為就顯得特別重要,必須緊記。

重要說明:作為起點,我們只允許后臺應用每小時接收幾次位置更新。我們將在整個預覽版階段繼續(xù)根據(jù)系統(tǒng)影響和開發(fā)者的反饋優(yōu)化位置更新間隔。

系統(tǒng)會對前臺應用和后臺應用進行區(qū)分。應用滿足以下任一條件即視為前臺應用:

 - 它具有可見的 Activity,無論 Activity 處于啟動還是暫停狀態(tài)。
 - 它具有前臺服務。
 - 另一個前臺應用通過綁定到應用的其中一個服務或使用應用的其中一個內(nèi)容提供程序與應用相連。
 
如果以上所有條件均不滿足,應用即視為后臺應用。

這段的精髓就是:如果你的 App 被切換到后臺了,如果你不在桌面添加個懸浮窗,也不在通知欄顯示你的 App 還在運行,那么你的 App 就會被限制訪問手機的定位。(并且這個限制,不關注你 App 支持到了什么版本,只要用戶用的系統(tǒng)是 8.0 的,你在后臺訪問位置的服務全部得撲街?。?/strong>

限制居然是每小時只能接收幾次位置更新,想著當初為了 App ?;?/strong> 做的艱苦奮戰(zhàn),一波回到解放前,心中 10000+只草泥馬 飄過~~~

由于一些 App 的特殊性,比如 地圖、簽到、外勤類的App ,需要在用戶切換到后臺后,還能實時的獲取用戶位置,需求會要求盡量讓 App 少 對用戶的其它行為造成影響( 并不是為了侵占隱私,也有的是為了員工切身利益,比如上班自動打卡 )?,F(xiàn)在這些操作,如果沒有開啟前臺服務,將變得非常困難。

推薦方案:
注冊一個前臺服務,顯示在通知欄上。

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容