diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 08bc8d2..e7656a4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { android { namespace = "com.gunishjain.sample" - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "com.gunishjain.sample" @@ -41,6 +41,8 @@ android { dependencies { + implementation(project(":grabbit")) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -56,4 +58,8 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + + + + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 65d52da..1445a31 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,11 @@ + + + - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } - } + DownloadUI() } } } @Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} +fun DownloadUI() { + val context = LocalContext.current + val grabbit = (context.applicationContext as MyApplication).grabbit -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - GrabbitTheme { - Greeting("Android") + var downloadProgress by remember { mutableIntStateOf(0) } + var isDownloading by remember { mutableStateOf(false) } + var isPaused by remember { mutableStateOf(false) } + var downloadId by remember { mutableIntStateOf(-1) } + + // Start download + fun startDownload(url: String, dirPath: String, fileName: String) { + val request = grabbit.newRequest(url, dirPath, fileName).build() + + downloadId = grabbit.enqueue( + request, + onStart = { + isDownloading = true + isPaused = false + }, + onProgress = { progress -> + downloadProgress = progress + }, + onPause = { + isPaused = true + }, + onCompleted = { + isDownloading = false + }, + onError = { error -> + // Handle error (optional) + } + ) + } + + // Pause download + fun pauseDownload() { + grabbit.pause(downloadId) + } + + // Resume download + fun resumeDownload() { + grabbit.resume(downloadId) + } + + // Cancel download + fun cancelDownload() { + grabbit.cancel(downloadId) + isDownloading = false + downloadProgress = 0 } -} \ No newline at end of file + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Progress bar + Text("Download Progress: $downloadProgress%") + LinearProgressIndicator( + progress = downloadProgress / 100f, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Buttons for control + if (isDownloading) { + if (isPaused) { + Button(onClick = { resumeDownload() }) { + Text("Resume") + } + } else { + Button(onClick = { pauseDownload() }) { + Text("Pause") + } + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { cancelDownload() }) { + Text("Cancel") + } + } else { + Button(onClick = { + startDownload( + url = "https://www.learningcontainer.com/download/sample-50-mb-pdf-file/?wpdmdl=3675&refresh=6721f942bd70b1730279746", + dirPath = "/downloads", + fileName = "gunish.pdf" + ) + }) { + Text("Download") + } + } + } +} + diff --git a/app/src/main/java/com/gunishjain/sample/MyApplication.kt b/app/src/main/java/com/gunishjain/sample/MyApplication.kt new file mode 100644 index 0000000..7a9964d --- /dev/null +++ b/app/src/main/java/com/gunishjain/sample/MyApplication.kt @@ -0,0 +1,15 @@ +package com.gunishjain.sample + +import android.app.Application +import com.gunishjain.grabbit.Grabbit + +class MyApplication : Application() { + + lateinit var grabbit: Grabbit + + override fun onCreate() { + super.onCreate() + grabbit = Grabbit.create() + } + +} \ No newline at end of file diff --git a/grabbit/build.gradle.kts b/grabbit/build.gradle.kts index 0959a4e..b28180d 100644 --- a/grabbit/build.gradle.kts +++ b/grabbit/build.gradle.kts @@ -40,4 +40,7 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + + implementation(libs.retrofit) + implementation(libs.okhttp) } \ No newline at end of file diff --git a/grabbit/src/main/java/com/gunishjain/grabbit/Grabbit.kt b/grabbit/src/main/java/com/gunishjain/grabbit/Grabbit.kt index 03c4238..63ef471 100644 --- a/grabbit/src/main/java/com/gunishjain/grabbit/Grabbit.kt +++ b/grabbit/src/main/java/com/gunishjain/grabbit/Grabbit.kt @@ -1,6 +1,8 @@ package com.gunishjain.grabbit +import com.gunishjain.grabbit.internal.download.DownloadDispatcher import com.gunishjain.grabbit.internal.download.DownloadRequest +import com.gunishjain.grabbit.internal.download.DownloadRequestQueue class Grabbit private constructor(private val config: DownloadConfig){ @@ -17,7 +19,7 @@ class Grabbit private constructor(private val config: DownloadConfig){ } - //Need to create Req Queue and add requests to it with callbacks + private val requestQueue = DownloadRequestQueue(DownloadDispatcher(config.httpClient)) fun enqueue( request: DownloadRequest, @@ -26,34 +28,34 @@ class Grabbit private constructor(private val config: DownloadConfig){ onPause: () -> Unit= {}, onCompleted: () -> Unit = {}, onError: (error: String) -> Unit = {_->} - ) { + ) : Int { request.onStart = onStart request.onProgress = onProgress request.onPause = onPause request.onCompleted = onCompleted request.onError = onError - //Add request to queue + return requestQueue.enqueue(request) } fun pause(id: Int){ - + requestQueue.pause(id) } fun resume(id: Int){ - + requestQueue.resume(id) } fun cancel(id: Int){ - + requestQueue.cancel(id) } fun cancel(tag: String){ - + requestQueue.cancel(tag) } fun cancelAll(){ - + requestQueue.cancelAll() } diff --git a/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadDispatcher.kt b/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadDispatcher.kt index 30d2f32..9b6ae07 100644 --- a/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadDispatcher.kt +++ b/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadDispatcher.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.launch class DownloadDispatcher(private val httpClient: HttpClient) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val downloadTasks = mutableMapOf() private fun executeOnMain(block: () -> Unit){ scope.launch { @@ -18,6 +19,10 @@ class DownloadDispatcher(private val httpClient: HttpClient) { } fun enqueue(req: DownloadRequest) : Int { + + val downloadTask = DownloadTask(req,httpClient) + downloadTasks[req.downloadId] = downloadTask + val job = scope.launch { execute(req) } @@ -38,15 +43,28 @@ class DownloadDispatcher(private val httpClient: HttpClient) { executeOnMain { request.onPause() } }, onCompleted = { - executeOnMain { request.onCompleted() } + executeOnMain { + request.onCompleted() + } + downloadTasks.remove(request.downloadId) + }, onError = { executeOnMain { request.onError(it) } + downloadTasks.remove(request.downloadId) } ) } + fun pause(downloadId: Int) { + downloadTasks[downloadId]?.pauseDownload() + } + + fun resume(downloadId: Int) { + downloadTasks[downloadId]?.resumeDownload() + } + fun cancel(req: DownloadRequest) { req.job.cancel() } @@ -56,5 +74,4 @@ class DownloadDispatcher(private val httpClient: HttpClient) { } - } \ No newline at end of file diff --git a/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadRequest.kt b/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadRequest.kt index 949d30b..0341a25 100644 --- a/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadRequest.kt +++ b/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadRequest.kt @@ -18,7 +18,7 @@ class DownloadRequest private constructor( internal var totalBytes: Long = 0 internal var downloadedBytes: Long = 0 - internal lateinit var job: Job //using lateintit since we are not initializing it here + internal lateinit var job: Job //using lateintit since we are not initializing it here internal lateinit var onStart: () -> Unit internal lateinit var onProgress: (value: Int) -> Unit internal lateinit var onPause: () -> Unit @@ -57,7 +57,7 @@ class DownloadRequest private constructor( tag = tag, dirPath = dirPath, fileName = fileName, - downloadId = getUniqueDownloadId(), + downloadId = getUniqueDownloadId(url, dirPath, fileName), readTimeout = readTimeOut, connectTimeout = connectTimeOut ) diff --git a/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadRequestQueue.kt b/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadRequestQueue.kt index d910e7e..29c900a 100644 --- a/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadRequestQueue.kt +++ b/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadRequestQueue.kt @@ -7,18 +7,17 @@ class DownloadRequestQueue(private val dispatcher: DownloadDispatcher) { fun enqueue(request: DownloadRequest) :Int { idRequestMap[request.downloadId] = request + return dispatcher.enqueue(request) } fun pause(id: Int) { - - - + dispatcher.pause(id) } fun resume(id: Int) { - + dispatcher.resume(id) } fun cancel(id: Int) { @@ -48,7 +47,4 @@ class DownloadRequestQueue(private val dispatcher: DownloadDispatcher) { } - - - } \ No newline at end of file diff --git a/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadTask.kt b/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadTask.kt index 422604c..b6d4bb8 100644 --- a/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadTask.kt +++ b/grabbit/src/main/java/com/gunishjain/grabbit/internal/download/DownloadTask.kt @@ -3,10 +3,16 @@ package com.gunishjain.grabbit.internal.download import com.gunishjain.grabbit.internal.network.HttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.cancellation.CancellationException class DownloadTask(private val request: DownloadRequest,private val httpClient: HttpClient) { + private var isPaused = false + private var file = File(request.dirPath, request.fileName) + private var downloadedBytes = 0L + suspend fun run( onStart: () -> Unit = {}, onProgress: (value: Int) -> Unit = { _ -> }, @@ -16,20 +22,70 @@ class DownloadTask(private val request: DownloadRequest,private val httpClient: ) { withContext(Dispatchers.IO) { + try { + + onStart() + + // Get initial file size if resuming + if (file.exists()) { + downloadedBytes = file.length() + } + + if (request.totalBytes <= 0) { + request.totalBytes = httpClient.getFileSize(request.url) + } + while (!isPaused) { + try { + httpClient.connect( + url = request.url, + file = file, + startByte = downloadedBytes, + timeout = request.connectTimeout + ) { currentBytes, totalBytes -> + downloadedBytes = currentBytes - onStart() + // Calculate and report progress + val progress = if (totalBytes > 0) { + ((currentBytes.toFloat() / totalBytes) * 100).toInt() + } else { + -1 + } + onProgress(progress) + } - // use of HttpClient - httpClient.connect() + // If we reach here, download is complete + break - TODO("For Pause and Resume need to use Range Header") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (isPaused) { + onPause() + continue + } + throw e + } + } - onCompleted() + onCompleted() + } catch (e: CancellationException){ + throw e + } catch (e: Exception) { + onError(e.message ?: "Unknown error Occurred") + } } } + fun pauseDownload() { + isPaused = true + } + + fun resumeDownload() { + isPaused = false + } + } \ No newline at end of file diff --git a/grabbit/src/main/java/com/gunishjain/grabbit/internal/network/DefaultHttpClient.kt b/grabbit/src/main/java/com/gunishjain/grabbit/internal/network/DefaultHttpClient.kt index 25cc0fa..a34a5ed 100644 --- a/grabbit/src/main/java/com/gunishjain/grabbit/internal/network/DefaultHttpClient.kt +++ b/grabbit/src/main/java/com/gunishjain/grabbit/internal/network/DefaultHttpClient.kt @@ -1,7 +1,95 @@ package com.gunishjain.grabbit.internal.network +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.Buffer +import okio.IOException +import okio.buffer +import okio.sink +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.coroutines.cancellation.CancellationException + class DefaultHttpClient : HttpClient { - override fun connect() { - TODO("Implement a default implementation of HttpClient") + + private val okHttpClient : OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30,TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + override suspend fun connect( + url: String, + file: File, + startByte: Long, + timeout: Int, + onProgress: (Long, Long) -> Unit + ) = withContext(Dispatchers.IO){ + + try { + val request = Request.Builder() + .url(url) + .apply { + if (startByte > 0) { + header("Range", "bytes=$startByte-") + } + } + .build() + + val response = okHttpClient.newCall(request).execute() + + if (!response.isSuccessful) { + throw IOException("Unexpected response code: ${response.code}") + } + + // Get content length from header + val contentLength = response.header("Content-Length")?.toLong() ?: -1L + val totalBytes = if (contentLength != -1L) contentLength + startByte else -1L + + + // Create parent directories if they don't exist + file.parentFile?.mkdirs() + + // Use response body to write to file + response.body?.let { body -> + val bufferedSink = file.sink(append = startByte > 0).buffer() + val source = body.source() + val buffer = Buffer() + var downloadedBytes = startByte + + while (true) { + val read = source.read(buffer, 8192L) // Read chunks of 8KB + if (read == -1L) break + + bufferedSink.write(buffer, read) + downloadedBytes += read + + // Report progress + onProgress(downloadedBytes, totalBytes) + } + + bufferedSink.close() + source.close() + } ?: throw IOException("Response body is null") + + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw IOException("Download failed: ${e.message}", e) + } + } + + override suspend fun getFileSize(url: String): Long = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(url) + .head() // Use HEAD request to get only headers + .build() + + val response = okHttpClient.newCall(request).execute() + response.header("Content-Length")?.toLong() ?: -1L + } + } \ No newline at end of file diff --git a/grabbit/src/main/java/com/gunishjain/grabbit/internal/network/HttpClient.kt b/grabbit/src/main/java/com/gunishjain/grabbit/internal/network/HttpClient.kt index ffcf22c..30550ac 100644 --- a/grabbit/src/main/java/com/gunishjain/grabbit/internal/network/HttpClient.kt +++ b/grabbit/src/main/java/com/gunishjain/grabbit/internal/network/HttpClient.kt @@ -1,7 +1,17 @@ package com.gunishjain.grabbit.internal.network +import java.io.File + interface HttpClient { - fun connect() + suspend fun connect( + url: String, + file: File, + startByte: Long = 0, + timeout: Int = 30000, + onProgress: (downloadedBytes: Long, totalBytes: Long) -> Unit = { _, _ -> } + ) + + suspend fun getFileSize(url: String): Long } \ No newline at end of file diff --git a/grabbit/src/main/java/com/gunishjain/grabbit/utils/Utils.kt b/grabbit/src/main/java/com/gunishjain/grabbit/utils/Utils.kt index 0d7e37f..5e00377 100644 --- a/grabbit/src/main/java/com/gunishjain/grabbit/utils/Utils.kt +++ b/grabbit/src/main/java/com/gunishjain/grabbit/utils/Utils.kt @@ -1,7 +1,30 @@ package com.gunishjain.grabbit.utils -fun getUniqueDownloadId(): Int { +import java.io.File +import java.io.UnsupportedEncodingException +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import kotlin.experimental.and - TODO("Implement a unique download id generator using url,dir,filename") +fun getUniqueDownloadId(url: String, dirPath: String, fileName: String): Int { + + val string = url + File.separator + dirPath + File.separator + fileName + + val hash: ByteArray = try { + MessageDigest.getInstance("MD5").digest(string.toByteArray(charset("UTF-8"))) + } catch (e: NoSuchAlgorithmException) { + throw RuntimeException("NoSuchAlgorithmException", e) + } catch (e: UnsupportedEncodingException) { + throw RuntimeException("UnsupportedEncodingException", e) + } + + val hex = StringBuilder(hash.size * 2) + + for (b in hash) { + if (b and 0xFF.toByte() < 0x10) hex.append("0") + hex.append(Integer.toHexString((b and 0xFF.toByte()).toInt())) + } + + return hex.toString().hashCode() } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5ac077..1bd58b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,8 @@ activityCompose = "1.9.3" composeBom = "2024.04.01" appcompat = "1.7.0" material = "1.12.0" +okhttp = "4.12.0" +retrofit = "2.9.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -28,6 +30,8 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }