4. 下载升级包并校验MD5

任务说明

本案例实现Android端OTA升级包下载与完整性校验闭环:从指定URL下载升级包到应用私有目录,下载完成后计算文件MD5值,与服务器下发的期望值比对,确保升级包未被篡改或损坏。核心代码可直接嵌入真实OTA流程中的下载与校验环节。

环境依赖

无需第三方库,基于HttpURLConnection与MessageDigest实现。需在AndroidManifest.xml中声明INTERNET权限,若targetSdkVersion≥28还需在application节点设置android:usesCleartextTraffic="true"(仅当使用http协议时)。

关键完整代码

import android.content.Context;
import android.os.Environment;
import android.util.Log;

import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;

public class OtaDownloader {
    private static final String TAG = "OtaDownloader";
    private static final int BUFFER_SIZE = 8192;
    private static final int TIMEOUT_MS = 30000;

    public interface DownloadCallback {
        void onSuccess(File file, String actualMd5);
        void onFailure(String errorMsg);
    }

    /**
     * 下载升级包并校验MD5
     *
     * @param context    上下文(用于获取私有目录)
     * @param downloadUrl 升级包下载地址
     * @param expectedMd5 服务器下发的期望MD5值(32位十六进制)
     * @param callback   结果回调
     */
    public static void downloadAndVerify(Context context, String downloadUrl,
                                         String expectedMd5, DownloadCallback callback) {
        new Thread(() -> {
            File outputFile = null;
            try {
                // 1. 创建输出文件(应用私有目录,避免存储权限)
                File dir = new File(context.getFilesDir(), "ota");
                if (!dir.exists() && !dir.mkdirs()) {
                    callback.onFailure("创建目录失败");
                    return;
                }
                outputFile = new File(dir, "update.zip");

                // 2. 下载文件
                URL url = new URL(downloadUrl);
                HttpURLConnection conn = (HttpURLConnection) url.openConnection();
                conn.setConnectTimeout(TIMEOUT_MS);
                conn.setReadTimeout(TIMEOUT_MS);
                conn.setRequestMethod("GET");

                int responseCode = conn.getResponseCode();
                if (responseCode != HttpURLConnection.HTTP_OK) {
                    callback.onFailure("HTTP错误: " + responseCode);
                    conn.disconnect();
                    return;
                }

                // 3. 边下载边计算MD5(避免二次读取)
                MessageDigest md5Digest = MessageDigest.getInstance("MD5");
                try (InputStream is = conn.getInputStream();
                     FileOutputStream fos = new FileOutputStream(outputFile)) {
                    byte[] buffer = new byte[BUFFER_SIZE];
                    int bytesRead;
                    while ((bytesRead = is.read(buffer)) != -1) {
                        fos.write(buffer, 0, bytesRead);
                        md5Digest.update(buffer, 0, bytesRead);
                    }
                    fos.flush();
                }
                conn.disconnect();

                // 4. 比对MD5
                String actualMd5 = bytesToHex(md5Digest.digest());
                if (expectedMd5 == null || expectedMd5.isEmpty()) {
                    callback.onFailure("期望MD5为空,无法校验");
                    return;
                }
                if (!expectedMd5.equalsIgnoreCase(actualMd5)) {
                    callback.onFailure("MD5校验失败,期望: " + expectedMd5
                            + " 实际: " + actualMd5);
                    return;
                }

                callback.onSuccess(outputFile, actualMd5);

            } catch (Exception e) {
                Log.e(TAG, "下载或校验异常", e);
                // 清理不完整文件
                if (outputFile != null && outputFile.exists()) {
                    outputFile.delete();
                }
                callback.onFailure("异常: " + e.getMessage());
            }
        }).start();
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}

调用方式

OtaDownloader.downloadAndVerify(context,
        "https://example.com/ota/update_v1.2.zip",
        "e4b99a8f8c6d2e1a0b3f5c7d9e2a4b6c",
        new OtaDownloader.DownloadCallback() {
            @Override
            public void onSuccess(File file, String actualMd5) {
                // 文件已就绪且MD5匹配,可执行安装流程
                Log.i("OTA", "下载成功: " + file.getAbsolutePath());
            }

            @Override
            public void onFailure(String errorMsg) {
                Log.e("OTA", "失败: " + errorMsg);
            }
        });

验证方法

在测试设备上准备一个已知内容的文件(如10MB随机数据),用md5sum命令计算其MD5值作为expectedMd5,通过本地HTTP服务(如Python的http.server)提供下载。运行上述代码后,观察日志:

  • 通过条件:日志输出“下载成功”,且文件路径指向的文件存在,其MD5与期望值一致。
  • 失败验证:将期望MD5改错一位,应输出“MD5校验失败”,且目标文件被删除(无残留)。

若下载中断(如飞行模式),应输出“异常”或“HTTP错误”,且无残留文件。

常见问题

  • 下载大文件内存溢出:本实现使用流式写入与增量MD5更新,内存占用恒定,不会因文件增大而溢出。
  • 校验失败但文件已存在:代码在异常和校验失败路径均会删除不完整文件,避免脏数据残留。
  • 主线程网络异常:下载已在子线程执行,回调回到调用线程(主线程),可直接更新UI。