Android代碼安裝卸載apk 處理6.0權限/7.0Uri/8.0安裝未知來源應用適配問題

前言

在自己的APP裏面通過代碼手動安裝第三方APP或者進行版本更新的時候,會碰到多個版本之間的差異帶來的一些適配問題,比如6.0版本開始的運行時權限,7.0開始的文件共享機制,8.0修改後的安裝未知來源應用權限等問題,今天通過這篇文章記錄下適配過程

Android 6.0以下

在AndroidManifest.xml中申請權限

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"></uses-permission>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"></uses-permission>
  • 第一步將apk文件拷貝到外置內存卡,不要將文件拷貝到內置存儲,否則解析安裝包會出現錯誤
    private void moveApk(String apkName,String path){
    
            File file = new File(path);
    
            if (file.exists()) {
                file.delete();
                moveApk(apkName,path);
            } else {
                try {
                    file.createNewFile();
                    BufferedInputStream bis = new BufferedInputStream(getAssets().open(apkName));
                    FileOutputStream fos = new FileOutputStream(file);
                    int len;
                    byte[] buff = new byte[1024*6];
                    while ((len = bis.read(buff)) != -1) {
                        fos.write(buff,0,len);
                    }
                    fos.flush();
                    bis.close();
                    fos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    
  • 直接使用Intent跳轉到安裝界面進行安裝
    
    File apkFile = new File(Environment.getExternalStorageDirectory(),"app.apk");
    
    private boolean detectAPP(String packageName){
        List<String> app = new ArrayList<>();
        List<PackageInfo> installedPackages = getPackageManager().getInstalledPackages(0);
        for (int i=0; i<installedPackages.size(); i++) {
            app.add(installedPackages.get(i).packageName.toLowerCase());
        }
        return app.contains(packageName.toLowerCase());
    }
    
    public void installApk(){
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        intent.setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive");
        startActivity(intent);
    }
    
    private void unInstall(String packageName){
        Intent intent = new Intent(Intent.ACTION_DELETE);
        intent.setData(Uri.parse("package:"+packageName));
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        startActivity(intent);
    }
    

Android 6.0

從Android6.0開始,對於某些權限是需要在應用運行時向用戶進行申請的(同時也需要在manifest文件配置),用戶可以選擇拒絕或者同意;如果拒絕了,用戶也可以從設置裏的應用詳情界面重新選擇權限

動態申請寫內存卡的權限

String[] permissions = new String[]{
                        Manifest.permission.READ_EXTERNAL_STORAGE,
                        Manifest.permission.WRITE_EXTERNAL_STORAGE
                };

if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) {
    if (ActivityCompat.checkSelfPermission(this, permissions[0]) == PackageManager.PERMISSION_GRANTED &&
            ActivityCompat.checkSelfPermission(this, permissions[1]) == PackageManager.PERMISSION_GRANTED) {
        installApk();
    } else {
        ActivityCompat.requestPermissions(this, permissions, 11);
    }
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    switch (requestCode) {
        case 11:
            if (grantResults[0] == PackageManager.PERMISSION_GRANTED && grantResults[1] == PackageManager.PERMISSION_GRANTED) {
                installApk();
            }
            break;
    }
}

Android 7.0

當你繼續像上面這樣調用時,會拋出異常

android.os.FileUriExposedException

原因如下:

對於面向Android7.0的應用,Android 框架執行的 StrictMode API 政策禁止在您的應用對外部公開 file:// URI。
如果一項包含文件 URI 的 intent 離開您的應用,則應用出現故障,並出現 FileUriExposedException 異常。
比如通過一個包含uri的Intent發送出去調用系統相機,調用系統安裝程序等。

解決方案是:
要在應用間共享文件,您應發送一項 content:// URI,並授予 URI 臨時訪問權限。進行此授權的最簡單方式是使用 FileProvider 類
可參考https://developer.android.com/reference/android/support/v4/content/FileProvider.html

FileProvider 實際上是 ContentProvider 的一個子類,它的作用也比較明顯,file://Uri 不給用,那麼換個 Uri 爲 content:// 來替代

註冊FileProvider

  1. 在AndroidManifest裏註冊FileProvider:爲什麼需要這麼做呢?因爲它是ContentProvider的子類
    <provider
        android:name="android.support.v4.content.FileProvider"
        android:authorities="com.mango.install.fileprovider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/filepaths" />
    </provider>
    

這裏有兩個地方需要注意:

  • authorities屬性裏的值是【包名.fileprovider】這種格式,是用來映射該ContentProvider
  • 在meta-data標籤裏的resource裏配置一個xml文件,因爲FileProvider使用content://uri替代file://uri,也就是虛擬路徑替換絕對路徑,那content://的uri如何定義呢?
    所以這時候就需要通過xml文件進行配置,使用虛擬路徑對文件路徑進行映射,通過path以及xml節點確定可訪問的目錄,通過name屬性來映射真實的文件路徑path。

至於exported和grantUriPermissions的值爲什麼這麼設置,是由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);
    }

創建xml文件

<?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-files" path="" />
    <external-cache-path name="external-cache" path="" />
</paths>

paths節點內部支持以下幾個子節點:

  • 代表new File("/") 設備的根目錄 root/
  • 代表context.getFilesDir() 內部存儲應用私有目錄 data/data/包名/files
  • 代表context.getCacheDir() 內部存儲應用私有目錄 data/data/包名/cache
  • 代表Environment.getExternalStorageDirectory() 外部存儲根目錄 /storage/sdcard0
  • 代表context.getExternalFilesDir() 外部存儲應用私有目錄 /storage/sdcard0/Android/data/包名/files/
  • 代表context.getExternalCacheDir() 外部存儲應用私有緩存目錄 /storage/sdcard0/Android/data/包名/cache/

每個子節點都支持兩個屬性name和path,其中的path是子目錄名稱,比如代表的真實路徑是/storage/sdcard0/apk,path的值可以爲空
而這個name就會在content://uri中映射這個真實路徑

xml文件所在目錄如圖:
在這裏插入圖片描述

構建Uri

這樣構建Uri就可以這樣弄了:

File parent = Environment.getExternalStorageDirectory();
File apkFile = new File(parent,"app.apk");
Uri fileUri = FileProvider.getUriForFile(Context, "com.mango.install.fileprovider", apkFile);
  • 第二個參數就是在AndroidManifest配置provider的authorities標籤的值,FileProvider就是通過它找到確定的ContentProvider
  • 第三個參數就是apk文件了

此時的fileUri值如下

content://com.mango.install.fileprovider/external/app.apk
  • com.mango.install.fileprovider的值是authorities標籤對應的
  • external就是xml文件 的name值,它隱藏了真正的文件夾路徑storage/sdcard0/

注意:apkFile的路徑一定要跟external所表示的真實路徑一致,才能生成這樣的Uri;如果我們修改path值,這時候external所表示的真實路徑是storage/sdcard0/apk/,而apkFile的路徑還是storage/sdcard0/,這樣就從paths節點中匹配不到一致的路徑,生成的Uri如下

content://com.mango.install.fileprovide/root/storage/sdcard0/app.apk

至於這種情況能不能調用成功,大家可以去試一試

如果我們把apkFile路徑修改成跟節點的一致後,看看結果

String parentPath = Environment.getExternalStorageDirectory() + "/apk";
File parent = new File(parentPath);
if (!parent.exists()) {
    parent.mkdir();
}
File apkFile = new File(parent,"app.apk");
Uri uriForFile = FileProvider.getUriForFile(this, "com.mango.install.fileprovide", apkFile);

這時候fileUri值如下

content://com.mango.install.fileprovide/external/app.apk

可以看到只要文件真實路徑和xml節點中的路徑一致,生成的Uri格式都是一樣的,這樣就對外隱藏了文件真實路徑,以此提高數據安全性

Uri 授權

這時候你以爲搞定了,通過如下代碼去安裝APK

    public void install(){
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.setDataAndType(getUriForFile(),"application/vnd.android.package-archive");
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        startActivity(intent);
    }

    public Uri getUriForFile(){
        File apk = new File(apkPath);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            return FileProvider.getUriForFile(this,"com.mango.install.fileprovider",apk);
        } else {
            return Uri.fromFile(apk);
        }
    }

你會發現拋出異常了

Caused by: java.lang.SecurityException: Permission Denial: opening provider android.support.v4.content.FileProvider from ProcessRecord

還記得前面配置provider的時候有這樣一句話

android:exported="false"

exported這個屬性用於指示該服務能夠被其他應用程序組件調用或跟它交互。如果爲true則表示能夠被調用或交互,否則不能。設置爲false時,只有同一個應用程序的組件或者帶有相同用戶ID的應用程序才能啓動或綁定該服務;false表明不能跨進程使用,也就是隻能在應用程序內部使用,不能跨APP使用,否則會報錯java.lang.SecurityException: Permission Denial

那麼要想我們的這個Uri能夠被對方APP接受,,就需要對接受這個Uri的APP進行授權

授權有兩種方法:

  • 通過 Context 的 grantUriPermission() 方法授權

    這個方法grantUriPermission(String toPackage, Uri uri, int modeFlags)接受的第一個參數是對方的應用包名,就是你給哪個應用授權,但是有時候你並不知道對方包名多少,所以可以這樣做

        public void grantPermission(Intent intent,Uri uri){
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return;
            List<ResolveInfo> resInfoList = getPackageManager()
                    .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
            for (ResolveInfo resolveInfo : resInfoList) {
                String packageName = resolveInfo.activityInfo.packageName;
                grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION
                        | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
            }
        }
    

    根據 Intent 查詢出所有符合的應用,都給他們授權;同時還有另外一個方法【revokeUriPermission】,意思是當你覺得不需要的時候可以通過該方法對那些已授權的應用進行移除權限,更加靈活的保證數據安全性

  • 通過Intent.setFlags() 或者 Intent.addFlag 的方式授權

    這種方法使用很簡單

    intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    

    使用這種形式的授權,權限截止於目標 App 所處的堆棧被銷燬。也就是說,一旦授權,直到該 App 被完全退出這段時間內,該 App 享有對此 Uri 指向的文件的對應權限,我們無法主動收回該權限了。

Android 8.0

在Android8.0之前的系統中,用戶要從除官方應用商店之外的來源安裝App時,需要打開系統設置當中的”允許未知來源”安裝應用程序的選項,在最新的Android O當中谷歌已經刪除了該永久授權的選項,從系統設置當中已經找不到該開關。谷歌將永久授權修改爲每次的單獨授權,當用戶每次安裝第三方來源的android軟件時需要對軟件權限進行手動確認

所以如果不處理好安裝未知來源的適配權限。可能會導致應用無法升級,只能卸載後重新下載新版本。

  • 第一步:.在AndroidManifest.xml中增加請求安裝的權限

    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"></uses-permission>
    
  • 第二步:在安裝apk之前進行判斷是否已經打開了該權限 ,使用到新增加的API canRequestPackageInstalls() 判斷是否已經打開權限,如果沒有打開權限就引導用戶跳轉到相應的頁面去手動打開,因爲這個權限不是運行時權限,不能在代碼中請求打開。如果用戶已經打開了該權限,那麼就直接安裝apk

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
         if (getPackageManager().canRequestPackageInstalls()) {
             install();
         } else {
             //跳轉到應用授權安裝第三方應用權限
             Uri packageURI = Uri.parse("package:"+mContext.getPackageName());
    		 Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,packageURI);
    		 startActivityForResult(intent, REQUEST_UNKNOW_PERMISSION);
         }
     } else {
         install();
     }
    
  • 第三步:在權限成功打開返回後,在onActivityResult中做處理,安裝apk

    if (resultCode == RESULT_OK && requestCode == INSTALL_PERMISS_CODE) {
                install();
     }
    

總結

可以說做Android開發是真的累,碎片化太嚴重了,區區一個安裝功能就要考慮這麼多版本的差異,實在是毫無人性啊

所有代碼在ApkInstall

附調用相機拍照

不光是通過攜帶Uri的調用安裝程序的Intent需要通過FileProvider處理,其它的也需要,比如調用相機拍照

    public void takePhtot(){
        Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        img = new File(Environment.getExternalStorageDirectory(),"img.jpg");
        Uri uri = FileProvider.getUriForFile(this,"com.mango.install.fileprovider",img);
        intent.putExtra(MediaStore.EXTRA_OUTPUT,uri);
        startActivityForResult(intent,REQUEST_TAKE_PHOTO);
    }

不知道你有沒有發現,這裏是沒有通過grantUriPermission方法或者addFlags進行授權處理就能直接調用相機拍照

這是因爲這裏的Intent使用的是MediaStore.ACTION_IMAGE_CAPTURE,最終系統會爲這種Intent自動添加FLAG_GRANT_WRITE_URI_PERMISSION|FLAG_GRANT_READ_URI_PERMISSION權限;還有MediaStore.ACTION_IMAGE_CAPTURE_SECURE和MediaStore.ACTION_VIDEO_CAPTURE這兩種Intent也是這種效果

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章