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也是这种效果

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