这个问题很有挑战性,因为Android系统对后台任务的限制越来越严格,特别是在Android 8及更高版本上。让我们设计一个能在后台下载长任务队列的下载管理器,并在应用回到前台时恢复下载。
主要需要考虑的挑战:
- Android系统的电池优化和后台任务限制
- 在应用进入后台后,下载可能被系统暂停
- 在应用返回前台时需要恢复下载
- 需要正确处理应用被终止的情况
- 保持下载状态的一致性
解决方案可以包含以下组件:
- WorkManager:用于管理后台任务,即使应用被终止也能恢复任务
- ForegroundService:在下载过程中显示通知,降低系统杀死服务的概率
- Room数据库:存储下载任务状态,便于恢复
- 生命周期感知:监测应用前台/后台状态转换
- 下载队列管理:正确管理任务队列和状态转换
下面我将设计一个完整的解决方案,包括:
- 下载管理器架构
- 持久化存储
- 应用生命周期处理
- 后台服务
- 任务队列管理
- 暂停/恢复机制
Android长任务队列后台下载管理器设计
下面是一个基于WorkManager、ForegroundService和Room数据库的下载管理器实现,专注于解决Android后台下载限制问题:
import android.app.*
import android.content.Context
import android.content.Intent
import android.os.*
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.lifecycle.*
import androidx.room.*
import androidx.work.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import okhttp3.*
import java.io.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
@Entity(tableName = "download_tasks")
data class DownloadTask(
@PrimaryKey val id: String,
val url: String,
val fileName: String,
val savePath: String,
val totalBytes: Long = 0,
val downloadedBytes: Long = 0,
val status: DownloadStatus = DownloadStatus.PENDING,
val priority: Int = 0,
val createTime: Long = System.currentTimeMillis(),
val lastUpdated: Long = System.currentTimeMillis(),
val attemptCount: Int = 0,
val retryDelayMillis: Long = 0,
val chunkStart: Long = 0,
val chunkEnd: Long = 0
)
enum class DownloadStatus {
PENDING,
CONNECTING,
DOWNLOADING,
PAUSED,
COMPLETED,
FAILED,
CANCELED
}
@Dao
interface DownloadTaskDao {
@Query("SELECT * FROM download_tasks ORDER BY priority DESC, createTime ASC")
fun getAllTasks(): Flow<List<DownloadTask>>
@Query("SELECT * FROM download_tasks WHERE status IN (:statuses) ORDER BY priority DESC, createTime ASC")
fun getTasksByStatus(statuses: List<DownloadStatus>): Flow<List<DownloadTask>>
@Query("SELECT * FROM download_tasks WHERE id = :taskId")
suspend fun getTaskById(taskId: String): DownloadTask?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTask(task: DownloadTask)
@Update
suspend fun updateTask(task: DownloadTask)
@Delete
suspend fun deleteTask(task: DownloadTask)
@Query("DELETE FROM download_tasks WHERE status = :status")
suspend fun deleteTasksByStatus(status: DownloadStatus)
@Query("UPDATE download_tasks SET status = :newStatus WHERE status = :currentStatus")
suspend fun updateTasksStatus(currentStatus: DownloadStatus, newStatus: DownloadStatus)
}
@Database(entities = [DownloadTask::class], version = 1, exportSchema = false)
abstract class DownloadDatabase : RoomDatabase() {
abstract fun downloadTaskDao(): DownloadTaskDao
companion object {
@Volatile
private var INSTANCE: DownloadDatabase? = null
fun getDatabase(context: Context): DownloadDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DownloadDatabase::class.java,
"download_database"
).build()
INSTANCE = instance
instance
}
}
}
}
sealed class DownloadEvent {
data class Progress(val taskId: String, val progress: Int, val downloadedBytes: Long, val totalBytes: Long) : DownloadEvent()
data class StatusChanged(val taskId: String, val status: DownloadStatus) : DownloadEvent()
data class Error(val taskId: String, val error: Throwable) : DownloadEvent()
data class Complete(val taskId: String, val filePath: String) : DownloadEvent()
}
class DownloadService : Service() {
companion object {
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "download_channel"
private const val CHANNEL_NAME = "Downloads"
fun startService(context: Context) {
val intent = Intent(context, DownloadService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stopService(context: Context) {
val intent = Intent(context, DownloadService::class.java)
context.stopService(intent)
}
}
private lateinit var notificationManager: NotificationManager
private var isServiceRunning = false
override fun onCreate() {
super.onCreate()
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!isServiceRunning) {
isServiceRunning = true
startForeground(NOTIFICATION_ID, createNotification("下载管理器正在运行"))
BackgroundDownloadManager.getInstance(applicationContext).onServiceStarted()
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
isServiceRunning = false
BackgroundDownloadManager.getInstance(applicationContext).onServiceStopped()
super.onDestroy()
}
fun updateNotification(title: String, message: String, progress: Int) {
val notification = createNotification(title, message, progress)
notificationManager.notify(NOTIFICATION_ID, notification)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
setShowBadge(false)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
notificationManager.createNotificationChannel(channel)
}
}
private fun createNotification(
title: String,
message: String = "正在处理下载任务",
progress: Int = 0
): Notification {
val pendingIntent = PendingIntent.getActivity(
this,
0,
packageManager.getLaunchIntentForPackage(packageName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOnlyAlertOnce(true)
.setOngoing(true)
if (progress > 0) {
builder.setProgress(100, progress, false)
}
return builder.build()
}
}
class DownloadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
const val KEY_TASK_ID = "task_id"
private const val TAG_DOWNLOAD = "download_worker"
fun enqueueWork(context: Context, taskId: String): Operation {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val inputData = workDataOf(KEY_TASK_ID to taskId)
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setConstraints(constraints)
.setInputData(inputData)
.addTag(TAG_DOWNLOAD)
.addTag(taskId)
.setBackoffCriteria(
BackoffPolicy.LINEAR,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
return WorkManager.getInstance(context)
.enqueueUniqueWork(
taskId,
ExistingWorkPolicy.REPLACE,
request
)
}
fun cancelWork(context: Context, taskId: String) {
WorkManager.getInstance(context).cancelUniqueWork(taskId)
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val taskId = inputData.getString(KEY_TASK_ID) ?: return@withContext Result.failure()
val downloadManager = BackgroundDownloadManager.getInstance(applicationContext)
return@withContext try {
val task = downloadManager.getTaskById(taskId) ?: return@withContext Result.failure()
downloadManager.updateTaskStatus(task.id, DownloadStatus.DOWNLOADING)
downloadManager.performDownload(task)
Result.success()
} catch (e: Exception) {
if (e is InterruptedException || e.cause is InterruptedException) {
downloadManager.updateTaskStatus(taskId, DownloadStatus.PAUSED)
Result.retry()
} else {
downloadManager.updateTaskStatus(taskId, DownloadStatus.FAILED)
Result.failure()
}
}
}
}
class AppLifecycleObserver(private val context: Context) : DefaultLifecycleObserver {
private val downloadManager = BackgroundDownloadManager.getInstance(context)
override fun onStart(owner: LifecycleOwner) {
downloadManager.onAppForeground()
}
override fun onStop(owner: LifecycleOwner) {
downloadManager.onAppBackground()
}
}
class BackgroundDownloadManager private constructor(private val context: Context) {
companion object {
@Volatile
private var INSTANCE: BackgroundDownloadManager? = null
fun getInstance(context: Context): BackgroundDownloadManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: BackgroundDownloadManager(context.applicationContext).also {
INSTANCE = it
}
}
}
}
private val database = DownloadDatabase.getDatabase(context)
private val downloadTaskDao = database.downloadTaskDao()
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val downloadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _downloadEvents = MutableSharedFlow<DownloadEvent>(extraBufferCapacity = 100)
val downloadEvents: SharedFlow<DownloadEvent> = _downloadEvents.asSharedFlow()
val allTasks = downloadTaskDao.getAllTasks()
val activeTasks = downloadTaskDao.getTasksByStatus(
listOf(DownloadStatus.PENDING, DownloadStatus.CONNECTING, DownloadStatus.DOWNLOADING)
)
private val isAppInForeground = AtomicBoolean(true)
private val isServiceRunning = AtomicBoolean(false)
private val activeDownloads = AtomicInteger(0)
init {
downloadScope.launch {
downloadTaskDao.updateTasksStatus(DownloadStatus.DOWNLOADING, DownloadStatus.PENDING)
downloadTaskDao.updateTasksStatus(DownloadStatus.CONNECTING, DownloadStatus.PENDING)
downloadTaskDao.getTasksByStatus(listOf(DownloadStatus.PENDING)).first().forEach { task ->
enqueueDownload(task.id)
}
}
}
suspend fun addDownloadTask(
url: String,
fileName: String,
savePath: String,
priority: Int = 0
): String {
val taskId = "${url.hashCode()}_${System.currentTimeMillis()}"
val downloadTask = DownloadTask(
id = taskId,
url = url,
fileName = fileName,
savePath = savePath,
priority = priority,
status = DownloadStatus.PENDING
)
downloadTaskDao.insertTask(downloadTask)
enqueueDownload(taskId)
return taskId
}
suspend fun getTaskById(taskId: String): DownloadTask? {
return downloadTaskDao.getTaskById(taskId)
}
suspend fun updateTaskStatus(taskId: String, status: DownloadStatus) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
val updatedTask = task.copy(
status = status,
lastUpdated = System.currentTimeMillis()
)
downloadTaskDao.updateTask(updatedTask)
_downloadEvents.emit(DownloadEvent.StatusChanged(taskId, status))
}
suspend fun pauseDownload(taskId: String) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
if (task.status == DownloadStatus.DOWNLOADING || task.status == DownloadStatus.CONNECTING) {
DownloadWorker.cancelWork(context, taskId)
updateTaskStatus(taskId, DownloadStatus.PAUSED)
}
}
suspend fun resumeDownload(taskId: String) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
if (task.status == DownloadStatus.PAUSED || task.status == DownloadStatus.FAILED) {
updateTaskStatus(taskId, DownloadStatus.PENDING)
enqueueDownload(taskId)
}
}
suspend fun cancelDownload(taskId: String) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
DownloadWorker.cancelWork(context, taskId)
updateTaskStatus(taskId, DownloadStatus.CANCELED)
val file = File(task.savePath, task.fileName)
if (file.exists()) {
file.delete()
}
}
suspend fun deleteTask(taskId: String) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
if (task.status == DownloadStatus.DOWNLOADING || task.status == DownloadStatus.CONNECTING) {
DownloadWorker.cancelWork(context, taskId)
}
downloadTaskDao.deleteTask(task)
}
suspend fun clearCompletedTasks() {
downloadTaskDao.deleteTasksByStatus(DownloadStatus.COMPLETED)
}
fun onAppForeground() {
isAppInForeground.set(true)
downloadScope.launch {
downloadTaskDao.getTasksByStatus(listOf(DownloadStatus.PAUSED)).first().forEach { task ->
if (task.attemptCount > 0) {
resumeDownload(task.id)
}
}
}
}
fun onAppBackground() {
isAppInForeground.set(false)
val continueInBackground = true
if (!continueInBackground) {
downloadScope.launch {
downloadTaskDao.getTasksByStatus(
listOf(DownloadStatus.DOWNLOADING, DownloadStatus.CONNECTING)
).first().forEach { task ->
pauseDownload(task.id)
}
}
} else {
if (activeDownloads.get() > 0 && !isServiceRunning.get()) {
DownloadService.startService(context)
}
}
}
fun onServiceStarted() {
isServiceRunning.set(true)
}
fun onServiceStopped() {
isServiceRunning.set(false)
}
private fun enqueueDownload(taskId: String) {
if (!isAppInForeground.get()) {
DownloadService.startService(context)
}
DownloadWorker.enqueueWork(context, taskId)
activeDownloads.incrementAndGet()
}
suspend fun performDownload(task: DownloadTask) = withContext(Dispatchers.IO) {
try {
val saveDir = File(task.savePath)
if (!saveDir.exists()) {
saveDir.mkdirs()
}
val file = File(saveDir, task.fileName)
var totalBytes = task.totalBytes
var downloadedBytes = task.downloadedBytes
if (file.exists() && downloadedBytes > 0) {
if (file.length() < downloadedBytes) {
file.delete()
downloadedBytes = 0
}
} else if (downloadedBytes > 0) {
downloadedBytes = 0
}
val request = Request.Builder()
.url(task.url)
.apply {
if (downloadedBytes > 0) {
header("Range", "bytes=$downloadedBytes-")
}
}
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("HTTP error: ${response.code}")
}
val body = response.body ?: throw IOException("Empty response body")
val contentLength = body.contentLength()
if (totalBytes <= 0 && contentLength > 0) {
totalBytes = contentLength + downloadedBytes
val updatedTask = task.copy(totalBytes = totalBytes)
downloadTaskDao.updateTask(updatedTask)
}
val outputStream = if (downloadedBytes > 0) {
FileOutputStream(file, true)
} else {
FileOutputStream(file)
}
outputStream.use { output ->
val buffer = ByteArray(8192)
val input = body.byteStream()
var bytesRead: Int
var lastProgressUpdate = 0L
while (input.read(buffer).also { bytesRead = it } != -1) {
if (!isAppInForeground.get() && !isServiceRunning.get()) {
throw InterruptedException("Download paused due to app background")
}
output.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead
val now = System.currentTimeMillis()
if (now - lastProgressUpdate > 500) {
lastProgressUpdate = now
val progress = if (totalBytes > 0) {
(downloadedBytes * 100 / totalBytes).toInt()
} else {
0
}
val updatedTask = task.copy(
downloadedBytes = downloadedBytes,
lastUpdated = now
)
downloadTaskDao.updateTask(updatedTask)
_downloadEvents.emit(
DownloadEvent.Progress(
task.id,
progress,
downloadedBytes,
totalBytes
)
)
if (isServiceRunning.get()) {
(context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager)?.let { notificationManager ->
val notification = NotificationCompat.Builder(context, DownloadService.CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle("正在下载 ${task.fileName}")
.setContentText("$progress% - ${formatBytes(downloadedBytes)}/${formatBytes(totalBytes)}")
.setProgress(100, progress, false)
.build()
notificationManager.notify(DownloadService.NOTIFICATION_ID, notification)
}
}
}
}
}
val finalTask = task.copy(
status = DownloadStatus.COMPLETED,
downloadedBytes = totalBytes,
lastUpdated = System.currentTimeMillis()
)
downloadTaskDao.updateTask(finalTask)
_downloadEvents.emit(DownloadEvent.Complete(task.id, file.absolutePath))
val remaining = activeDownloads.decrementAndGet()
if (remaining <= 0 && isServiceRunning.get()) {
DownloadService.stopService(context)
}
}
} catch (e: Exception) {
activeDownloads.decrementAndGet()
throw e
}
}
private fun formatBytes(bytes: Long): String {
if (bytes <= 0) return "0 B"
val units = arrayOf("B", "KB", "MB", "GB", "TB")
val digitGroups = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt()
return String.format("%.1f %s", bytes / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups])
}
}
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import androidx.work.*
import java.io.IOException
import java.util.concurrent.TimeUnit
class NetworkMonitor(private val context: Context) {
interface NetworkListener {
fun onNetworkAvailable()
fun onNetworkUnavailable()
}
private val listeners = mutableListOf<NetworkListener>()
private var isNetworkAvailable = false
private var isMonitoring = false
private val connectivityManager by lazy {
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val wasUnavailable = !isNetworkAvailable
isNetworkAvailable = true
if (wasUnavailable) {
notifyNetworkAvailable()
}
}
override fun onLost(network: Network) {
if (isAnyNetworkAvailable()) {
return
}
isNetworkAvailable = false
notifyNetworkUnavailable()
}
}
private val networkReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val wasUnavailable = !isNetworkAvailable
isNetworkAvailable = isAnyNetworkAvailable()
if (isNetworkAvailable && wasUnavailable) {
notifyNetworkAvailable()
} else if (!isNetworkAvailable) {
notifyNetworkUnavailable()
}
}
}
fun startMonitoring() {
if (isMonitoring) return
isNetworkAvailable = isAnyNetworkAvailable()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)
} else {
val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
context.registerReceiver(networkReceiver, filter)
}
isMonitoring = true
}
fun stopMonitoring() {
if (!isMonitoring) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityManager.unregisterNetworkCallback(networkCallback)
} else {
try {
context.unregisterReceiver(networkReceiver)
} catch (e: Exception) {
}
}
isMonitoring = false
}
fun addListener(listener: NetworkListener) {
if (!listeners.contains(listener)) {
listeners.add(listener)
}
}
fun removeListener(listener: NetworkListener) {
listeners.remove(listener)
}
fun isNetworkAvailable(): Boolean {
return isAnyNetworkAvailable()
}
private fun isAnyNetworkAvailable(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} else {
@Suppress("DEPRECATION")
val networkInfo = connectivityManager.activeNetworkInfo
return networkInfo != null && networkInfo.isConnected
}
}
private fun notifyNetworkAvailable() {
listeners.forEach { it.onNetworkAvailable() }
}
private fun notifyNetworkUnavailable() {
listeners.forEach { it.onNetworkUnavailable() }
}
}
class NetworkAwareDownloadWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
companion object {
const val TAG_DOWNLOAD_WORKER = "network_aware_download_worker"
const val KEY_TASK_ID = "task_id"
const val KEY_RETRY_COUNT = "retry_count"
const val MAX_RETRY_COUNT = 10
const val NOTIFICATION_CHANNEL_ID = "download_channel"
const val NOTIFICATION_ID = 1
fun scheduleDownload(context: Context, taskId: String, retryCount: Int = 0) {
val data = workDataOf(
KEY_TASK_ID to taskId,
KEY_RETRY_COUNT to retryCount
)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresStorageNotLow(true)
.build()
val workRequest = OneTimeWorkRequestBuilder<NetworkAwareDownloadWorker>()
.setConstraints(constraints)
.setInputData(data)
.addTag(TAG_DOWNLOAD_WORKER)
.addTag(taskId)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"download_$taskId",
ExistingWorkPolicy.REPLACE,
workRequest
)
}
fun cancelDownload(context: Context, taskId: String) {
WorkManager.getInstance(context)
.cancelUniqueWork("download_$taskId")
}
}
override suspend fun doWork(): Result {
val taskId = inputData.getString(KEY_TASK_ID) ?: return Result.failure()
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
val downloadManager = NetworkAwareDownloadManager.getInstance(applicationContext)
val task = downloadManager.getTaskById(taskId) ?: return Result.failure()
downloadManager.updateTaskStatus(taskId, DownloadStatus.RUNNING)
try {
setForeground(createForegroundInfo("准备下载..."))
downloadManager.downloadFile(task, this)
downloadManager.updateTaskStatus(taskId, DownloadStatus.COMPLETED)
return Result.success()
} catch (e: Exception) {
e.printStackTrace()
val isNetworkError = e is IOException &&
(e.message?.contains("network", ignoreCase = true) == true ||
e.message?.contains("connect", ignoreCase = true) == true ||
e.message?.contains("timeout", ignoreCase = true) == true)
if (isNetworkError) {
downloadManager.updateTaskStatus(taskId, DownloadStatus.WAITING_FOR_NETWORK)
if (retryCount < MAX_RETRY_COUNT) {
val newRetryCount = retryCount + 1
val delayInSeconds = Math.min(30, Math.pow(2.0, newRetryCount.toDouble())).toLong()
val retryData = workDataOf(
KEY_TASK_ID to taskId,
KEY_RETRY_COUNT to newRetryCount
)
return Result.retry()
}
}
downloadManager.updateTaskStatus(taskId, DownloadStatus.FAILED)
return Result.failure()
}
}
private fun createForegroundInfo(message: String, progress: Int = 0): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle("下载中")
.setContentText(message)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.apply {
if (progress > 0) {
setProgress(100, progress, false)
}
}
.build()
return ForegroundInfo(NOTIFICATION_ID, notification)
}
}
enum class DownloadStatus {
PENDING,
RUNNING,
PAUSED,
WAITING_FOR_NETWORK,
COMPLETED,
FAILED,
CANCELED
}
class NetworkAwareDownloadManager private constructor(private val context: Context) : NetworkMonitor.NetworkListener {
companion object {
@Volatile
private var INSTANCE: NetworkAwareDownloadManager? = null
fun getInstance(context: Context): NetworkAwareDownloadManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: NetworkAwareDownloadManager(context.applicationContext).also {
INSTANCE = it
}
}
}
}
private val downloadTaskDao: DownloadTaskDao by lazy {
DownloadDatabase.getDatabase(context).downloadTaskDao()
}
private val networkMonitor = NetworkMonitor(context)
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val downloadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _downloadEvents = MutableSharedFlow<DownloadEvent>()
val downloadEvents = _downloadEvents.asSharedFlow()
val allTasks = downloadTaskDao.getAllTasks()
init {
networkMonitor.addListener(this)
networkMonitor.startMonitoring()
downloadScope.launch {
if (networkMonitor.isNetworkAvailable()) {
downloadTaskDao.getTasksByStatus(DownloadStatus.WAITING_FOR_NETWORK).first().forEach { task ->
resumeDownloadWithNetwork(task.id)
}
}
}
}
override fun onNetworkAvailable() {
downloadScope.launch {
downloadTaskDao.getTasksByStatus(DownloadStatus.WAITING_FOR_NETWORK).first().forEach { task ->
resumeDownloadWithNetwork(task.id)
}
}
}
override fun onNetworkUnavailable() {
downloadScope.launch {
downloadTaskDao.getTasksByStatus(DownloadStatus.RUNNING).first().forEach { task ->
pauseDownloadDueToNetwork(task.id)
}
}
}
suspend fun addDownloadTask(
url: String,
fileName: String,
savePath: String,
priority: Int = 0
): String {
val taskId = "${url.hashCode()}_${System.currentTimeMillis()}"
val saveDir = File(savePath)
if (!saveDir.exists()) {
saveDir.mkdirs()
}
val task = DownloadTask(
id = taskId,
url = url,
fileName = fileName,
savePath = savePath,
status = if (networkMonitor.isNetworkAvailable()) DownloadStatus.PENDING else DownloadStatus.WAITING_FOR_NETWORK,
priority = priority
)
downloadTaskDao.insertTask(task)
if (networkMonitor.isNetworkAvailable()) {
NetworkAwareDownloadWorker.scheduleDownload(context, taskId)
} else {
_downloadEvents.emit(
DownloadEvent.StatusChanged(
taskId,
DownloadStatus.WAITING_FOR_NETWORK
)
)
}
return taskId
}
suspend fun pauseDownload(taskId: String) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
if (task.status == DownloadStatus.RUNNING ||
task.status == DownloadStatus.PENDING ||
task.status == DownloadStatus.WAITING_FOR_NETWORK) {
NetworkAwareDownloadWorker.cancelDownload(context, taskId)
updateTaskStatus(taskId, DownloadStatus.PAUSED)
}
}
private suspend fun pauseDownloadDueToNetwork(taskId: String) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
if (task.status == DownloadStatus.RUNNING || task.status == DownloadStatus.PENDING) {
updateTaskStatus(taskId, DownloadStatus.WAITING_FOR_NETWORK)
}
}
suspend fun resumeDownload(taskId: String) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
if (task.status == DownloadStatus.PAUSED || task.status == DownloadStatus.FAILED) {
if (networkMonitor.isNetworkAvailable()) {
updateTaskStatus(taskId, DownloadStatus.PENDING)
NetworkAwareDownloadWorker.scheduleDownload(context, taskId)
} else {
updateTaskStatus(taskId, DownloadStatus.WAITING_FOR_NETWORK)
}
}
}
private suspend fun resumeDownloadWithNetwork(taskId: String) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
if (task.status == DownloadStatus.WAITING_FOR_NETWORK) {
updateTaskStatus(taskId, DownloadStatus.PENDING)
NetworkAwareDownloadWorker.scheduleDownload(context, taskId)
}
}
suspend fun cancelDownload(taskId: String) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
NetworkAwareDownloadWorker.cancelDownload(context, taskId)
updateTaskStatus(taskId, DownloadStatus.CANCELED)
val file = File(task.savePath, task.fileName)
if (file.exists()) {
file.delete()
}
}
suspend fun deleteTask(taskId: String) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
if (task.status != DownloadStatus.COMPLETED) {
NetworkAwareDownloadWorker.cancelDownload(context, taskId)
}
downloadTaskDao.deleteTask(task)
}
suspend fun getTaskById(taskId: String): DownloadTask? {
return downloadTaskDao.getTaskById(taskId)
}
suspend fun updateTaskStatus(taskId: String, status: DownloadStatus) {
val task = downloadTaskDao.getTaskById(taskId) ?: return
val updatedTask = task.copy(
status = status,
lastUpdated = System.currentTimeMillis()
)
downloadTaskDao.updateTask(updatedTask)
_downloadEvents.emit(DownloadEvent.StatusChanged(taskId, status))
}
suspend fun downloadFile(task: DownloadTask, worker: NetworkAwareDownloadWorker) {
if (!networkMonitor.isNetworkAvailable()) {
throw IOException("网络不可用")
}
val saveDir = File(task.savePath)
if (!saveDir.exists()) {
saveDir.mkdirs()
}
val file = File(saveDir, task.fileName)
var totalBytes = task.totalBytes
var downloadedBytes = task.downloadedBytes
if (file.exists() && downloadedBytes > 0) {
val fileLength = file.length()
if (fileLength != downloadedBytes) {
downloadedBytes = fileLength
}
} else if (downloadedBytes > 0) {
downloadedBytes = 0
}
val requestBuilder = Request.Builder()
.url(task.url)
if (downloadedBytes > 0) {
requestBuilder.addHeader("Range", "bytes=$downloadedBytes-")
}
val request = requestBuilder.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("HTTP error: ${response.code}")
}
val body = response.body ?: throw IOException("Empty response body")
val contentLength = body.contentLength()
if (totalBytes <= 0 && contentLength > 0) {
totalBytes = when {
response.code == 206 -> {
val contentRange = response.header("Content-Range")
if (contentRange != null) {
val rangeMatcher = Regex("bytes \\d+-\\d+/(\\d+)").find(contentRange)
rangeMatcher?.groupValues?.get(1)?.toLongOrNull() ?: (downloadedBytes + contentLength)
} else {
downloadedBytes + contentLength
}
}
else -> contentLength
}
val updatedTask = task.copy(totalBytes = totalBytes)
downloadTaskDao.updateTask(updatedTask)
}
val outputStream = if (downloadedBytes > 0) {
FileOutputStream(file, true)
} else {
FileOutputStream(file)
}
outputStream.use { output ->
val buffer = ByteArray(8192)
val input = body.byteStream()
var bytesRead: Int
var lastProgressUpdate = 0L
while (input.read(buffer).also { bytesRead = it } != -1) {
if (!networkMonitor.isNetworkAvailable()) {
throw IOException("网络连接已断开")
}
if (worker.isStopped) {
throw InterruptedException("下载已停止")
}
output.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead
val now = System.currentTimeMillis()
if (now - lastProgressUpdate > 500) {
lastProgressUpdate = now
val progress = if (totalBytes > 0) {
(downloadedBytes * 100 / totalBytes).toInt()
} else 0
val updatedTask = task.copy(
downloadedBytes = downloadedBytes,
lastUpdated = now
)
downloadTaskDao.updateTask(updatedTask)
_downloadEvents.emit(
DownloadEvent.Progress(
task.id,
progress,
downloadedBytes,
totalBytes
)
)
worker.setForeground(
worker.createForegroundInfo(
"下载中...",
progress
)
)
}
}
}
}
val updatedTask = task.copy(
downloadedBytes = totalBytes,
status = DownloadStatus.COMPLETED,
lastUpdated = System.currentTimeMillis()
)
downloadTaskDao.updateTask(updatedTask)
_downloadEvents.emit(
DownloadEvent.Complete(
task.id,
File(task.savePath, task.fileName).absolutePath
)
)
}
fun release() {
networkMonitor.removeListener(this)
networkMonitor.stopMonitoring()
downloadScope.cancel()
}
}