Android 文件下载、上传支持下载状态回调,断点续传(2)

Android 文件下载支持下载状态回调,断点续传(2)

DownloadUtil
java 复制代码
package com.sdt.devicemanager.utils;

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

import com.sdt.devicemanager.ssl.SSLHelper;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

/**
 * 文件下载工具类
 * 支持:
 * - 下载进度回调
 * - 断点续传(可选)
 * - 多线程下载管理
 * - 主线程回调
 */
public class DownloadUtil {
    private static final String TAG = "DownloadUtil";

    /**
     * 下载回调接口
     */
    public interface OnDownloadListener {
        /**
         * 下载开始
         *
         * @param totalBytes    文件总大小(-1表示未知)
         * @param downloadedBytes 已下载的字节数(断点续传时 > 0)
         */
        void onStart(long totalBytes, long downloadedBytes);

        /**
         * 下载进度回调
         *
         * @param totalBytes      文件总大小
         * @param downloadedBytes 已下载的字节数
         * @param progress        进度百分比 0-100
         */
        void onProgress(long totalBytes, long downloadedBytes, int progress);

        /**
         * 下载成功
         *
         * @param file 下载完成的文件对象
         */
        void onSuccess(File file);

        /**
         * 下载失败
         *
         * @param e 异常信息
         */
        void onFail(Exception e);
    }

    private static final int DEFAULT_TIMEOUT = 60;
    private static final int BUFFER_SIZE = 8192;

    /**
     * 默认下载路径
     */
    public static final String DEFAULT_DOWNLOAD_PATH = Environment.getExternalStorageDirectory().getAbsolutePath() + "/deviceManager/";

    private final OkHttpClient client;
    private final Handler mMainHandler;

    /**
     * 下载线程池
     */
    private static final ExecutorService downloadExecutor =
            new ThreadPoolExecutor(
                    3,                      // 核心线程数
                    6,                      // 最大线程数
                    60L, TimeUnit.SECONDS, // 空闲线程存活时间
                    new LinkedBlockingQueue<>(100), // 队列容量
                    new ThreadFactory() {
                        private final AtomicInteger count = new AtomicInteger(1);

                        @Override
                        public Thread newThread(Runnable r) {
                            return new Thread(r, "download-thread-" + count.getAndIncrement());
                        }
                    },
                    new ThreadPoolExecutor.CallerRunsPolicy() // 队列满了后的策略
            );

    // 双检锁单例
    private static volatile DownloadUtil downloadInstance;

    // 正在执行的下载任务
    private final Map<String, Call> downloadCalls = new ConcurrentHashMap<>();

    private DownloadUtil(Context context) {
        client = SSLHelper.getUnsafeOkHttpClient(context)
                .connectTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)
                .readTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)
                .writeTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS)
                .build();
        mMainHandler = new Handler(Looper.getMainLooper());
    }

    /**
     * 获取单例实例
     */
    public static DownloadUtil getInstance(Context context) {
        if (downloadInstance == null) {
            synchronized (DownloadUtil.class) {
                if (downloadInstance == null) {
                    downloadInstance = new DownloadUtil(context.getApplicationContext());
                }
            }
        }
        return downloadInstance;
    }

    /**
     * 下载文件
     *
     * @param url      下载地址
     * @param savePath 保存路径(文件夹路径)
     * @param fileName 文件名
     * @param listener 下载回调
     */
    public void download(String url, String savePath, String fileName, OnDownloadListener listener) {
        download(url, savePath, fileName, false, listener);
    }

    /**
     * 下载文件,可选择是否启用断点续传
     *
     * @param url          下载地址
     * @param savePath     保存路径(文件夹路径)
     * @param fileName     文件名
     * @param enableResume 是否启用断点续传
     * @param listener     下载回调
     */
    public void download(String url, String savePath, String fileName, boolean enableResume, OnDownloadListener listener) {
        if (url == null || url.isEmpty()) {
            postToMain(() -> listener.onFail(new IllegalArgumentException("Download URL is empty")));
            return;
        }

        if (fileName == null || fileName.isEmpty()) {
            postToMain(() -> listener.onFail(new IllegalArgumentException("File name is empty")));
            return;
        }

        // 在后台线程中执行下载
        downloadExecutor.execute(() -> {
            try {
                // 创建保存目录
                File saveDir = new File(savePath);
                if (!saveDir.exists()) {
                    if (!saveDir.mkdirs()) {
                        postToMain(() -> listener.onFail(new IOException("Failed to create directory: " + savePath)));
                        return;
                    }
                }

                // 目标文件
                File targetFile = new File(saveDir, fileName);
                long downloadedBytes = 0;

                // 如果启用断点续传且文件已存在,获取已下载的字节数
                if (enableResume && targetFile.exists()) {
                    downloadedBytes = targetFile.length();
                    Log.d(TAG, "Resume download from byte: " + downloadedBytes);
                }

                String downloadId = generateDownloadId(url, targetFile);
                doDownload(url, targetFile, downloadedBytes, enableResume, downloadId, listener);
            } catch (Exception e) {
                postToMain(() -> listener.onFail(e));
            }
        });
    }

    /**
     * 执行实际下载
     */
    private void doDownload(String url, File targetFile, long downloadedBytes,
                           boolean enableResume, String downloadId, OnDownloadListener listener) {
        Request.Builder builder = new Request.Builder().url(url);

        // 断点续传时添加 Range 头
        if (enableResume && downloadedBytes > 0) {
            builder.addHeader("Range", "bytes=" + downloadedBytes + "-");
        }

        Request request = builder.build();
        Call call = client.newCall(request);

        downloadCalls.put(downloadId, call);

        call.enqueue(new Callback() {
            @Override
            public void onResponse(Call call, Response response) {
                downloadCalls.remove(downloadId);

                try {
                    if (!response.isSuccessful()) {
                        postToMain(() -> listener.onFail(new IOException("Download failed, HTTP " + response.code())));
                        return;
                    }

                    // 获取文件总大小
                    long totalBytes;
                    if (enableResume && downloadedBytes > 0) {
                        // 断点续传时,从 Content-Range 头解析总大小
                        String contentRange = response.header("Content-Range");
                        if (contentRange != null && contentRange.contains("/")) {
                            String[] parts = contentRange.split("/");
                            totalBytes = Long.parseLong(parts[1]) + downloadedBytes;
                        } else {
                            totalBytes = -1;
                        }
                    } else {
                        totalBytes = response.body() != null ? response.body().contentLength() : -1;
                    }

                    // 下载进度回调包装
                    final long finalDownloadedBytes = downloadedBytes;
                    final long finalTotalBytes = totalBytes;

                    // 写入文件
                    boolean success = writeToFile(response.body().byteStream(), targetFile,
                            downloadedBytes, totalBytes, enableResume, listener);

                    if (success) {
                        postToMain(() -> listener.onSuccess(targetFile));
                    }
                } catch (Exception e) {
                    Log.e(TAG, "Download error: " + e.getMessage(), e);
                    postToMain(() -> listener.onFail(e));
                } finally {
                    response.close();
                }
            }

            @Override
            public void onFailure(Call call, IOException e) {
                downloadCalls.remove(downloadId);
                Log.e(TAG, "Download failed: " + e.getMessage(), e);
                postToMain(() -> listener.onFail(e));
            }
        });
    }

    /**
     * 将输入流写入文件,并报告进度
     */
    private boolean writeToFile(InputStream inputStream, File targetFile,
                                long downloadedBytes, long totalBytes,
                                boolean enableResume, OnDownloadListener listener) {
        FileOutputStream fos = null;
        try {
            // 断点续传时追加模式
            fos = new FileOutputStream(targetFile, enableResume && downloadedBytes > 0);

            byte[] buffer = new byte[BUFFER_SIZE];
            int len;
            long currentDownloaded = downloadedBytes;
            int lastProgress = -1;

            // 通知开始下载
            postToMain(() -> listener.onStart(totalBytes, downloadedBytes));

            while ((len = inputStream.read(buffer)) != -1) {
                fos.write(buffer, 0, len);
                currentDownloaded += len;

                // 计算进度
                int progress = -1;
                if (totalBytes > 0) {
                    progress = (int) ((currentDownloaded * 100.0) / totalBytes);
                }

                // 只在进度变化时回调
                if (progress != lastProgress && progress >= 0) {
                    lastProgress = progress;
                    final int finalProgress = progress;
                    final long finalCurrentDownloaded = currentDownloaded;
                    postToMain(() -> listener.onProgress(totalBytes, finalCurrentDownloaded, finalProgress));
                }
            }

            fos.flush();
            Log.d(TAG, "Download completed: " + targetFile.getAbsolutePath());
            return true;
        } catch (IOException e) {
            Log.e(TAG, "Write file error: " + e.getMessage(), e);
            return false;
        } finally {
            if (fos != null) {
                try {
                    fos.close();
                } catch (IOException e) {
                    Log.w(TAG, "Close file error: " + e.getMessage());
                }
            }
        }
    }

    /**
     * 取消下载
     */
    public void cancelDownload(String url, File file) {
        String downloadId = generateDownloadId(url, file);
        Call call = downloadCalls.get(downloadId);
        if (call != null && !call.isCanceled()) {
            call.cancel();
            downloadCalls.remove(downloadId);
            Log.d(TAG, "Download canceled: " + url);
        }
    }

    /**
     * 生成下载任务ID
     */
    private String generateDownloadId(String url, File file) {
        return url + "_" + file.getAbsolutePath();
    }

    /**
     * 发送到主线程执行
     */
    private void postToMain(Runnable runnable) {
        if (Looper.myLooper() == Looper.getMainLooper()) {
            runnable.run();
        } else {
            mMainHandler.post(runnable);
        }
    }

    /**
     * 获取默认下载路径
     */
    public static String getDefaultDownloadPath() {
        return DEFAULT_DOWNLOAD_PATH;
    }
}
DownloadUtil 使用示例
java 复制代码
package com.sdt.devicemanager.utils;

import android.content.Context;
import android.util.Log;
import android.widget.Toast;

import java.io.File;

/**
 * DownloadUtil 使用示例
 * 
 * 展示如何使用 DownloadUtil 下载论坛附件
 * 下载接口:/forumTopic/download?id=10 (GET方法)
 */
public class DownloadUtilExample {
    private static final String TAG = "DownloadUtilExample";

    /**
     * 示例1:基础下载(不启用断点续传)
     * 
     * @param context 上下文
     * @param fileId  附件ID
     */
    public static void example1_BasicDownload(Context context, int fileId) {
        // 1. 构建下载URL
        String baseUrl = "https://your-domain.com"; // 替换为实际域名
        String downloadUrl = baseUrl + "/forumTopic/download?id=" + fileId;

        // 2. 设置下载路径和文件名
        String savePath = FilesUtils.getDefaultDownloadPath();
        String fileName = "forum_attachment_" + fileId + ".pdf"; // 根据实际文件类型调整

        // 3. 执行下载
        DownloadUtil.getInstance(context).download(downloadUrl, savePath, fileName,
                new DownloadUtil.OnDownloadListener() {
                    @Override
                    public void onStart(long totalBytes, long downloadedBytes) {
                        Log.d(TAG, "下载开始 - 总大小: " + FilesUtils.formatFileSize(totalBytes)
                                + ", 已下载: " + FilesUtils.formatFileSize(downloadedBytes));
                    }

                    @Override
                    public void onProgress(long totalBytes, long downloadedBytes, int progress) {
                        Log.d(TAG, "下载进度: " + progress + "%"
                                + " - " + FilesUtils.formatFileSize(downloadedBytes)
                                + "/" + FilesUtils.formatFileSize(totalBytes));
                    }

                    @Override
                    public void onSuccess(File file) {
                        Log.d(TAG, "下载成功: " + file.getAbsolutePath());
                        Toast.makeText(context, "下载成功: " + file.getName(), Toast.LENGTH_SHORT).show();
                        
                        // 可以在这里打开文件
                        // FilesUtils.openFile(context, file);
                    }

                    @Override
                    public void onFail(Exception e) {
                        Log.e(TAG, "下载失败: " + e.getMessage(), e);
                        Toast.makeText(context, "下载失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
                    }
                });
    }

    /**
     * 示例2:启用断点续传的下载
     * 
     * @param context 上下文
     * @param fileId  附件ID
     */
    public static void example2_ResumeDownload(Context context, int fileId) {
        String baseUrl = "https://your-domain.com";
        String downloadUrl = baseUrl + "/forumTopic/download?id=" + fileId;
        String savePath = FilesUtils.getDefaultDownloadPath();
        String fileName = "forum_attachment_" + fileId + ".docx";

        // 启用断点续传(第四个参数为 true)
        DownloadUtil.getInstance(context).download(downloadUrl, savePath, fileName, true,
                new DownloadUtil.OnDownloadListener() {
                    @Override
                    public void onStart(long totalBytes, long downloadedBytes) {
                        if (downloadedBytes > 0) {
                            Log.d(TAG, "断点续传 - 已下载: " + FilesUtils.formatFileSize(downloadedBytes));
                        } else {
                            Log.d(TAG, "开始新下载");
                        }
                    }

                    @Override
                    public void onProgress(long totalBytes, long downloadedBytes, int progress) {
                        Log.d(TAG, "进度: " + progress + "%");
                    }

                    @Override
                    public void onSuccess(File file) {
                        Log.d(TAG, "下载完成: " + file.getAbsolutePath());
                        Toast.makeText(context, "下载完成", Toast.LENGTH_SHORT).show();
                    }

                    @Override
                    public void onFail(Exception e) {
                        Log.e(TAG, "下载失败", e);
                        Toast.makeText(context, "下载失败", Toast.LENGTH_SHORT).show();
                    }
                });
    }

    /**
     * 示例3:批量下载多个附件
     * 
     * @param context  上下文
     * @param fileIds  附件ID数组
     */
    public static void example3_BatchDownload(Context context, int[] fileIds) {
        String baseUrl = "https://your-domain.com";
        String savePath = FilesUtils.getDefaultDownloadPath();

        for (int fileId : fileIds) {
            String downloadUrl = baseUrl + "/forumTopic/download?id=" + fileId;
            String fileName = "attachment_" + fileId + ".pdf";

            DownloadUtil.getInstance(context).download(downloadUrl, savePath, fileName,
                    new DownloadUtil.OnDownloadListener() {
                        @Override
                        public void onStart(long totalBytes, long downloadedBytes) {
                            Log.d(TAG, "文件 " + fileId + " 开始下载");
                        }

                        @Override
                        public void onProgress(long totalBytes, long downloadedBytes, int progress) {
                            // 批量下载时可以减少进度日志频率
                            if (progress % 25 == 0) { // 每25%记录一次
                                Log.d(TAG, "文件 " + fileId + " 进度: " + progress + "%");
                            }
                        }

                        @Override
                        public void onSuccess(File file) {
                            Log.d(TAG, "文件 " + fileId + " 下载成功: " + file.getName());
                        }

                        @Override
                        public void onFail(Exception e) {
                            Log.e(TAG, "文件 " + fileId + " 下载失败", e);
                        }
                    });
        }
    }

    /**
     * 示例4:取消下载
     * 
     * @param context 上下文
     * @param fileId  附件ID
     */
    public static void example4_CancelDownload(Context context, int fileId) {
        String baseUrl = "https://your-domain.com";
        String downloadUrl = baseUrl + "/forumTopic/download?id=" + fileId;
        String savePath = FilesUtils.getDefaultDownloadPath();
        String fileName = "attachment_" + fileId + ".pdf";
        File targetFile = new File(savePath, fileName);

        // 取消下载
        DownloadUtil.getInstance(context).cancelDownload(downloadUrl, targetFile);
        Log.d(TAG, "已取消下载: " + fileName);
    }

    /**
     * 示例5:在 Activity 中完整使用
     * 
     * 这是在 Activity 中的典型用法:
     * 
     * <pre>
     * public class ForumAttachmentActivity extends AppCompatActivity {
     *     
     *     private void downloadAttachment(int attachmentId, String fileName) {
     *         String baseUrl = "https://your-domain.com";
     *         String downloadUrl = baseUrl + "/forumTopic/download?id=" + attachmentId;
     *         String savePath = FilesUtils.getDefaultDownloadPath();
     *         
     *         DownloadUtil.getInstance(this).download(downloadUrl, savePath, fileName,
     *                 new DownloadUtil.OnDownloadListener() {
     *                     @Override
     *                     public void onStart(long totalBytes, long downloadedBytes) {
     *                         // 显示进度对话框
     *                         showProgressDialog();
     *                     }
     *                     
     *                     @Override
     *                     public void onProgress(long totalBytes, long downloadedBytes, int progress) {
     *                         // 更新进度条
     *                         updateProgress(progress);
     *                     }
     *                     
     *                     @Override
     *                     public void onSuccess(File file) {
     *                         // 隐藏进度对话框
     *                         hideProgressDialog();
     *                         Toast.makeText(ForumAttachmentActivity.this, 
     *                                 "下载成功: " + file.getName(), Toast.LENGTH_SHORT).show();
     *                         
     *                         // 可选:打开文件
     *                         FilesUtils.openFile(ForumAttachmentActivity.this, file);
     *                     }
     *                     
     *                     @Override
     *                     public void onFail(Exception e) {
     *                         // 隐藏进度对话框
     *                         hideProgressDialog();
     *                         Toast.makeText(ForumAttachmentActivity.this, 
     *                                 "下载失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
     *                     }
     *                 });
     *     }
     * }
     * </pre>
     */
    public static void example5_ActivityUsage() {
        // 参见方法注释中的完整示例代码
    }
}