Android包下载安装流程
背景
应用上线前,必不可少的需要应用升级操作,android选择的是在应用内升级,这里选择系统自带的downloadManager进行操作。实现应用内升级及通知栏升级进度显示。
我们首先需要给应用添加存储权限和允许应用安装包的权限。
1.添加权限
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
2.版本号判断
判断当前版本号是否需要升级
@JvmStatic //Java使用该方法 fun checkVersion(apkInfo: UpgradeBean?): Boolean { if (apkInfo == null) { return false } if (apkInfo.isUpdate == false) { return false } val oldVersion = AppUtils.getAppVersionName().filter { it.isDigit() } .toInt()//本地版本号 //本地版本号 = BaseApplication.getContext().getPackageManager().getPackageInfo(BaseApplication.getContext().getPackageName(), PackageManager.GET_CONFIGURATIONS).versionCode val version = if (!apkInfo.latestVersion.isNullOrEmpty()) apkInfo.latestVersion.filter { it.isDigit() } ?.toInt() ?: -1 else -1 //filter过滤器过滤字符,isDigit()只提取数字,防止其他字符混入 if (version > oldVersion) { return true } return false }
3.申请存储权限
6.0以上存储需要申请写入权限,这里使用的XXpermission,代码仓库地址:https://github.com/getActivity/XXPermissions
//1.申请权限
XXPermissions.with(mContext)
// 申请单个权限
.permission(Permission.WRITE_EXTERNAL_STORAGE)
.request(object : OnPermissionCallback {
override fun onGranted(permissions: MutableList<String>, allGranted: Boolean) {
upgradeBean ?: return
UpgradeManager.upgradeApk(mContext, upgradeBean!!, processCallBack = { bean ->
if (upgradeBean?.updateType == "NORMAL" && bean.status == ProgressState.download_start) {
ToastUtils.showShort("正在下载中...")
dismissCallback.invoke()
dismiss()
return@upgradeApk
}
bind.tvUpgrade.isEnabled = false
bind.tvUpgrade.setSolidColor(KBSkinUtil.getColor(KBColor.primary, 0.3f))
bind.tvUpgrade.text = "下载中..."
when (bean.status) {
ProgressState.download_start -> {
progressBar.isShow(true)
progressBar.setProgress(0f)
}
ProgressState.downloading -> {
progressBar.setProgress(bean.progress.toFloat())
}
ProgressState.download_success -> {
progressBar.isShow(false)
progressBar.setProgress(0f)
// dismiss()
bind.tvUpgrade.isEnabled = true
bind.tvUpgrade.setSolidColor(KBSkinUtil.getColor(KBColor.primary))
bind.tvUpgrade.text = "立即更新"
}
else -> {
//失败
progressBar.isShow(false)
progressBar.setProgress(0f)
bind.tvUpgrade.isEnabled = true
bind.tvUpgrade.setSolidColor(KBSkinUtil.getColor(KBColor.primary))
bind.tvUpgrade.text = "立即更新"
}
}
})
}
override fun onDenied(permissions: MutableList<String>, doNotAskAgain: Boolean) {
if (doNotAskAgain) {
// 如果是被永久拒绝就跳转到应用权限系统设置页面
XXPermissions.startPermissionActivity(context, permissions)
} else {
toast("获取存储权限失败")
}
}
})
}
}
需要注意的是,在android10以上的设备需要适配分区存储,如果暂未适配,请在manifast文件中添加
android:requestLegacyExternalStorage="true"
在android11以上必须适配分区存储,andorid11以上系统对手机存储进行了分区,写入权限已经不存在,应用只能在自己私有目录及系统分配的公共目录(例如,download)进行读取操作。但是android10及以下还是需要申请动态存储权限。
4.包下载
下载包之前先判断包存不存在,这里可以使用后端返回包的md5值来进行检测包的完整性安装。
使用DownloadManager可配置在通知栏自定义titile和描述。
fun upgradeApk(context: Context, upgradeInfo: UpgradeBean, processCallBack: (data: ProgressBean) -> Unit = { data -> }) {
//设置apk下载地址:本机存储的download文件夹下
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
//找到该路径下的对应名称的apk文件,有可能已经下载过了
val file = File(dir, "kqiu-v${upgradeInfo.latestVersion}.apk")
//开辟线程
MainScope().launch {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// 1、判断是否下载过apk
if (file.exists() && false) {
val authority: String = context.applicationContext.packageName + ".fileprovider"
// "content://" 类型的uri --将"file://"类型的uri变成"content://"类型的uri
val uri = FileProvider.getUriForFile(context, authority, file)
// 5、安装apk, content://和file://路径都需要
requestPermission(context, uri, file)
} else {
// 2、DownloadManager配置
val request = DownloadManager.Request(Uri.parse(encodeGB(upgradeInfo.resURL))) //处理中文下载地址
// 设置下载路径和下载的apk名称
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "kqiu-v${upgradeInfo.latestVersion}.apk")
request.setTitle("K球")
request.setDescription("下载中.....")
// 下载时在通知栏内显示下载进度条
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
// 设置MIME类型,即设置下载文件的类型, 如果下载的是android文件, 设置为application/vnd.android.package-archive
request.setMimeType("application/vnd.android.package-archive")
// 3、获取到下载id
downloadId = downloadManager.enqueue(request)
processCallBack.invoke(ProgressBean(ProgressState.download_start, 0))
// 开辟IO线程
MainScope().launch(Dispatchers.IO) {
// 4、动态更新下载进度
val success = checkDownloadProgress(
downloadManager,
downloadId,
processCallBack,
file
)
MainScope().launch {
if (success) {
processCallBack.invoke(ProgressBean(ProgressState.download_success, 100))
// 下载文件"content://"类型的uri ,DownloadManager通过downloadId
val uri = downloadManager.getUriForDownloadedFile(downloadId)
// 通过downLoadId查询下载的apk文件转成"file://"类型的uri
val file = queryDownloadedApk(context, downloadId)
// 5、安装apk
requestPermission(context, uri, file)
} else {
ToastUtil.showNormal("下载失败")
if (file.exists()) {
// 当不需要的时候,清除之前的下载文件,避免浪费用户空间
file.delete()
}
// 删除下载任务和文件
downloadManager.remove(downloadId)
// 隐藏进度条显示按钮,重新下载
processCallBack.invoke(ProgressBean(ProgressState.download_fail, 0))
}
cancel()
}
cancel()
}
}
cancel()
}
}
查询包下载进度。
private fun queryDownloadedApk(context: Context, downloadId: Long): File? {
var targetApkFile: File? = null
val downloader = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
if (downloadId != -1L) {
val query = DownloadManager.Query()
query.setFilterById(downloadId)
query.setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL)
val cur: Cursor? = downloader.query(query)
if (cur != null) {
if (cur.moveToFirst()) {
val uriString: String =
cur.getString(cur.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
if (!TextUtils.isEmpty(uriString)) {
targetApkFile = Uri.parse(uriString).path?.let { File(it) }
}
}
cur.close()
}
}
return targetApkFile
}
处理下载地址为中文编码的问题
private fun encodeGB(downloadUrl: String): String? {
//转换中文编码
val split = downloadUrl.split("/".toRegex()).toTypedArray()
for (i in 1 until split.size) {
try {
split[i] = URLEncoder.encode(split[i], "GB2312")
} catch (e: UnsupportedEncodingException) {
e.printStackTrace()
}
split[0] = split[0] + "/" + split[i]
}
split[0] = split[0].replace("\\+".toRegex(), "%20") //处理空格
return split[0]
}
6.处理未知来源应用权限
Android 8.0 中未知应用安装权限的开关默认是被关闭的 ,需要用户手动打开允许【未知来源应用权限】才能够安装。在安装之前先判断是否有安装权限,有直接安装,没有,申请后安装。
private fun requestPermission(context: Context, uri: Uri, file: File?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val haveInstallPermission = context.packageManager.canRequestPackageInstalls();
if (haveInstallPermission) {
installAPK(context, uri, file)
} else {
XXPermissions.with(context)
.permission(Permission.REQUEST_INSTALL_PACKAGES)
//.interceptor(PermissionInterceptor())
.request(object : OnPermissionCallback {
override fun onGranted(permissions: MutableList<String>, allGranted: Boolean) {
if (allGranted) {
installAPK(context, uri, file)
} else {
toast("获取安装权限失败")
}
}
override fun onDenied(permissions: MutableList<String>, doNotAskAgain: Boolean) {
super.onDenied(permissions, doNotAskAgain)
if (doNotAskAgain) {
// 如果是被永久拒绝就跳转到应用权限系统设置页面
XXPermissions.startPermissionActivity(context, permissions)
} else {
toast("获取安装权限失败")
}
}
})
}
} else {
installAPK(context, uri, file)
}
}
实际测试中,华为手机申请了权限后也没弹出允许未知来源的系统提示,而小米手机在调用context.packageManager.canRequestPackageInstalls()后,返回为false,跳到允许未知来源的设置里。如果不申请权限,也可以安装,但是用户手动关闭未知来源的权限后,应用就安装不了了。
8.安装包
在android7.0以后,私有目录被限制访问,给其他应用传递 file:// URI 类型的Uri,可能会导致接受者无法访问该路径。 因此,在Android7.0中尝试传递 file:// URI 会触发 FileUriExposedException。
如果我们需要安装包,需要适配android7.0文件应用共享,可以发送 content:// URI类型的Uri,并授予 URI 临时访问权限。 进行此授权的最简单方式是使用 FileProvider类。
使用fileProvider的步骤大致如下:
1.在manifest清单文件中注册provider。
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="{packageName}.fileprovider"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"
tools:replace="android:resource" />
</provider>
2.指定共享的目录。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<external-path
name="camera_photos"
path="" />
<external-cache-path
name="external-cache"
path="" />
<path>
<root-path name="files_apk"
path="/"/>
</path>
</paths>
</resources>
- <files-path/>代表的根目录: Context.getFilesDir()
- <external-path/>代表的根目录: Environment.getExternalStorageDirectory()
- <cache-path/>代表的根目录: getCacheDir()
使用fileprovider。
android7.0以上通过FileProvider创建一个content类型的URI
val authority: String = context.applicationContext.packageName + ".fileprovider"
// "content://" 类型的uri --将"file://"类型的uri变成"content://"类型的uri
val uri = FileProvider.getUriForFile(context, authority, file)
拿到uri进行安装包的请求。
private fun installAPK(context: Context, apkUri: Uri, apkFile: File?) {
val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
//安卓7.0版本以上安装
intent.action = Intent.ACTION_VIEW
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)//添加这一句表示对目标应用临时授权该Uri所代表的文件
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
} else {
//安卓6.0-7.0版本安装
intent.action = Intent.ACTION_DEFAULT
intent.addCategory(Intent.CATEGORY_DEFAULT)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
apkFile?.let {
intent.setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive")
}
}
try {
context.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
}
}
注意事项
以上,应用内安装包的流程就完成了,主要注意的点就是针对android各个版本的特性适配。
- android6.0动态权限
- android7.0文件共享适配
- android8.0未知来源权限适配
- android11.0分区存储适配
问题
测试过程中出现一个现象,当请求权限弹窗弹出后,不允许不会报错,但是如果手动在应用管理中关闭存储权限等,返回首页会出现白屏的情况。
通过踩着前人的肩膀可发现,虽然返回会正常回到启动页,但是启动页和主界面都是异常杀死的,所以savedInstanceState都会有数据。
解决方法:手动判断基类activity中的onCreate方法,判断savedInstanceState是否不为空,如果不为空,强制跳转到启动页。
参考链接:
2.Android进阶之路 - 设置中关闭权限,返回app应用崩溃