Android下载APK自动安装(兼容7.0版本)

前言

在Android开发中,下载和安装APK是经常需要的,简单的项目升级,更新APP就需要我们实现APK文件的下载和安装,但是在Android 7.0之后,对于自动安装这块又有了一些新的限制。导致我们无法像之前版本那样直接通过Intent跳转一下就OK,下面详细说一下这块。

APK文件下载

对于APK文件的下载,我这里介绍两种方式

  • 系统自带的DownloadManager下载
  • 通过异步任务AsyncTask去实现下载

DownloadManager方式

DownloadManager是Android系统自带的提供下载功能类,通过查看DownloadManager的源码我们知道在这个类中主要是有两个静态内部类Request和Query。

public class DownloadManager {
    ...

    /**
     * Enqueue a new download.  
     * The download will start automatically once the download manager isready to execute it 
     * and connectivity is available.
     *
    */
    public long enqueue(Request request) {
        ...
    }

    /**
    * This class contains all the information necessary to request a new download. 
    * The URI is the only required parameter.
    *
    */
    public static class Request {
        ...
    }

    /**
    * This class may be used to filter download manager queries.
    *
    */
    public static class Query {
        ...
    }
}

通过注释内容很明白的看出Request是负责下载的类(Url作为唯一的参数),Query用于查询下载的信息(例如下载进度,文件大小等),enqueue()方法执行下载。

下载APK

获取DownloadManager对象

DownloadManager属于系统服务,我们需要通过getSystemService来获取他的对象:

DownloadManager downloadManager = (DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE);
下载

DownloadManager下载执行enqueue(Request request)方法,该方法接收一个Request对象,因此我们需要新建一个Request对象,该静态内部类的唯一构造方法接收一个Uri参数,即下载地址;下面贴一下这两个重要的方法:

public long enqueue(Request request) {
    ContentValues values = request.toContentValues(mPackageName);
    Uri downloadUri = mResolver.insert(Downloads.Impl.CONTENT_URI, values);
    long id = Long.parseLong(downloadUri.getLastPathSegment());
    return id;
}

public static class Request {
    ...
    public Request(Uri uri) {
        if (uri == null) {
            throw new NullPointerException();
        }
        String scheme = uri.getScheme();
        if (scheme == null || (!scheme.equals("http") && !scheme.equals("https"))) {
            throw new IllegalArgumentException("Can only download HTTP/HTTPS URIs: " + uri);
        }
        mUri = uri;
    }

    public Request setDestinationUri(Uri uri) {
        mDestinationUri = uri;
        return this;
    }
}

DownloadManager类中的enqueue()方法源码很简单,将下载请求加入到下载队列,去执行下载任务,返回一个long类型对象,也就是下载任务的id,到时候我们会根据这个id去查询下载的进度。

静态内部类Request的唯一构造方法接收一个Uri类型的参数,根据源码中的注释可以知道,该参数只接受http或https类型的Uri地址,否则会抛出异常。

setDestinationUri()方法为下载的APK设置保存地址。

private void download() {
    File installFile = new File(this.filePath + this.fileName);
    if (installFile.exists()) {
        installFile.delete();
    }
    // 下载应用
    DownloadManager downloadManager = (DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE);
    Uri uri = Uri.parse(this.serverUrl + "download/xxx.apk");
    DownloadManager.Request request = new DownloadManager.Request(uri);
    request.setDestinationUri(Uri.fromFile(installFile));
    long downloadReference = downloadManager.enqueue(request);
    // 下载前注册完成监听
    registerDownLoadFinishReceiver();
}
下载进度查询

上面说到的在DownloadManager类中有个静态内部类Query用于查询下载的进度。上面在执行下载任务的时候,执行enqueue()方法返回的一个long类型的结果,这个结果将用作查询进度的参数。调用DownloadManager的query()方法,返回一个Cursor对象,通过这个游标对象来查询已经下载的大小已经APK文件总大小,计算下载的百分比,用于显示ProgressDialog。

public int getProgress(long downloadId) {
    DownloadManager mDownloadManager = (DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE);
    //查询进度
    DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
    Cursor cursor = null;
    int progress = 0;
    try {
        cursor = mDownloadManager.query(query);//获得游标
        if (cursor != null && cursor.moveToFirst()) {
            //当前的下载量
            int downloadSoFar = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
            //文件总大小
            int totalBytes = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
            progress = (int) (downloadSoFar * 1.0f / totalBytes * 100);
        }
    } finally {
        if (cursor != null) {
            cursor.close();
        }
    }
    return progress;
}

下面我们看一下query()方法的源码:

/**
 * Query the download manager about downloads that have been requested.
 *
 */
public Cursor query(Query query) {
    Cursor underlyingCursor = query.runQuery(mResolver, UNDERLYING_COLUMNS, mBaseUri);
    if (underlyingCursor == null) {
        return null;
    }
    return new CursorTranslator(underlyingCursor, mBaseUri, mAccessFilename);
}

可以看到执行的还是Query类中的runQuery()方法,具体该方法的源码比较长,这里就不再贴了,有兴趣的可以自己下载源码看一下。

下载监听

APK文件下载时,注册一个广播去监听该下载任务,当下载完成之后,提醒我们去执行安装该APK文件,执行安装方法。

private void registerDownLoadFinishReceiver() {
    IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
    BroadcastReceiver receiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            long reference = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
            if (downloadReference == reference) {
                // 下载完成后取消监听
                mContext.unregisterReceiver(this);
                // 安装应用
                install();
            }
        }
    };
    mContext.registerReceiver(receiver, filter);
}

AsyncTask方法下载

自定义类继承AsyncTask

自定义异步任务下载类继承AsyncTask,并实现其抽象方法。该类会自动启动子线程去执行耗时操作,然后与UI进行交互,通知UI下载进度。

下载

直接贴代码:

private class DownloadAPK extends AsyncTask<String, Integer, String> {
    ProgressDialog progressDialog;
    File file;

    public DownloadAPK(ProgressDialog progressDialog) {
        this.progressDialog = progressDialog;
    }

    @Override
    protected String doInBackground(String... params) {
        URL url;
        HttpURLConnection conn;
        BufferedInputStream bis = null;
        FileOutputStream fos = null;

        try {
            url = new URL(params[0]);
            conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setConnectTimeout(5000);

            int fileLength = conn.getContentLength();
            bis = new BufferedInputStream(conn.getInputStream());
            String fileName = Environment.getExternalStorageDirectory().getPath() + "/xxxapk";
            file = new File(fileName);
            if (!file.exists()) {
                if (!file.getParentFile().exists()) {
                    file.getParentFile().mkdirs();
                }
                file.createNewFile();
            }
            fos = new FileOutputStream(file);
            byte data[] = new byte[4 * 1024];
            long total = 0;
            int count;
            while ((count = bis.read(data)) != -1) {
                total += count;
                publishProgress((int) (total * 100 / fileLength));
                fos.write(data, 0, count);
                fos.flush();
            }
            fos.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (fos != null) {
                    fos.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            try {
                if (bis != null) {
                    bis.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    @Override
    protected void onProgressUpdate(Integer... progress) {
        super.onProgressUpdate(progress);
        progressDialog.setProgress(progress[0]);
    }

    @Override
    protected void onPostExecute(String s) {
        super.onPostExecute(s);
        openFile(file);
        progressDialog.dismiss();
    }
}
UI显示进度

该方法会自己去创建子线程下载,我们需要在UI界面显示来通知用户下载的进度:

private void downloadAPK(Context context) {
    ProgressDialog progressDialog = new ProgressDialog(context);
    progressDialog.setTitle("提示");
    progressDialog.setMessage("正在下载...");
    progressDialog.setIndeterminate(false);
    progressDialog.setMax(100);
    progressDialog.setCancelable(false);
    progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
    progressDialog.show();
    String downloadUrl = "http://xxx.xxx.xx.xx:xxxx/Server/download/xxx.apk";
    new DownloadAPK(progressDialog).execute(downloadUrl);
}

安装APK

在这里安装apk文件的时候会有一些问题,在Android7.0之后我们不能在像以前那样的方式去直接跳转到系统的安装界面去安装了,需要使用FileProvider来进行。

  • Android7.0之前安装APK文件
  • Android7.0及以上安装APK文件
  • 兼容的安装方式

Android 7.0版本之前

在7.0之前,我们安装的方式比较简单,直接创建一个Intent,将下载的APK文件地址信息传递进去跳转到系统的安装界面即可。

public void install() {
    // 安装指定路径下最新版本的应用
    File installFile = new File(this.filePath + this.fileName);
    if (!installFile.exists()) {
        return;
    }
    Intent intent = new Intent(Intent.ACTION_VIEW);
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    intent.setDataAndType(Uri.fromFile(installFile), "application/vnd.android.package-archive");
    mContext.startActivity(intent);
}

Android7.0之后

如果在7.0之后继续用上述方法安装APK文件则会抛出 android.os.FileUriExposedException异常。

因为在Android7.0之后,为了提高私有文件的安全性,应用的私有目录被限制访问,此设置可防止私有文件的元数据泄漏,导致我们无法传递文件路径。但是Google肯定也给我们提供了其他方式去安装,即使用FileProvider。

FileProvider

FileProvider,即在应用间共享文件,对于面向 Android 7.0 的应用,Android 框架执行的 StrictMode API 政策禁止在你的应用外部公开 file:// URI。如果一项包含文件 URI 的 intent 离开你的应用,则应用出现故障,并出现 FileUriExposedException 异常。

要在应用间共享文件,你应发送一项 content:// URI,并授予 URI 临时访问权限。进行此授权的最简单方式是使用 FileProvider 类。具体了解该类详情可查看Google官网或者API。

APK安装

注册provider

在AndroidManifest.xml中注册provider

<application
    ...
    >
    <provider
        android:name="android.support.v4.content.FileProvider"
        android:authorities="${applicationId}.provider"
        android:grantUriPermissions="true"
        android:exported="false">
        <!--元数据-->
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>

其中:android:authorities表示组件的标识,一般为包名+provider,不能和其他应用的重复;android:grantUriPermission=”true”表示授予Uri临时访问权限;android:exported=”false”表示当前内容提供者是否会被其他应用使用。

注意android:exported这个属性,我们必须指定为false,不能指定为true,否则会抛出异常,我们可以查看一下FileProvider的源码:

/**
 * After the FileProvider is instantiated, this method is called to provide the system with information about the provider.
 *
 * @param context A {@link Context} for the current component.
 * @param info A {@link ProviderInfo} for the new provider.
 */
@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);
}

通过以上源代码我们可以看出,在FileProvider中会去判断这个属性,如果为true(即info.exported = true),则会抛出new SecurityException(“Provider must not be exported”)这个异常,所以我们必须指定为false。

创建xml文件file_paths

在res目录下新建一个xml文件夹,在其中创建file_paths.xml文件,通过这个文件中的内容来对外暴露的文件夹路径,定义如下:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path name="external_files" path="." />
</paths>

· name:一个引用字符串

· path:文件夹”相对路径”,完整路径取决于当前的标签类型。

注:path可以空,表示指定目录下的所有文件、文件夹都可以被共享。

<files-path name="name" path="path" />

物理路径相当于Context.getFilesDir() + /path/。

<cache-path name="name" path="path" />

物理路径相当于Context.getCacheDir() + /path/。

<external-path name="name" path="path" />

物理路径相当于Environment.getExternalStorageDirectory() + /path/。

读取FileProvider

通过FileProvider来进行对Android7.0及以上版本安装APK文件:

private void openFile(File file) {
    if (file!=null){
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        //参数1:上下文,参数2:Provider主机地址 和配置文件中保持一致,参数3:共享的文件
        Uri apkUri = FileProvider.getUriForFile(context, getContext().getApplicationContext().getPackageName() + ".provider",file);
        //添加这一句表示对目标应用临时授权该Uri所代表的文件
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
        getActivity().startActivity(intent);
    }
}

兼容的安装方式

我们在开发中不能确定到底是哪个版本去使用该APP,所以在代码中需要对版本进行判断,在7.0之前使用之前的安装方式,在7.0之后使用FileProvider来进行文件共享安装:

private void openFile(File file) {
    if (file!=null){
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        // 判断版本是否在7.0以上
        if(Build.VERSION.SDK_INT>=24) {
            //参数1:上下文,参数2:Provider主机地址 和配置文件中保持一致,参数3:共享的文件
            Uri apkUri = FileProvider.getUriForFile(context, getContext().getApplicationContext().getPackageName() + ".provider",file);
            //添加这一句表示对目标应用临时授权该Uri所代表的文件
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
            intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
        } else {
            intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive");
        }
        getActivity().startActivity(intent);
    }
}