Fork me on GitHub

使用 DownloadManager 实现自动下载 APK 和更新(适配到8.0)

使用 DownloadManager 实现自动下载 APK 和更新(适配到 8.0)

最近有一个强制更新的需求,当然对于强制更新,我的内心是拒绝的。但是有的时候的确有应用的场景,比如服务器接口由于一些考量改变了,一些协议也改变了,那么老的客户端可能就会出现闪退的情况,一般可以通过几个版本的迭代解决这个问题,但是如果几个版本后还是有一些用户停留在老版本,那么强制更新就很有必要了。当然了,这里所谓的强制更新也就是你不更新到最新版本,会让你无法使用,并提醒你更新。普通的应用只能做到这个程度了,因为你没有 root 权限是做不到静默更新的。

这里只是记录一下在实现的过程中需要注意的几个点:

  • Android 6.0 权限问题
  • Android 7.0 File Uri 适配问题
  • Android 8.0 权限申请问题
  • 利用 md5 防止重复下载 apk

当然,并不会每一个都展开写,因为有些东西真的出来挺久了,还没去看的话只能说你已经有点落后于 Android 版本了。本文也不是详细介绍如何使用 DownloadManager 的文,对这玩意感兴趣的可以去看看 api。

这里我们至少需要后台提供哪些信息呢?

  • 后台目前的 apk 版本号
  • 下载地址
  • apk 的 md5

这里的版本号,最好对应我们 Android 中的 versionCode,用这个来判断最直观。下文所有实现均为 Kotlin 代码!

6.0 权限适配

这里不展开讲,在下载之前记得申请好读写内存权限,目前我用的是 RxPermission。

Android 7.0 File Uri 适配问题

首先在哪用到了 File Uri 呢,在安装 apk 的时候会用到:

1
2
3
4
5
val f = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "xxx.apk")
val intent = Intent(Intent.ACTION_VIEW)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.setDataAndType(Uri.fromFile(f), "application/vnd.android.package-archive")
context.startActivity(intent)

Android 7.0 以上的版本继续使用这种代码会直接抛异常,所以需要使用 File Provider 来做一下适配,首先在清单文件中注册 provider:

1
2
3
4
5
6
7
8
9
10
<provider
android:name=".util.InstallProvider"
android:authorities="${applicationId}.installapk.provider"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/remeet_path" />
</provider>

看看 xml:

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<external-path
name="download"
path="" />
</paths>
</resources>

最后在对应的包下新建对应的 Provider:

1
2
3
4
5
6
7
8
9
10
11
12
package com.haisong.remeet.util;

import android.support.v4.content.FileProvider;

/**
* Created by xiasuhuei321 on 2018/3/16.
* author:luo
* e-mail:xiasuhuei321@163.com
*/

public class InstallProvider extends FileProvider {
}

完整的适配代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fun installApk() {
try {
val f = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "xxx.apk")
// val f = File("sdcard/remeet/apk/remeet.apk")
if (ChatFlow.status == ChatControl.NORMAL) {
val intent = Intent(Intent.ACTION_VIEW)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val apkUri = FileProvider.getUriForFile(context, "${context.applicationInfo.packageName}.installapk.provider", f)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
} else {
intent.setDataAndType(Uri.fromFile(f), "application/vnd.android.package-archive")
}

context.startActivity(intent)
}
} catch (e: Exception) {
XLog.i(TAG, "安装失败")
} finally {

}
}

Android 8.0 权限申请

上面的代码适配 7.0,最近在自己 8.0 的手机上跑的时候发现没有弹出安装的界面,惊的我赶紧去看了一眼 8.0 行为变更,没找到,只能搜索了一下,发现了端倪:

  • Android8.0的诸多新特性中有一个非常重要的特性:未知来源应用权限

不过我去看了一下也没看到这玩意。。。先不管那么多,的确有这个问题,然后我向清单文件中加了一行代码就完事了:

1
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

利用 md5 防止重复下载 apk

首先奉上计算文件 md5 的方法(Java 实现):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public static String getFileMD5(String path) {
return getFileMD5(new File(path));
}

public static String getFileMD5(File f) {
try {
InputStream in = new FileInputStream(f);

StringBuffer md5 = new StringBuffer();
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] dataBytes = new byte[1024];

int nread = 0;
while ((nread = in.read(dataBytes)) != -1) {
md.update(dataBytes, 0, nread);
}

byte[] mdbytes = md.digest();

// convert the byte to hex format
for (int i = 0; i < mdbytes.length; i++) {
md5.append(Integer.toString((mdbytes[i] & 0xff) + 0x100, 16).substring(1));
}
return md5.toString().toLowerCase();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

需要注意的是这里计算文件的 md5 是一个耗时的操作,所以需要我们新开一个线程去做这件事。

接下来奉上我小改之后的代码,各位可以根据需要自己改,或者看一下我实现的思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
/**
* Created by xiasuhuei321 on 2018/3/6.
* author:luo
* e-mail:xiasuhuei321@163.com
*
* 在 wifi 状态下自动下载 apk
*/

// 最后会将 activity 置 null,所以不用担心静态 activity 引用导致的内存泄漏问题
@SuppressWarnings("all")
object UpdateManager {
private val TAG = "UpdateManager"
private var updateReceiver = InstallReceiver()
var activity: Activity? = null
private var versionName: String? = null
private var md5: String? = null
private val downloadManager: DownloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
private var downloadFlag = true

// 开始下载监听
var downloadStart = {}
// 下载结束监听
var downloadFinish = {}
var relogin = {}
// 进度监听
var progressListener: (progress: Float) -> Unit = { p ->
}

// 入口
fun checkUpdate(activity: Activity) = asyncUI {
this@UpdateManager.activity = activity
// 如果正在更新,那么直接调用 relogin
if (Client.isUpdating) {
relogin.invoke()
return@asyncUI
}

// activity.registerReceiver(updateReceiver, filter)
// 获取客户端 versionCode
val versionCode = getVersionCode()
XLog.i(TAG, "versionCode=$versionCode")
// 请求后台接口,这里看自己的接口是如何实现的
val res = RemeetService.checkUpdate(CheckUpdate(versionCode, 1))
// val res = RemeetService.checkUpdate(CheckUpdate(0, 1)) // test
.executeOnBg(LoadingDialog(activity))
.await()
.dealErrorAndFailed(ExceptionHandleType.TOAST)

val info = res.data
// 如果后台给的更新标识为 false,那么不下载(可能 apk 有问题,先不执行更新的逻辑)
if (!info.downloadFlag) return@asyncUI

Client.serverApkMd5 = info.md5
Client.serverVersionCode = info.versionId

versionName = info.versionName
md5 = info.md5

// 比对服务端版本号,确认服务端是否是高版本
if (info.versionId > versionCode) {
XLog.i(TAG, "客户端需要更新")

asyncUI {
// 相当于开启了一个新线程
bg {
try {
// 首先检查本地文件
checkAndDeleteDownloadFiles(info)
} catch (e: Exception) {
e.printStackTrace()
}
}.await()
}
} else {
XLog.i(TAG, "客户端: $versionCode >= 服务端: ${info.versionId}")
}
}

private fun update(url: String): Long {
Client.isUpdating = true
val request = DownloadManager.Request(Uri.parse(url))
//设置下载的文件存储的地址,我们这里将下载的apk文件存在/Download目录下面
val f = File("sdcard/remeet/apk")
if (!f.exists()) f.mkdirs()
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "remeet-${getVersionName()}.apk")
//设置现在的文件可以被MediaScanner扫描到。
request.allowScanningByMediaScanner()
//设置通知的标题
request.setTitle("下载")
//设置下载的时候Notification的可见性。
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
//设置下载文件类型
request.setMimeType("application/vnd.android.package-archive")
// 指定在WIFI状态下,执行下载操作
request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI)

XLog.i(TAG, "正在更新中")
return downloadManager.enqueue(request)
}

private fun getPath(): Uri {
val f = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
return Uri.withAppendedPath(Uri.fromFile(f), "xxx.apk")
}

/**
* 更新下载进度
*/
private fun updateProgress(id: Long) {
val query = DownloadManager.Query()
// 500ms 查询一次进度
val timer = Timer()
val task = object : TimerTask() {
override fun run() {
val cursor = downloadManager.query(query.setFilterById(id))
if (cursor != null && cursor.moveToFirst()) {
// 已经下载文件大小
val downloadSize = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
// 下载文件的总大小
val fileSize = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
// percent
val percent = downloadSize.toFloat() / fileSize
// XLog.i(TAG, "下载进度:$percent")
progressListener.invoke(percent * 100)
}
}
}

timer.schedule(task, 0, 500)
}

private fun getVersionCode(): Int {
try {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
return info.versionCode
} catch (e: Exception) {

}

return 0
}

private fun getVersionName() = versionName

fun installApk() {
try {
val f = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "xxx.apk")
// val f = File("sdcard/remeet/apk/remeet.apk")
if (ChatFlow.status == ChatControl.NORMAL) {
val intent = Intent(Intent.ACTION_VIEW)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val apkUri = FileProvider.getUriForFile(context, "${context.applicationInfo.packageName}.installapk.provider", f)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
} else {
intent.setDataAndType(Uri.fromFile(f), "application/vnd.android.package-archive")
}

context.startActivity(intent)
}
} catch (e: Exception) {
XLog.i(TAG, "安装失败")
} finally {

}
}

fun unregisterReceiver() {
this.activity = null
}

private fun checkAndDeleteDownloadFiles(info: Update) {
XLog.i(TAG, "file name: xxx.apk")
val f = File("/sdcard/Download")
XLog.i(TAG, "运行到这了")
if (f.exists()) {
for (file in f.listFiles()) {
if (file.name == "xxx.apk") {
// 检查 md5
val fMd5 = MD5.getFileMD5(file.absolutePath)
XLog.i(TAG, "服务端 md5: $md5\n客户端本地文件 md5: $fMd5")
if (fMd5 == md5) {
// md5 相同无需重新下载 apk
XLog.i(TAG, "本地已有下载完成的最新apk")
// downloadStart.invoke()
downloadFlag = false
showInstallDialog(info)
// installApk()
} else {
// 如果 md5 不相等,重新下载
downloadFlag = true
file.delete()
XLog.i(TAG, "本地文件不完整,重新下载")
showDownloadDialog(info)
break
}
}
}

if (downloadFlag) {
XLog.i(TAG, "本地无此文件,开始下载")
showDownloadDialog(info)
// downloadStart.invoke()
// update(info.updateUrl.let {
// if (it.startsWith("http") || it.startsWith("https"))
// it
// else
// "http://$it"
// })
}
} else {
XLog.i(TAG, "Download 文件夹不存在!?")
}
}

// private fun updateDialog() {
// activity!!.runOnUiThread {
// if (ChatFlow.status != ChatFlow.CHATTING)
// activity!!.showDialog {
// title("提示")
// content("是否更新到最新版本?")
// positiveText("更新")
// negativeText("取消")
//
// onPositive { _, _ ->
// installApk()
// }
// }
// }
// }

fun isWifiConnected(): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val info = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI)
if (info != null) return info.isConnected
return false
}

fun isMobileConnect(): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val info = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE)
if (info != null) return info.isConnected
return false
}

fun clearCallback() {
downloadStart = {}
downloadFinish = {}
}

private fun showInstallDialog(info: Update) {
asyncUI {
activity?.showDialog {
title("发现新版本,已经下载完毕")
content(info.updateContent)
positiveText("安装")
canceledOnTouchOutside(false)
keyListener { _, keyCode, e ->
if (e.keyCode == KeyEvent.KEYCODE_BACK) {
XLog.i(TAG, "拦截返回事件")
return@keyListener true
}
return@keyListener false
}

onPositive { _, _ ->
asyncUI {
installApk()
}
}
}
}
}

private fun showDownloadDialog(info: Update) {
asyncUI {
activity?.showDialog {
title("发现新版本")
content(info.updateContent)
positiveText("立刻下载")
canceledOnTouchOutside(false)
keyListener { _, keyCode, e ->
if (e.keyCode == KeyEvent.KEYCODE_BACK) {
XLog.i(TAG, "拦截返回事件")
return@keyListener true
}
return@keyListener false
}

onPositive { _, _ ->
downloadStart.invoke()
updateProgress(update(info.updateUrl.let {
if (it.startsWith("http") || it.startsWith("https"))
it
else
"http://$it"
}))
}
}
}
}

}

class InstallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
XLog.i("InstallReceiver", "下载完成")
if (intent?.action.equals(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) {
Client.isUpdating = false
UpdateManager.downloadFinish.invoke()
// updateDialog()
UpdateManager.installApk()
}
}
}

可能有一些无用的函数,这个类入口是 checkUpdate,顺着checkUpdate的线一直看下去就能了解整个流程了。