跳到主要内容

文件上传模块

📋 模块概述

文件上传模块负责将设备本地的照片和录制视频自动上传到云端服务器,支持定时检查分段上传断点续传并发控制等高级特性,确保在各种网络环境下都能可靠地完成文件传输。

核心类:

  • android.znhaas.util.UploadUtil - 文件上传管理器
  • android.znhaas.util.ResumableUploadUtil - 断点续传工具
  • android.znhaas.util.FileUploadTimerTask - 定时上传任务
  • android.znhaas.util.FileIntegrityChecker - 文件完整性检查

上传协议: HTTPS Multipart/Form-Data

服务器端口: 2443 (SSL/TLS加密)

🎯 主要功能

核心特性

  • 定时自动上传 - 后台定时扫描并上传本地媒体文件
  • 分段上传 (Chunked Upload) - 大文件拆分为多个分片上传,降低内存占用
  • 断点续传 (Resumable Upload) - 网络中断后从上次位置继续,避免重传
  • 并发控制 - 限制同时上传数量(最多5个),避免网络拥塞
  • 文件完整性校验 - 上传前检查文件是否完整写入
  • 网络类型感知 - 根据WiFi/流量网络智能上传
  • 智能重试 - 上传失败自动重试,指数退避策略
  • Token管理 - 自动申请和刷新上传Token
  • 进度监控 - 实时上传进度回调
  • 防重复上传 - 已上传文件自动标记或删除

上传策略

场景策略说明
小文件 (照片小于5MB)单次上传直接使用HTTP Multipart上传
大文件 (视频大于10MB)分片上传自动启用断点续传,2MB/分片
网络中断断点续传保存已上传分片,下次继续
录像中/推流中暂停上传避免内存竞争,录像结束后恢复
WiFi环境全量上传上传所有未传文件
流量环境条件上传根据"数据上传"开关决定

📚 核心API

1. 获取UploadUtil实例

val uploadUtil = UploadUtil.getInstance(
context = context,
mqttClient = mqttClient,
deviceId = deviceId,
clientName = clientName,
mqttServer = mqttServer,
logUtil = logUtil
)

2. 上传单个照片

// 普通上传
val photoFile = File("/storage/emulated/0/H07/Photo/20250119_143022.jpg")
uploadUtil.uploadPhoto(photoFile, object : UploadUtil.UploadCallback {
override fun onTokenReceived(token: String, seqNo: Int) {
Log.d(TAG, "Token已接收: $token")
}

override fun onUploadProgress(progress: Int, seqNo: Int) {
Log.d(TAG, "上传进度: $progress%")
updateProgressBar(progress)
}

override fun onUploadSuccess(fileName: String, seqNo: Int) {
Log.d(TAG, "照片上传成功: $fileName")
Toast.makeText(context, "照片上传完成", Toast.LENGTH_SHORT).show()
}

override fun onUploadFailure(error: String, seqNo: Int) {
Log.e(TAG, "照片上传失败: $error")
Toast.makeText(context, "上传失败: $error", Toast.LENGTH_SHORT).show()
}
})

3. 上传单个视频 (自动分片)

// 大文件自动启用断点续传
val videoFile = File("/storage/emulated/0/H07/Video/20250119_143520.mp4")
uploadUtil.uploadVideo(videoFile, object : UploadUtil.UploadCallback {
override fun onTokenReceived(token: String, seqNo: Int) {
Log.d(TAG, "Token已接收")
}

override fun onUploadProgress(progress: Int, seqNo: Int) {
Log.d(TAG, "视频上传进度: $progress%")
progressTextView.text = "上传中: $progress%"
}

override fun onUploadSuccess(fileName: String, seqNo: Int) {
Log.d(TAG, "视频上传成功: $fileName")
}

override fun onUploadFailure(error: String, seqNo: Int) {
Log.e(TAG, "视频上传失败: $error")
}
})

4. 使用断点续传上传大文件

// 显式使用断点续传API
val largeVideoFile = File("/storage/emulated/0/H07/Video/20250119_150000.mp4")
val uploadId = uploadUtil.uploadVideoResumable(
videoFile = largeVideoFile,
callback = object : UploadUtil.ResumableUploadCallback {
override fun onUploadStarted(uploadId: String, fileName: String, totalChunks: Int) {
Log.d(TAG, "断点续传开始: $fileName, 总分片数: $totalChunks")
progressDialog.show()
}

override fun onChunkUploaded(uploadId: String, chunkIndex: Int, totalChunks: Int, progress: Int) {
Log.d(TAG, "分片上传: ${chunkIndex + 1}/$totalChunks")
// 注意: progress参数提供实际完成百分比
}

override fun onUploadProgress(uploadId: String, progress: Int, uploadedBytes: Long, totalBytes: Long) {
val uploadedMB = uploadedBytes / (1024 * 1024)
val totalMB = totalBytes / (1024 * 1024)
Log.d(TAG, "实际进度: $progress%, 已上传: ${uploadedMB}MB/${totalMB}MB")
progressDialog.setMessage("上传中: $progress% (${uploadedMB}MB/${totalMB}MB)")
}

override fun onUploadCompleted(uploadId: String, fileName: String) {
Log.d(TAG, "断点续传完成: $fileName")
progressDialog.dismiss()
Toast.makeText(context, "上传成功!", Toast.LENGTH_SHORT).show()
}

override fun onUploadFailed(uploadId: String, fileName: String, error: String) {
Log.e(TAG, "断点续传失败: $error")
progressDialog.dismiss()
Toast.makeText(context, "上传失败: $error", Toast.LENGTH_SHORT).show()
}

override fun onUploadCancelled(uploadId: String, fileName: String) {
Log.d(TAG, "上传已取消: $fileName")
progressDialog.dismiss()
}
},
useResumable = true // 强制启用断点续传
)

// 保存uploadId用于后续取消上传
currentUploadId = uploadId

5. 批量上传本地媒体文件

// 自动扫描并上传所有未传文件(先照片后视频)
uploadUtil.uploadLocalMediaFiles(object : UploadUtil.UploadCallback {
override fun onTokenReceived(token: String, seqNo: Int) {
Log.d(TAG, "批量上传Token已接收")
}

override fun onUploadProgress(progress: Int, seqNo: Int) {
Log.d(TAG, "批量上传进度: $progress%")
}

override fun onUploadSuccess(fileName: String, seqNo: Int) {
if (fileName == "所有本地媒体文件上传完成") {
Log.d(TAG, "✅ 全部文件上传完成")
showSuccessNotification()
} else {
Log.d(TAG, "✅ 文件上传成功: $fileName")
}
}

override fun onUploadFailure(error: String, seqNo: Int) {
Log.e(TAG, "批量上传失败: $error")
}
})

6. 处理MQTT上传Token响应

// 在MainActivity的MQTT消息回调中
override fun messageArrived(topic: String, message: MqttMessage) {
val messageBody = String(message.payload)

// 解析消息
val jsonObject = JSONObject(messageBody)
val command = jsonObject.getString("command")

when (command) {
"8017" -> {
// 上传Token响应
val response = jsonHandler.decodeFromString<UploadTokenResponse>(messageBody)
uploadUtil.handleUploadTokenResponse(response)
}
"8018" -> {
// 上传完成确认
val filePath = jsonObject.getString("filePath")
uploadUtil.handleUploadCompletionConfirmation(filePath)
}
}
}

7. 管理录制和推流状态

// 录像开始时暂停上传
recordingService.setOnRecordingStateChanged { isRecording ->
uploadUtil.setRecordingState(isRecording)

if (isRecording) {
Log.d(TAG, "录像开始,已暂停文件上传")
} else {
Log.d(TAG, "录像结束,文件上传将自动恢复")
}
}

// 推流开始时暂停上传
liveKitManager.setOnStreamingStateChanged { isStreaming ->
uploadUtil.setStreamingState(isStreaming)
}

8. 检查上传状态

// 检查是否有可上传的文件
val hasFiles = uploadUtil.hasUploadableFiles()
Log.d(TAG, "是否有可上传文件: $hasFiles")

// 获取上传状态信息
val statusInfo = uploadUtil.getUploadStatus()
Log.d(TAG, "上传状态: $statusInfo")
// 输出示例: "无正在进行的上传, 照片队列: 0个, 视频队列: 0个, 空闲"

// 检查是否正在上传
val isUploading = uploadUtil.hasActiveUpload()
Log.d(TAG, "是否正在上传: $isUploading")

// 获取当前待上传数量
val pendingCount = uploadUtil.getPendingUploadCount()
Log.d(TAG, "待上传文件数: $pendingCount")

9. 取消上传

// 取消所有上传操作
uploadUtil.cancelAllUploads()
Log.d(TAG, "已取消所有上传")

// 取消特定的断点续传上传
val uploadId = "abc123..."
uploadUtil.cancelResumableUpload(uploadId)
Log.d(TAG, "已取消上传: $uploadId")

10. 查询断点续传进度

// 获取断点续传上传进度
val uploadId = "abc123..."
val progress = uploadUtil.getResumableUploadProgress(uploadId)

if (progress != null) {
Log.d(TAG, "上传进度:")
Log.d(TAG, " 文件名: ${progress.fileName}")
Log.d(TAG, " 总大小: ${progress.totalSize / (1024 * 1024)}MB")
Log.d(TAG, " 总分片数: ${progress.totalChunks}")
Log.d(TAG, " 已完成分片: ${progress.getCompletedChunksCount()}")
Log.d(TAG, " 完成百分比: ${progress.getProgressPercentage()}%")
Log.d(TAG, " 是否完成: ${progress.isCompleted}")
}

// 获取所有活跃的断点续传上传
val activeUploads = uploadUtil.getActiveResumableUploads()
Log.d(TAG, "活跃上传ID列表: $activeUploads")

🔧 工作原理

1. 定时检查机制

文件上传模块集成了全局定时管理器(GlobalTimerManager),通过FileUploadTimerTask实现周期性检查:

┌─────────────────────────────────────────────┐
│ GlobalTimerManager (统一定时器) │
│ - 全局检查间隔: 60秒 │
│ - 管理所有定时任务 │
└───────────────┬─────────────────────────────┘

├──> FileUploadTimerTask
│ ├─ 检查上传开关(WiFi/数据)
│ ├─ 检查录像/推流状态
│ ├─ 检查是否有可上传文件
│ └─ 触发批量上传

├──> LocationReportTask (GPS上报)
├──> StatusReportTask (状态上报)
└──> OtherTasks...

定时上传触发条件:

  1. ✅ 上传开关已启用(WiFi或数据开关至少开启一个)
  2. ✅ 不在录像或推流状态
  3. ✅ 有可上传的文件(未标记_uploaded后缀)
  4. ✅ 文件不在上传队列或断点续传中

2. 分段上传流程

大文件(视频 >10MB, 照片 >5MB)自动启用分段上传:

┌─────────────────────────────────────────────────────┐
│ Step 1: 文件分片 (Chunking) │
│ - 分片大小: 2MB/片 │
│ - 总分片数 = (文件大小 + 2MB - 1) / 2MB │
│ - 示例: 100MB视频 → 50个分片 │
└───────────────┬─────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│ Step 2: 请求上传Token (MQTT 0017) │
│ → 服务器返回Token (MQTT 8017) │
└───────────────┬─────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│ Step 3: 顺序上传分片 (Sequential Upload) │
│ - URL: https://server:2443/resumabletransfer/upload│
│ - 参数: uploadID, chunkIndex, totalChunks, file │
│ - 每片成功后保存进度 (每10片) │
└───────────────┬─────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│ Step 4: 合并分片 (Merge Chunks) │
│ - URL: https://server:2443/resumabletransfer/merge │
│ - 服务器端合并为完整文件 │
│ - 返回最终文件URL │
└───────────────┬─────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│ Step 5: MQTT确认 (8018) │
│ - 服务器通过MQTT推送上传完成确认 │
│ - 设备收到后删除/标记本地文件 │
└─────────────────────────────────────────────────────┘

分片上传优势:

  • 降低内存占用 - 每次只读取2MB到内存,避免OOM
  • 支持断点续传 - 已上传分片持久化,网络恢复后继续
  • 提高成功率 - 小片失败只重传失败片,不影响全局
  • 零拷贝优化 - 使用ByteArrayRequestBody避免数组复制

3. 断点续传原理

断点续传通过ResumableUploadUtil实现:

// 上传进度数据结构
data class UploadProgress(
val uploadId: String, // MD5(文件名_大小)
val fileName: String, // 文件名
val totalSize: Long, // 总大小
val chunkSize: Int, // 分片大小 (2MB)
val totalChunks: Int, // 总分片数
val completedChunks: MutableSet<Int>, // 已完成分片索引集合
val createdTime: Long, // 创建时间
var isCompleted: Boolean // 是否完成
)

断点续传流程:

  1. 开始上传: 根据文件名+大小生成稳定的uploadId
  2. 保存进度: 每上传成功10个分片,保存进度到SharedPreferences
  3. 网络中断: 停止上传,进度已持久化
  4. 恢复上传: 下次检查时,加载进度,只上传剩余分片 = 全部分片 - 已完成分片
  5. 全部完成: 所有分片上传后,调用合并接口,服务器端拼接完整文件

存储位置:

  • SharedPreferences key: upload_progress_{uploadId}
  • 保存格式: JSON
  • 过期清理: 7天后自动清理

4. 并发控制策略

为避免网络拥塞和服务器压力,系统限制最大并发上传数为5:

// 并发控制参数
private const val MAX_CONCURRENT_UPLOADS = 5

并发控制流程:

批量上传开始

检查活跃上传数 (activeUploads.size)

活跃数 < 5?

├─ Yes → 启动新上传任务 (最多5-活跃数个)
│ ↓
│ 任务加入 activeUploads
│ ↓
│ 上传完成后从 activeUploads 移除

└─ No → 跳过本次批量上传,等待下次定时任务

为什么限制并发?

  • 网络拥塞 - 过多并发导致带宽争抢,单个文件速度下降
  • 服务器压力 - 同时处理过多请求,服务器响应变慢
  • OkHttp连接池 - 连接池资源有限,过多请求导致等待
  • 稳定优先 - 5个并发在速度和稳定性间平衡

5. Token管理机制

上传需要先通过MQTT申请Token:

设备 → MQTT Topic: MINIO/{clientName}/{deviceId}
↓ (command: 0017)
{
"command": "0017",
"deviceID": "123456",
"seqNo": 1234567890,
"upTime": 1737353000000
}

服务器 → MQTT Topic: MINIO/{clientName}/{deviceId}
↓ (command: 8017)
{
"command": "8017",
"result": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"deviceID": "123456"
}

Token特性:

  • 有效期: 通常30-60分钟
  • 复用: 多个文件可以复用同一个Token
  • 失效处理: 收到401 Unauthorized时,自动重新申请
  • 防重复请求: 同时只能有一个Token请求,避免重复

Token失效场景:

  1. 超时失效 - Token过期
  2. 🔄 服务器刷新 - 服务端Token策略变更
  3. 🚫 权限变更 - 设备权限被撤销

6. 文件完整性检查

上传前通过FileIntegrityChecker检查文件是否写入完成:

// 完整性检查逻辑
fun checkFileIntegrity(file: File, fileType: FileType): IntegrityResult {
// 1. 检查文件大小是否为0
if (file.length() == 0L) {
return IntegrityResult(false, "文件大小为0")
}

// 2. 检查文件是否可读
if (!file.canRead()) {
return IntegrityResult(false, "文件无法读取")
}

// 3. 根据文件类型检查头部标识
return when (fileType) {
FileType.VIDEO -> checkMp4Integrity(file)
FileType.PHOTO -> checkJpegIntegrity(file)
}
}

// MP4文件检查
private fun checkMp4Integrity(file: File): IntegrityResult {
val header = file.inputStream().use { it.readNBytes(12) }

// 检查ftyp box (文件类型盒子)
if (header.size >= 8) {
val ftypSignature = String(header.copyOfRange(4, 8))
if (ftypSignature == "ftyp") {
return IntegrityResult(true, "MP4文件头部完整")
}
}

return IntegrityResult(false, "MP4文件头部损坏")
}

// JPEG文件检查
private fun checkJpegIntegrity(file: File): IntegrityResult {
val header = file.inputStream().use { it.readNBytes(2) }
val footer = file.inputStream().use {
it.skip(file.length() - 2)
it.readNBytes(2)
}

// JPEG格式: 0xFF 0xD8 (SOI) ... 0xFF 0xD9 (EOI)
val hasValidHeader = header[0] == 0xFF.toByte() && header[1] == 0xD8.toByte()
val hasValidFooter = footer[0] == 0xFF.toByte() && footer[1] == 0xD9.toByte()

return if (hasValidHeader && hasValidFooter) {
IntegrityResult(true, "JPEG文件完整")
} else {
IntegrityResult(false, "JPEG文件损坏")
}
}

检查时机:

  • ✅ 拍照完成后立即上传 → 立即检查
  • ✅ 录屏停止后上传 → 立即检查
  • ✅ 定时批量上传 → 逐个检查

📊 上传策略详解

网络策略

// 网络类型判断
enum class NetworkType {
WIFI, // WiFi网络
CELLULAR, // 流量网络(4G/5G)
OTHER, // 其他网络(以太网等)
NONE // 无网络
}

// 上传策略
fun checkUploadSettings(): Boolean {
val wifiUpload = preferences.getBoolean("wifiUpload", true)
val dataUpload = preferences.getBoolean("dataUpload", true)
val currentNetwork = getCurrentNetworkType()

return when (currentNetwork) {
NetworkType.WIFI -> wifiUpload // WiFi: 检查WiFi开关
NetworkType.CELLULAR -> dataUpload // 流量: 检查数据开关
NetworkType.OTHER -> wifiUpload // 其他: 按WiFi处理
NetworkType.NONE -> false // 无网络: 禁止上传
}
}
网络类型WiFi开关数据开关上传行为
WiFi✅ 开启任意✅ 允许上传
WiFi❌ 关闭任意❌ 禁止上传
流量(4G/5G)任意✅ 开启✅ 允许上传
流量(4G/5G)任意❌ 关闭❌ 禁止上传
无网络任意任意❌ 禁止上传

优先级策略

上传优先级:

  1. 🥇 照片优先 - 照片文件小,优先上传完成
  2. 🥈 视频延后 - 视频文件大,后台慢速上传
  3. 🥉 FIFO顺序 - 同类文件按时间戳排序 (先拍的先传)
// 优先级队列
private val pendingPhotoUploads = ConcurrentLinkedQueue<PendingUpload>()
private val pendingVideoUploads = ConcurrentLinkedQueue<PendingUpload>()

// 取文件时先照片后视频
val nextUpload = pendingPhotoUploads.poll() ?: pendingVideoUploads.poll()

智能暂停策略

为避免与高内存操作竞争,上传模块会在以下场景自动暂停:

场景暂停原因恢复时机
录像中录像需要高速写入存储,上传会竞争I/O录像停止后自动恢复
推流中推流占用大量网络带宽和内存推流结束后自动恢复
电量过低避免耗电导致设备关机充电后恢复
// 暂停上传逻辑
fun setRecordingState(recording: Boolean) {
if (recording) {
cancelCurrentUpload() // 取消当前上传
clearUploadQueues() // 清空队列
Log.d(TAG, "录像开始,已暂停所有上传")
} else {
Log.d(TAG, "录像结束,上传将自动恢复")
// 下次定时任务自动恢复上传
}
}

🛡️ 错误处理

常见错误及解决方案

错误类型错误信息原因解决方案
Token失效401 UnauthorizedToken过期或无效自动重新申请Token并重试
网络超时SocketTimeoutException网络不稳定等待下次定时任务重试
文件不存在FileNotFoundException上传过程中文件被删除跳过该文件,继续下一个
分片上传失败Chunk upload failed单个分片上传失败下次只重传失败的分片
合并失败Merge failed服务器合并分片失败等待MQTT 8018确认或重试
队列已满Max concurrent uploads并发数达到上限等待当前上传完成

重试策略

// 指数退避重试策略
suspend fun retryReportAlarm(payload: String, maxRetries: Int = 3) {
repeat(maxRetries) { attempt ->
delay(1000 * (attempt + 1)) // 1s, 2s, 3s

mqttClient.publish(
topic = "alarm/$deviceId",
payload = payload,
qos = 2
).onSuccess {
Log.d(TAG, "重试成功 (第${attempt + 1}次)")
return
}
}

Log.e(TAG, "重试失败,已重试$maxRetries次")
}

异常日志示例

[ERROR] UploadUtil: 分片上传失败: chunkIndex=23/50, 网络超时
[WARN] UploadUtil: ⚠️ Token请求超时(31000ms > 30000ms),重置状态并重新请求
[ERROR] UploadUtil: 文件完整性检查失败: video_20250119.mp4, 原因: MP4文件头部损坏
[WARN] UploadUtil: 🚨 检测到token失效,停止当前上传: uploadId_abc123
[INFO] UploadUtil: 已达到最大并发上传数(5),跳过本次批量上传

📖 完整使用示例

示例1: 拍照后立即上传

class CameraFragment : Fragment() {
private lateinit var uploadUtil: UploadUtil

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

uploadUtil = UploadUtil.getInstance(/*...*/)

// 拍照按钮点击
btnTakePhoto.setOnClickListener {
takePhoto { photoFile ->
// 拍照成功,立即上传
uploadPhotoWithProgress(photoFile)
}
}
}

/**
* 带进度条的照片上传
*/
private fun uploadPhotoWithProgress(photoFile: File) {
// 显示进度对话框
val progressDialog = ProgressDialog(requireContext()).apply {
setTitle("上传照片")
setMessage("准备上传...")
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
max = 100
show()
}

uploadUtil.uploadPhoto(photoFile, object : UploadUtil.UploadCallback {
override fun onTokenReceived(token: String, seqNo: Int) {
progressDialog.setMessage("正在上传...")
}

override fun onUploadProgress(progress: Int, seqNo: Int) {
progressDialog.progress = progress
progressDialog.setMessage("上传中: $progress%")
}

override fun onUploadSuccess(fileName: String, seqNo: Int) {
progressDialog.dismiss()

Snackbar.make(
requireView(),
"照片上传成功!",
Snackbar.LENGTH_SHORT
).show()
}

override fun onUploadFailure(error: String, seqNo: Int) {
progressDialog.dismiss()

AlertDialog.Builder(requireContext())
.setTitle("上传失败")
.setMessage(error)
.setPositiveButton("重试") { _, _ ->
uploadPhotoWithProgress(photoFile)
}
.setNegativeButton("取消", null)
.show()
}
})
}
}

示例2: 录像停止后自动上传

class RecordingService : Service() {
private lateinit var uploadUtil: UploadUtil
private var isRecording = false

/**
* 开始录像
*/
fun startRecording() {
isRecording = true
uploadUtil.setRecordingState(true) // 暂停上传

Log.d(TAG, "录像开始,文件上传已暂停")
// 开始录像逻辑...
}

/**
* 停止录像
*/
fun stopRecording() {
// 停止录像逻辑...
val videoFile = currentVideoFile

isRecording = false
uploadUtil.setRecordingState(false) // 恢复上传

// 录像停止后,立即上传刚录制的视频
if (videoFile != null && videoFile.exists()) {
uploadVideoWithNotification(videoFile)
}
}

/**
* 带通知栏进度的视频上传
*/
private fun uploadVideoWithNotification(videoFile: File) {
val notificationId = Random.nextInt()
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_upload)
.setContentTitle("上传视频")
.setContentText("准备上传: ${videoFile.name}")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setProgress(100, 0, false)

val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(notificationId, builder.build())

uploadUtil.uploadVideo(videoFile, object : UploadUtil.UploadCallback {
override fun onTokenReceived(token: String, seqNo: Int) {
builder.setContentText("正在上传...")
notificationManager.notify(notificationId, builder.build())
}

override fun onUploadProgress(progress: Int, seqNo: Int) {
builder.setProgress(100, progress, false)
builder.setContentText("上传中: $progress%")
notificationManager.notify(notificationId, builder.build())
}

override fun onUploadSuccess(fileName: String, seqNo: Int) {
builder.setContentText("上传完成")
builder.setProgress(0, 0, false)
builder.setSmallIcon(R.drawable.ic_check)
notificationManager.notify(notificationId, builder.build())

// 3秒后自动关闭通知
Handler(Looper.getMainLooper()).postDelayed({
notificationManager.cancel(notificationId)
}, 3000)
}

override fun onUploadFailure(error: String, seqNo: Int) {
builder.setContentText("上传失败: $error")
builder.setProgress(0, 0, false)
builder.setSmallIcon(R.drawable.ic_error)
notificationManager.notify(notificationId, builder.build())
}
})
}
}

示例3: 手动触发批量上传

class SettingsActivity : AppCompatActivity() {
private lateinit var uploadUtil: UploadUtil

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)

uploadUtil = UploadUtil.getInstance(/*...*/)

// "立即上传"按钮
btnManualUpload.setOnClickListener {
startManualUpload()
}

// "清理已上传"按钮
btnCleanUploaded.setOnClickListener {
cleanUploadedFiles()
}
}

/**
* 手动触发批量上传
*/
private fun startManualUpload() {
// 检查是否有可上传文件
if (!uploadUtil.hasUploadableFiles()) {
Toast.makeText(this, "没有可上传的文件", Toast.LENGTH_SHORT).show()
return
}

// 检查网络状态
val networkType = uploadUtil.getCurrentNetworkType()
if (networkType == UploadUtil.NetworkType.NONE) {
Toast.makeText(this, "当前无网络连接", Toast.LENGTH_SHORT).show()
return
}

// 显示上传对话框
val progressDialog = ProgressDialog(this).apply {
setTitle("批量上传")
setMessage("正在扫描文件...")
setProgressStyle(ProgressDialog.STYLE_SPINNER)
setCancelable(false)
show()
}

uploadUtil.uploadLocalMediaFiles(object : UploadUtil.UploadCallback {
private var uploadedCount = 0
private var totalCount = 0

override fun onTokenReceived(token: String, seqNo: Int) {
progressDialog.setMessage("准备上传...")
}

override fun onUploadProgress(progress: Int, seqNo: Int) {
// 单个文件上传进度
}

override fun onUploadSuccess(fileName: String, seqNo: Int) {
if (fileName == "所有本地媒体文件上传完成") {
progressDialog.dismiss()

AlertDialog.Builder(this@SettingsActivity)
.setTitle("上传完成")
.setMessage("成功上传 $uploadedCount 个文件")
.setPositiveButton("确定", null)
.show()
} else {
uploadedCount++
progressDialog.setMessage("已上传: $uploadedCount 个文件")
}
}

override fun onUploadFailure(error: String, seqNo: Int) {
progressDialog.dismiss()

AlertDialog.Builder(this@SettingsActivity)
.setTitle("上传失败")
.setMessage(error)
.setPositiveButton("确定", null)
.show()
}
})
}

/**
* 清理已上传文件
*/
private fun cleanUploadedFiles() {
AlertDialog.Builder(this)
.setTitle("清理已上传文件")
.setMessage("确定要删除所有已上传的文件吗?")
.setPositiveButton("确定") { _, _ ->
// 扫描并删除带_uploaded后缀的文件
val photoDir = File(getExternalFilesDir(null), "Photo")
val videoDir = File(getExternalFilesDir(null), "Video")

var deletedCount = 0

listOf(photoDir, videoDir).forEach { dir ->
dir.listFiles()?.forEach { file ->
if (file.name.contains("_uploaded")) {
if (file.delete()) {
deletedCount++
}
}
}
}

Toast.makeText(
this,
"已删除 $deletedCount 个已上传文件",
Toast.LENGTH_SHORT
).show()
}
.setNegativeButton("取消", null)
.show()
}
}

🔧 注意事项

1. 文件命名规范

上传的文件名必须遵循特定格式以便服务器解析日期:

格式: YYYYMMDD_HHmmss.ext

示例:
- 照片: 20250119_143022.jpg
- 视频: 20250119_143520.mp4

日期提取:
- YYYY: 2025
- MM: 01
- DD: 19
- fileDate: "2025-01-19"

2. 内存管理

断点续传使用内存池复用字节数组,避免频繁GC:

// 内存池: 8个2MB缓冲区
private val chunkBufferPool = Channel<ByteArray>(capacity = 8)

init {
repeat(8) {
chunkBufferPool.trySend(ByteArray(2 * 1024 * 1024))
}
}

// 使用后归还
val buffer = chunkBufferPool.receive()
try {
// 使用buffer...
} finally {
chunkBufferPool.send(buffer)
}

3. 权限要求

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

4. 网络安全配置

服务器使用自签名SSL证书,需要配置信任:

<!-- res/xml/network_security_config.xml -->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>

<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">your-server.com</domain>
<trust-anchors>
<certificates src="@raw/server_cert" />
</trust-anchors>
</domain-config>
</network-security-config>

5. 上传后文件处理

根据用户设置,上传成功后有两种处理方式:

val deleteAfterUpload = preferences.getBoolean("deleteAfterUpload", false)

if (deleteAfterUpload) {
// 选项1: 删除文件
file.delete()
Log.d(TAG, "已删除本地文件: ${file.name}")
} else {
// 选项2: 添加已上传标识 (重命名)
val uploadedFile = File(file.parent, "${file.nameWithoutExtension}_uploaded.${file.extension}")
file.renameTo(uploadedFile)
Log.d(TAG, "已标记为已上传: ${uploadedFile.name}")
}

📖 相关资源

源码位置

  • UploadUtil: app/src/main/java/android/znhaas/util/UploadUtil.kt
  • ResumableUploadUtil: app/src/main/java/android/znhaas/util/ResumableUploadUtil.kt
  • FileUploadTimerTask: app/src/main/java/android/znhaas/util/FileUploadTimerTask.kt
  • FileIntegrityChecker: app/src/main/java/android/znhaas/util/FileIntegrityChecker.kt

依赖库

// app/build.gradle.kts
dependencies {
// HTTP客户端
implementation("com.squareup.okhttp3:okhttp:4.10.0")

// JSON序列化
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")

// 协程
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
}

相关模块

扩展阅读


最后更新: 2025-01-19
文档版本: v1.0
基于代码版本: H07 Android App (main分支)