Skip to content

Instantly share code, notes, and snippets.

@talenguyen
Last active July 19, 2023 23:20
Show Gist options
  • Save talenguyen/ede0204c2ad6d152ed4c0571c6cf3360 to your computer and use it in GitHub Desktop.
Save talenguyen/ede0204c2ad6d152ed4c0571c6cf3360 to your computer and use it in GitHub Desktop.
@file:Suppress("BlockingMethodInNonBlockingContext")
package features.file.download
import android.content.Context
import androidx.hilt.Assisted
import androidx.hilt.work.WorkerInject
import androidx.lifecycle.Observer
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.NetworkType.CONNECTED
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkInfo.State.RUNNING
import androidx.work.WorkInfo.State.SUCCEEDED
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import dagger.hilt.android.qualifiers.ApplicationContext
import features.file.data.repo.FileApi
import features.file.download.DownloadState.Status.Failed
import features.file.download.DownloadState.Status.NotFound
import features.file.download.DownloadState.Status.Running
import features.file.download.DownloadState.Status.Succeed
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.File
import java.io.IOException
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
const val URL = "url"
const val PROGRESS = "progress"
const val OUTPUT_PATH = "outputPath"
data class DownloadState(
val status: Status,
val progress: Float = 0.0f,
val url: String? = null,
val outputPath: String? = null
) {
enum class Status {
Succeed,
Failed,
Running,
NotFound
}
}
@ExperimentalCoroutinesApi
@Singleton
class DownloadManager @Inject constructor(
@ApplicationContext private val context: Context,
private val api: FileApi
) {
@ExperimentalCoroutinesApi
class ProgressWorker @WorkerInject constructor(
@Assisted context: Context,
@Assisted parameters: WorkerParameters,
private val api: FileApi
) : CoroutineWorker(context, parameters) {
private val url: String = inputData.getString(URL)!!
private val outputPath: String = inputData.getString(OUTPUT_PATH)!!
override suspend fun doWork(): Result {
val downloadRequest: Call<ResponseBody> = api.downloadFile(url)
download(downloadRequest, outputPath).collect {
println("doWork => $it")
val update = workDataOf(PROGRESS to it)
setProgress(update)
}
return Result.success()
}
}
private class RequestData(
val uuid: UUID,
val url: String,
val outputPath: String
)
private val requests = mutableMapOf<String, RequestData>()
fun downloadWorker(
url: String,
outputPath: String
) {
val data = Data.Builder()
.putString(URL, url)
.putString(OUTPUT_PATH, outputPath)
.build()
val request = OneTimeWorkRequestBuilder<ProgressWorker>()
.setConstraints(
Constraints
.Builder()
.setRequiredNetworkType(CONNECTED)
.build()
)
.setInputData(data)
.build()
requests[url] = RequestData(request.id, url, outputPath)
WorkManager.getInstance(context)
.enqueue(request)
}
fun downloadFlow(
url: String,
outputPath: String
): Flow<DownloadState> {
val downloadRequest: Call<ResponseBody> = api.downloadFile(url)
return download(downloadRequest, outputPath)
.map {
if (it < 1.0) {
DownloadState(Running, it, url, outputPath)
} else {
DownloadState(Succeed, 1.0f, url, outputPath)
}
}
.catch {
it.printStackTrace()
emit(DownloadState(Failed))
}
}
fun getState(url: String): Flow<DownloadState> = callbackFlow {
val request: RequestData? = requests[url]
if (request == null) {
offer(DownloadState(NotFound))
awaitClose { }
} else {
val workInfoLiveData = WorkManager.getInstance(context)
// requestId is the WorkRequest id
.getWorkInfoByIdLiveData(request.uuid)
val observer: Observer<WorkInfo> = Observer { workInfo ->
if (workInfo.state.isFinished) {
when (workInfo.state) {
SUCCEEDED -> offer(
DownloadState(Succeed, progress = 1.0f, request.url, request.outputPath)
)
else -> offer(DownloadState(Failed, progress = 0.0f, request.url, request.outputPath))
}
requests.remove(url)
close()
} else {
if (workInfo.state == RUNNING) {
val progress = workInfo.progress.getFloat(PROGRESS, 0f)
offer(
DownloadState(
Running, progress = progress, request.url, request.outputPath
)
)
}
}
}
workInfoLiveData.observeForever(observer)
awaitClose { workInfoLiveData.removeObserver(observer) }
}
}
}
@ExperimentalCoroutinesApi
fun download(
downloadRequest: Call<ResponseBody>,
output: String
) = callbackFlow<Float> {
downloadRequest.enqueue(object : Callback<ResponseBody> {
override fun onResponse(
call: Call<ResponseBody>,
response: Response<ResponseBody>
) {
if (response.isSuccessful) {
// make2h container directory
mkdirContainerIfNotExists(output)
val downloading = File("$output.downloading")
// delete the old downloading if exists
deleteIfExists(downloading)
response.body()?.let {
val total = it.contentLength().toFloat()
it.byteStream().use { inputStream ->
downloading.outputStream().use { outputStream ->
var count = 0
val buffer = ByteArray(2048)
while (true) {
val read = inputStream.read(buffer)
if (read == -1) {
break
}
outputStream.write(buffer, 0, read)
count += read
val progress = count / total
if (progress < 1.0) {
offer(progress) // This will make sure we only send progress 1.0 once.
}
}
}
}
}
val outputFile = File(output)
// delete output if existed
deleteIfExists(outputFile)
// rename output.downloading to output then callback.onSuccess
if (!downloading.renameTo(outputFile)) {
close(IOException("can't rename file"))
} else {
offer(1.0f)
close()
}
} else {
close()
}
}
override fun onFailure(
call: Call<ResponseBody>,
t: Throwable
) {
close(t)
}
})
awaitClose { downloadRequest.cancel() }
}
@Throws(IOException::class)
internal fun deleteIfExists(downloading: File) {
if (downloading.exists() && !downloading.delete()) {
throw IOException("can't delete file " + downloading.absolutePath)
}
}
@Throws(IOException::class)
internal fun mkdirContainerIfNotExists(output: String) {
val container = File(output).parentFile ?: return
if (container.isFile) {
// If the container is a file then delete is to create a directory with given name.
container.delete()
}
if (!container.exists() && !container.mkdirs()) {
throw IOException("can not create " + container.absolutePath)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment