说说Android版本更新

关于本文DownloadManager版本更新的源码连接详见个人开源项目 AppUpdate

前言

版本升级对于app来说已是很是常见的功能了,每次项目的版本迭代、新功能的开发都须要下载更新新版本,经过安装新版原本实现咱们的迭代。固然除了这种方式,实际上也有热更新与热修复的存在,无需安装的状况下实现版本的迭代,并且不少大型的项目在有了大量用户的积累后也大都采起了灰度发布的功能,先小范围升级试用,在正式推向市场。今天我只想单纯来说讲基于系统自带的DownloadManager来实现的下载更新。android

万能流程图

画图不易,这张流程图几乎包含了app检查更新的全部涉及到的流程,像流程图中进度框、下载失败的弹框,MD5校验我的以为能够不须要,通常像DownloadManager来实现下载更新只须要在后台下载,下载完成用系统的Notification进行通知便可,而后自动弹出安装界面,这是个标准的流程。 git

涉及知识概括

  • DownloadManager系统下载服务的相关api及使用。
  • Android M 运行时权限的动态申请,主要涉及读写存储卡权限。
  • Android N 关于文件的访问权限,不能以file://xxx格式的Uri来访问文件,须要使用FileProvider,Uri格式为content://xxx
  • Android O 关于未知来源应用的权限申请。
  • Android Q 增长沙箱并改变了应用程序访问设备外部存储上文件的方式,并且不能够在内部存储肆意的构建本身的目录
  • 文件MD5校验,防止apk下载被拦截篡改及验证apk文件的完整性。

DownloadManager介绍及使用

介绍

DownloadManager下载管理器是一种处理长时间运行的HTTP下载的系统服务。客户端能够请求将URI下载到特定目标文件。下载管理器将在后台进行下载,负责HTTP交互并在发生故障或跨链接更改和系统从新启动后重试下载。翻译过来的始终感受很差,如下是官方的原话 (官方传送门github

The download manager is a system service that handles long-running HTTP downloads. Clients may request that a URI be downloaded to a particular destination file. The download manager will conduct the download in the background, taking care of HTTP interactions and retrying downloads after failures or across connectivity changes and system reboots.shell

Apps that request downloads through this API should register a broadcast receiver for ACTION_NOTIFICATION_CLICKED to appropriately handle when the user clicks on a running download in a notification or from the downloads UI.api

Note that the application must have the Manifest.permission.INTERNET permission to use this class.浏览器

从概念上都已经明确说明了DownloadManager系统下载服务的优越性:
1.能够长时间在后台运行下载
2.能够指定任意的下载路径,也能够支持Android Q
3.下载过程当中碰见问题或者更改网络会重试下载,断点续传
4.原生系统下载服务,不依赖第三方,兼容性和稳定性无疑最好
5.默认已经帮你封装好了系统栏通知、wifi/移动网络/漫游等等下载限制缓存

下载核心的API

类/常量/方法 介绍
DownloadManager.Query 主要用来在下载的过程当中查询过滤,好比下载状态、进度等
DownloadManager.Request 下载服务一些配置、下载地址、下载路径、通知栏配置、网络限制、媒体类型等
ACTION_DOWNLOAD_COMPLETE 下载完成后,由下载管理器发送的广播意图操做
ACTION_NOTIFICATION_CLICKED 当用户从系统通知或下载UI单击正在运行的下载时,下载管理器发送广播意图操做
ACTION_VIEW_DOWNLOADS 启动活动以显示全部下载的意图操做,说白了手机系统的下载管理界面
COLUMN_BYTES_DOWNLOADED_SO_FAR 目前下载的字节数,须要下载进度条的用获得
COLUMN_TOTAL_SIZE_BYTES 下载文件的总大小,单位为字节,须要下载进度条的用获得
COLUMN_LOCAL_URI 下载的文件将存储在Uri中,注意:N以前是file://xxx,N以后是content://xxx
EXTRA_DOWNLOAD_ID 在广播ACTION_DOWNLOAD_COMPLETE中,可拿到download_id
COLUMN_REASON 提供有关下载状态的更多详细信息
COLUMN_STATUS 当前的下载状态,经过DownloadManager.Query来查询
STATUS_PENDING 下载开始
STATUS_RUNNING 下载进行中
STATUS_PAUSED 下载暂停,这里会等待重试,注意这是断点续传,暂停缘由能够经过COLUMN_REASON去查
STATUS_SUCCESSFUL 下载成功
STATUS_FAILED 下载失败,这里的失败不会重试的,缘由能够经过COLUMN_REASON去查
enqueue(DownloadManager.Request request) 开启一个下载服务
getMaxBytesOverMobile(Context context) 返回手机移动网络限定下载的最大值
getMimeTypeForDownloadedFile(long id) 经过download_id查询下载文件的媒体类型,也就是格式
getRecommendedMaxBytesOverMobile(Context context) 获取建议的移动网络下载的大小
getUriForDownloadedFile(long id) 若是文件下载成功,返回文件的Uri
openDownloadedFile(long id) 打开下载的文件,读文件
query(DownloadManager.Query query) 下载查询
remove(long... ids) 取消下载并从下载管理器中删除文件

以上即是DownloadManager下载使用到的核心api了,基本上知足一个正常的下载了,固然并无所有罗列出来,像下载暂停和下载失败关于COLUMN_REASON的描述 还有不少,就不罗列出来了,下面看看下载更新的代码片断:安全

  • 下载核心代码
// 获取下载管理器
downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
clearCurrentTask();
// 下载地址若是为null,抛出异常
String downloadUrl = Objects.requireNonNull(appUpdate.getNewVersionUrl());
Uri uri = Uri.parse(downloadUrl);
DownloadManager.Request request = new DownloadManager.Request(uri);
// 下载中和下载完成显示通知栏
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
if (TextUtils.isEmpty(appUpdate.getSavePath())) {
//使用系统默认的下载路径 此处为应用内 /android/data/packages ,因此兼容7.0
request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, context.getPackageName() + ".apk");
deleteApkFile(Objects.requireNonNull(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS + File.separator + context.getPackageName() + ".apk")));
} else {
// 自定义的下载目录,注意这是涉及到android Q的存储权限,建议不要用getExternalStorageDirectory()
request.setDestinationInExternalFilesDir(context, appUpdate.getSavePath(), context.getPackageName() + ".apk");
deleteApkFile(Objects.requireNonNull(context.getExternalFilesDir(appUpdate.getSavePath() + File.separator + context.getPackageName() + ".apk")));
}

// 设置通知栏的标题
request.setTitle(getAppName());
// 设置通知栏的描述
request.setDescription("正在下载中...");
// 设置媒体类型为apk文件
request.setMimeType("application/vnd.android.package-archive");
// 开启下载,返回下载id
lastDownloadId = downloadManager.enqueue(request);
// 如须要进度及下载状态,增长下载监听
if (!appUpdate.getIsSlentMode()) {
DownloadHandler downloadHandler = new DownloadHandler(this);
downloadObserver = new DownloadObserver(downloadHandler, downloadManager, lastDownloadId);
context.getContentResolver().registerContentObserver(Uri.parse("content://downloads/my_downloads"), true, downloadObserver);
}
复制代码
  • 下载进度的监听
    默认采起的是系统的ContentObserver对于本地下载的文件变化监听进度,也能够经过开启定时器每隔必定的时间去查询当前的下载进度。
Cursor cursor = downloadManager.query(query);
        if (cursor != null && cursor.moveToNext()) {
            int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS));
            int totalSize = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
            int currentSize = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
            // 当前进度
            int mProgress;
            if (totalSize != 0) {
                mProgress = (currentSize * 100) / totalSize;
            } else {
                mProgress = 0;
            }
            Log.d(TAG,String.valueOf(mProgress));
            switch (status) {
                case DownloadManager.STATUS_PAUSED:
                    // 下载暂停
                    handler.sendEmptyMessage(DownloadManager.STATUS_PAUSED);
                    Log.d(TAG,"STATUS_PAUSED");
                    break;
                case DownloadManager.STATUS_PENDING:
                    // 开始下载
                    handler.sendEmptyMessage(DownloadManager.STATUS_PENDING);
                    Log.d(TAG,"STATUS_PENDING");
                    break;
                case DownloadManager.STATUS_RUNNING:
                    // 正在下载,不作任何事情
                    Message message = new Message();
                    message.what = DownloadManager.STATUS_RUNNING;
                    message.arg1 = mProgress;
                    handler.sendMessage(message);
                    Log.d(TAG,"STATUS_RUNNING");
                    break;
                case DownloadManager.STATUS_SUCCESSFUL:
                    if(!isEnd){
                        // 完成
                        handler.sendEmptyMessage(DownloadManager.STATUS_SUCCESSFUL);
                        Log.d(TAG,"STATUS_SUCCESSFUL");
                    }
                    isEnd = true;
                    break;
                case DownloadManager.STATUS_FAILED:
                    if(!isEnd){
                        handler.sendEmptyMessage(DownloadManager.STATUS_FAILED);
                        Log.d(TAG,"STATUS_FAILED");
                    }
                    isEnd = true;
                    break;
                default:
                    Log.d(TAG,"default");
                    break;
            }
            cursor.close();
        } else {
            Log.d(TAG,"cursor======null");
        }
复制代码

Android M 运行时权限

android 6.0 版本引入了一种新的权限模式,现在,用户可直接在运行时管理应用权限。这种模式让用户可以更好地了解和控制权限,同时为应用开发者精简了安装和自动更新过程。用户可为所安装的各个应用分别授予或撤销权限。性能优化

对于以 Android 6.0(API级别23)或更高版本为目标平台的应用,请务必在运行时检查和请求权限。要肯定您的应用是否已被授予权限,请调用新增的checkSelfPermission()方法。要请求权限,请调用新增的requestPermissions()方法。即便您的应用并不以Android6.0(API级别23)为目标平台,您也应该在新权限模式下测试您的应用.官方传送门bash

因为下载须要读写文件,Android M 须要动态申请运行时权限,关于如何查看运行时权限,能够经过AndroidStudio的Terminal终端执行以下命令:

  • 按组列出权限和状态:

$ adb shell pm list permissions -d -g

  • 授予或撤销一项或多项权限:

$ adb shell pm [grant|revoke] ...

  • 列出全部权限:

$ adb shell pm list permissions -s

M运行时权限请求代码片断:

/**
     * 判断存储卡权限
     */
    private void requestPermission() {
        //权限判断是否有访问外部存储空间权限
        int flag = ActivityCompat.checkSelfPermission(getActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE);
        if (flag != PackageManager.PERMISSION_GRANTED) {
            if (ActivityCompat.shouldShowRequestPermissionRationale(getActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
                // 用户拒绝过这个权限了,应该提示用户,为何须要这个权限。
                Toast.makeText(getActivity(), getResources().getString(R.string.update_permission), Toast.LENGTH_LONG).show();
            }
            // 申请受权
            requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);
        } else {
            // 拥有权限,执行下载相关逻辑
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == 1) {
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                 // 授予权限,执行下载相关逻辑
            } else {
                //拒绝权限,给出提示
                Toast.makeText(getActivity(), getResources().getString(R.string.update_permission), Toast.LENGTH_LONG).show();
                dismiss();
            }
        }
    }
}

复制代码

Android N 文件的访问权限

为了提升私有文件的安全性,面向Android7.0或更高版本的应用私有目录被限制访问(0700)。此设置可防止私有文件的元数据泄漏,如它们的大小或存在性。此权限更改有多重反作用:

  • 私有文件的文件权限不该再由全部者放宽,为使用 MODE_WORLD_READABLE 和/或 MODE_WORLD_WRITEABLE 而进行的此类尝试将触发 SecurityException

注:迄今为止,这种限制尚不能彻底执行。应用仍可能使用原生 API 或 File API 来修改它们的私有目录权限。可是,咱们强烈反对放宽私有目录的权限

  • 传递软件包网域外的file://URI可能给接收器留下没法访问的路径。所以,尝试传递 file:// URI 会触发FileUriExposedException。分享私有文件内容的推荐方法是使用 FileProvider。
  • DownloadManager 再也不按文件名分享私人存储的文件。旧版应用在访问COLUMN_LOCAL_FILENAME 时可能出现没法访问的路径。面向Android7.0或更高版本的应用在尝试访问COLUMN_LOCAL_FILENAME时会触发SecurityException。经过使用DownloadManager.Request.setDestinationInExternalFilesDir()DownloadManager.Request.setDestinationInExternalPublicDir()将下载位置设置为公共位置的旧版应用仍能够访问COLUMN_LOCAL_FILENAME中的路径,可是咱们强烈反对使用这种方法。对于由DownloadManager公开的文件,首选的访问方式是使用ContentResolver.openFileDescriptor()
    下面看一下代码片断:

清单文件

<provider
   android:name=".DownloadFileProvider"
   android:authorities="${applicationId}.fileProvider"
   android:exported="false"
   android:grantUriPermissions="true">
      <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/update_file_path" />
    </provider>
复制代码

文件存储配置

<paths>
    <external-path
        name="external_storage_root"
        path="." />
    <files-path
        name="files-path"
        path="." />
    <cache-path
        name="cache-path"
        path="." />
    <!--/storage/emulated/0/Android/data/...-->
    <external-files-path
        name="external_file_path"
        path="." />
    <!--表明app 外部存储区域根目录下的文件 Context.getExternalCacheDir目录下的目录-->
    <external-cache-path
        name="external_cache_path"
        path="." />
    <!--配置root-path。这样子能够读取到sd卡和一些应用分身的目录,听说应用分身有bug-->
    <root-path
        name="root-path"
        path="" />
/paths>
复制代码

app安装

File downloadFile = getDownloadFile();
    Intent intent = new Intent(Intent.ACTION_VIEW);
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
        intent.setDataAndType(Uri.fromFile(downloadFile), "application/vnd.android.package-archive");
    } else {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
            boolean allowInstall = context.getPackageManager().canRequestPackageInstalls();
            if (!allowInstall) {
                //不容许安装未知来源应用,请求安装未知应用来源的权限
                if (mainPageExtraListener != null) {
                    mainPageExtraListener.applyAndroidOInstall();
                }
                return;
            }
        }
        //Android7.0以后获取uri要用contentProvider
        Uri apkUri = FileProvider.getUriForFile(context.getApplicationContext(), context.getPackageName() + ".fileProvider", downloadFile);
        //Granting Temporary Permissions to a URI
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
    }
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(intent);
复制代码

Android O 关于未知来源应用

针对 8.0 的应用须要在 AndroidManifest.xml 中声明REQUEST_INSTALL_PACKAGES 权限,不然将没法进行应用内升级

清单文件

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
复制代码

权限检测

/**
     * 检测到无权限安装未知来源应用,回调接口中须要从新请求安装未知应用来源的权限
     */
    @RequiresApi(api = Build.VERSION_CODES.O)
    @Override
    public void applyAndroidOInstall() {
        //请求安装未知应用来源的权限
        ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.REQUEST_INSTALL_PACKAGES}, INSTALL_PACKAGES_REQUESTCODE);
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        // 8.0的权限请求结果回调
        if (requestCode == INSTALL_PACKAGES_REQUESTCODE) {
            // 受权成功
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
               // 执行安装apk的逻辑...
            } else {
                // 受权失败,引导用户去未知应用安装的界面
                if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
                    //注意这个是8.0新API
                    Uri packageUri = Uri.parse("package:" + getPackageName());
                    Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, packageUri);
                    startActivityForResult(intent, GET_UNKNOWN_APP_SOURCES);
                }
            }
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        //8.0应用设置界面未知安装开源返回时候
        if (requestCode == GET_UNKNOWN_APP_SOURCES) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                boolean allowInstall = getPackageManager().canRequestPackageInstalls();
                if (allowInstall) {
                   // 执行安装app的逻辑...
                } else {
                   // 拒绝权限逻辑...
                   Toast.makeText(MainActivity.this,"您拒绝了安装未知来源应用,应用暂时没法更新!",Toast.LENGTH_SHORT).show();
                }
            }
        }
    }
复制代码

Android Q 存储变动

目前Android Q官网上仍是处于Beta版,Android Q最大的变化 无非是对用户隐 私权的进一步保护,为每一个应用程序在外部存储设备提供了一个独立的存储沙箱,应用经过路径建立的文件都保存在应用的沙箱目录。
关于下载,文件确定须要保存到本地了,可是因为AndroidQ采起分区存储,导致:getExternalStorageDirectory()与getExternalStoragePublicDirectory()读写权限变化,用户在拥有读写权限的同时,不能够在内部存储肆意的构建本身的目录,这样也更容易管理,卸载应用的时候也能够将这块数据与文件彻底删除。

if (TextUtils.isEmpty(appUpdate.getSavePath())) {
        //使用系统默认的下载路径 此处为应用内 /android/data/packages ,因此兼容7.0
        request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, context.getPackageName() + ".apk");
    } else {
        // 自定义的下载目录,注意这是涉及到android Q的存储权限,建议不要用getExternalStorageDirectory()
        request.setDestinationInExternalFilesDir(context, appUpdate.getSavePath(), context.getPackageName() + ".apk");
        // 清除本地缓存的文件
        deleteApkFile(Objects.requireNonNull(context.getExternalFilesDir(appUpdate.getSavePath())));
    }
复制代码

经过setDestinationInExternalFilesDir()存储文件与getExternalFilesDir()获取文件,彻底能够避免Android Q对于存储作出的限制。

文件MD5校验

若是采起系统的DownloadManager来实现更新的话,我的以为能够不用进行校验,固然若是惧怕下载的文件被篡改或者不完整的话建议能够加上MD5校验。关于MD5做用有如下几点:

  • 用于校验apk文件签名是否一致,防止下载被拦截与篡改
  • 用于校验文件大小的完整性

下面查看一下代码片断:

/**
     * 检查文件的MD5的合法性,若不一致,则没法安装
     *
     * @param md5  服务器返回的文件md5值
     * @param file 下载的apk文件
     * @return true 则md5校验经过 false 则失败
     */
    public static boolean checkFileMd5(String md5, File file) {
        if (TextUtils.isEmpty(md5)) {
            return false;
        }
        String md5OfFile = getFileMd5ToString(file);
        if (TextUtils.isEmpty(md5OfFile)) {
            return false;
        }
        return md5.equalsIgnoreCase(md5OfFile);
    }

    /**
     * Return the MD5 of file.
     *
     * @param file The file.
     * @return the md5 of file
     */
    private static String getFileMd5ToString(final File file) {
        return bytes2HexString(getFileMd5(file));
    }

    private static final char[] HEX_DIGITS =
            {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};

    private static String bytes2HexString(final byte[] bytes) {
        if (bytes == null) {
            return "";
        }
        int len = bytes.length;
        if (len <= 0) {
            return "";
        }
        char[] ret = new char[len << 1];
        for (int i = 0, j = 0; i < len; i++) {
            ret[j++] = HEX_DIGITS[bytes[i] >> 4 & 0x0f];
            ret[j++] = HEX_DIGITS[bytes[i] & 0x0f];
        }
        return new String(ret);
    }

    /**
     * Return the MD5 of file.
     *
     * @param file The file.
     * @return the md5 of file
     */
    private static byte[] getFileMd5(final File file) {
        if (file == null) {
            return null;
        }
        DigestInputStream dis = null;
        try {
            FileInputStream fis = new FileInputStream(file);
            MessageDigest md = MessageDigest.getInstance("MD5");
            dis = new DigestInputStream(fis, md);
            byte[] buffer = new byte[1024 * 256];
            while (true) {
                if (dis.read(buffer) <= 0) {
                    break;
                }
            }
            md = dis.getMessageDigest();
            return md.digest();
        } catch (NoSuchAlgorithmException | IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (dis != null) {
                    dis.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }
复制代码

最后

关于版本更新大概就这么多知识点了,比较简单,可是很零碎,若是想要了解详细的内容,卿能够下载源码进行查看哦,源码详见个人开源地址AppUpdate,本库通过长期的验证,稳定性很OK的啦,若是有好的想法,直接提issues。

本库目前的功能

  • 兼容AndroidX,项目已经迁移到Androidx
  • 适配Android M,处理关于存储文件的运行时权限
  • 适配Android N,安卓加强了文件访问的安全性,利用FileProvider来访问文件
  • 适配Android O,增长未知来源应用的安装提示
  • 适配Android Q,关于Q增长沙箱,改变了应用程序访问设备外部存储上文件的方式如SD卡
  • 支持静默下载,下载完毕自动弹出安装
  • 支持下载进度监听与下载失败提示
  • 支持强制更新,未更新没法使用应用
  • 支持MD5文件防篡改及完整性校验
  • 支持自定义更新提示界面
  • 下载失败支持经过系统浏览器下载

客官观赏一下其余文章

相关文章
相关标签/搜索