├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── colors.xml
│ │ │ │ └── themes.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── xml
│ │ │ │ ├── file_paths.xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── data_extraction_rules.xml
│ │ │ ├── values-night
│ │ │ │ └── themes.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── layout
│ │ │ │ ├── activity_main.xml
│ │ │ │ ├── fragment_main.xml
│ │ │ │ └── item_file.xml
│ │ │ ├── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ │ └── drawable
│ │ │ │ └── ic_launcher_background.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── khush
│ │ │ │ └── sample
│ │ │ │ ├── MainApplication.kt
│ │ │ │ ├── Util.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ └── MainFragment.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── khush
│ │ │ └── sample
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── khush
│ │ └── sample
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle
├── ketch
├── .gitignore
├── consumer-rules.pro
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── ketch
│ │ │ │ ├── internal
│ │ │ │ ├── utils
│ │ │ │ │ ├── UserAction.kt
│ │ │ │ │ ├── ExceptionConst.kt
│ │ │ │ │ ├── DownloadLogger.kt
│ │ │ │ │ ├── MapperUtil.kt
│ │ │ │ │ ├── DownloadConst.kt
│ │ │ │ │ ├── WorkUtil.kt
│ │ │ │ │ ├── NotificationConst.kt
│ │ │ │ │ ├── TextUtil.kt
│ │ │ │ │ └── FileUtil.kt
│ │ │ │ ├── database
│ │ │ │ │ ├── DownloadDatabase.kt
│ │ │ │ │ ├── DatabaseInstance.kt
│ │ │ │ │ ├── DownloadEntity.kt
│ │ │ │ │ └── DownloadDao.kt
│ │ │ │ ├── download
│ │ │ │ │ ├── DownloadRequest.kt
│ │ │ │ │ ├── ApiResponseHeaderChecker.kt
│ │ │ │ │ ├── DownloadTask.kt
│ │ │ │ │ └── DownloadManager.kt
│ │ │ │ ├── network
│ │ │ │ │ ├── DownloadService.kt
│ │ │ │ │ └── RetrofitInstance.kt
│ │ │ │ ├── worker
│ │ │ │ │ └── DownloadWorker.kt
│ │ │ │ └── notification
│ │ │ │ │ ├── NotificationReceiver.kt
│ │ │ │ │ └── DownloadNotificationManager.kt
│ │ │ │ ├── Status.kt
│ │ │ │ ├── DownloadConfig.kt
│ │ │ │ ├── Logger.kt
│ │ │ │ ├── NotificationConfig.kt
│ │ │ │ ├── DownloadModel.kt
│ │ │ │ └── Ketch.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── ketch
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── ketch
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle
├── assets
├── Ketch_hld.png
├── Ketch_logo.png
├── Sample_app.png
└── Sample_notification.png
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── settings.gradle
├── gradle.properties
├── gradlew.bat
├── gradlew
└── README.md
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/ketch/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/ketch/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/Ketch_hld.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/assets/Ketch_hld.png
--------------------------------------------------------------------------------
/assets/Ketch_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/assets/Ketch_logo.png
--------------------------------------------------------------------------------
/assets/Sample_app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/assets/Sample_app.png
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Ketch
3 |
--------------------------------------------------------------------------------
/assets/Sample_notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/assets/Sample_notification.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/khushpanchal/Ketch/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FF000000
4 | #FFFFFFFF
5 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/file_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/utils/UserAction.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.utils
2 |
3 | internal enum class UserAction {
4 | PAUSE,
5 | RESUME,
6 | CANCEL,
7 | RETRY,
8 | START,
9 | DEFAULT
10 | }
11 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/utils/ExceptionConst.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.utils
2 |
3 | internal object ExceptionConst {
4 | const val KEY_EXCEPTION = "key_exception"
5 | const val EXCEPTION_FAILED_DESERIALIZE = "WorkInputData failed to deserialize"
6 | }
7 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/Status.kt:
--------------------------------------------------------------------------------
1 | package com.ketch
2 |
3 | enum class Status {
4 |
5 | QUEUED,
6 |
7 | STARTED,
8 |
9 | PROGRESS,
10 |
11 | SUCCESS,
12 |
13 | CANCELLED,
14 |
15 | FAILED,
16 |
17 | PAUSED,
18 |
19 | DEFAULT
20 | }
21 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat May 04 16:01:34 IST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | /.idea
11 | .DS_Store
12 | /build
13 | /captures
14 | .externalNativeBuild
15 | .cxx
16 | local.properties
17 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/database/DownloadDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.database
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 |
6 | @Database(entities = [DownloadEntity::class], version = 1)
7 | internal abstract class DownloadDatabase : RoomDatabase() {
8 | abstract fun downloadDao(): DownloadDao
9 | }
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/DownloadConfig.kt:
--------------------------------------------------------------------------------
1 | package com.ketch
2 |
3 | import com.ketch.internal.utils.DownloadConst
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class DownloadConfig(
8 | val connectTimeOutInMs: Long = DownloadConst.DEFAULT_VALUE_CONNECT_TIMEOUT_MS,
9 | val readTimeOutInMs: Long = DownloadConst.DEFAULT_VALUE_READ_TIMEOUT_MS
10 | )
11 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 |
16 | rootProject.name = "Ketch"
17 | include ':app'
18 | include ':ketch'
19 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/ketch/src/test/java/com/ketch/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.ketch
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/khush/sample/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.khush.sample
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/Logger.kt:
--------------------------------------------------------------------------------
1 | package com.ketch
2 |
3 | interface Logger {
4 | companion object {
5 | const val TAG = "KetchLogs"
6 | }
7 |
8 | fun log(
9 | tag: String? = TAG,
10 | msg: String? = "",
11 | tr: Throwable? = null,
12 | type: LogType = LogType.DEBUG
13 | )
14 | }
15 |
16 | enum class LogType {
17 | VERBOSE,
18 | DEBUG,
19 | INFO,
20 | WARN,
21 | ERROR
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/download/DownloadRequest.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.download
2 |
3 | import com.ketch.internal.utils.FileUtil.getUniqueId
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | internal data class DownloadRequest(
8 | val url: String,
9 | val path: String,
10 | val fileName: String,
11 | val tag: String,
12 | val id: Int = getUniqueId(url, path, fileName),
13 | val headers: HashMap = hashMapOf(),
14 | val metaData: String = "",
15 | val supportPauseResume: Boolean = true,
16 | )
17 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/download/ApiResponseHeaderChecker.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.download
2 |
3 | import com.ketch.internal.network.DownloadService
4 |
5 | internal class ApiResponseHeaderChecker(
6 | private val url: String,
7 | private val downloadService: DownloadService,
8 | private val headers: HashMap = hashMapOf()
9 | ) {
10 | suspend fun getHeaderValue(
11 | header: String
12 | ): String? {
13 | val response = downloadService.getHeadersOnly(url, headers)
14 | return response.headers().get(header)
15 | }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/ketch/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
13 |
14 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/network/DownloadService.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.network
2 |
3 | import okhttp3.ResponseBody
4 | import retrofit2.Response
5 | import retrofit2.http.GET
6 | import retrofit2.http.HEAD
7 | import retrofit2.http.HeaderMap
8 | import retrofit2.http.Streaming
9 | import retrofit2.http.Url
10 |
11 | internal interface DownloadService {
12 | @Streaming
13 | @GET
14 | suspend fun getUrl(
15 | @Url url: String,
16 | @HeaderMap headers: Map
17 | ): Response
18 |
19 | @HEAD
20 | suspend fun getHeadersOnly(
21 | @Url url: String,
22 | @HeaderMap headers: Map
23 | ): Response
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/utils/DownloadLogger.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.utils
2 |
3 | import android.util.Log
4 | import com.ketch.LogType
5 | import com.ketch.Logger
6 |
7 | internal class DownloadLogger(private val enableLogs: Boolean) : Logger {
8 | override fun log(tag: String?, msg: String?, tr: Throwable?, type: LogType) {
9 | if (enableLogs) {
10 | when (type) {
11 | LogType.VERBOSE -> Log.v(tag, msg, tr)
12 | LogType.DEBUG -> Log.d(tag, msg, tr)
13 | LogType.INFO -> Log.i(tag, msg, tr)
14 | LogType.WARN -> Log.w(tag, msg, tr)
15 | LogType.ERROR -> Log.e(tag, msg, tr)
16 | }
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/NotificationConfig.kt:
--------------------------------------------------------------------------------
1 | package com.ketch
2 |
3 | import com.ketch.internal.utils.NotificationConst
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class NotificationConfig(
8 | val enabled: Boolean = NotificationConst.DEFAULT_VALUE_NOTIFICATION_ENABLED,
9 | val channelName: String = NotificationConst.DEFAULT_VALUE_NOTIFICATION_CHANNEL_NAME,
10 | val channelDescription: String = NotificationConst.DEFAULT_VALUE_NOTIFICATION_CHANNEL_DESCRIPTION,
11 | val importance: Int = NotificationConst.DEFAULT_VALUE_NOTIFICATION_CHANNEL_IMPORTANCE,
12 | val showSpeed: Boolean = true,
13 | val showSize: Boolean = true,
14 | val showTime: Boolean = true,
15 | val smallIcon: Int
16 | )
17 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khush/sample/MainApplication.kt:
--------------------------------------------------------------------------------
1 | package com.khush.sample
2 |
3 | import android.app.Application
4 | import com.ketch.DownloadConfig
5 | import com.ketch.Ketch
6 | import com.ketch.NotificationConfig
7 |
8 | class MainApplication : Application() {
9 |
10 | lateinit var ketch: Ketch
11 |
12 | override fun onCreate() {
13 | super.onCreate()
14 | ketch = Ketch.builder()
15 | .setDownloadConfig(DownloadConfig())
16 | .setNotificationConfig(
17 | NotificationConfig(
18 | true,
19 | smallIcon = R.drawable.ic_launcher_foreground
20 | )
21 | )
22 | .enableLogs(true)
23 | .build(this)
24 | }
25 |
26 | }
--------------------------------------------------------------------------------
/ketch/src/androidTest/java/com/ketch/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.ketch
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.ketch.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/khush/sample/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.khush.sample
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.khush.sample", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/ketch/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
17 |
18 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/database/DatabaseInstance.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.database
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 |
6 | internal object DatabaseInstance {
7 |
8 | @Volatile
9 | private var INSTANCE: DownloadDatabase? = null
10 |
11 | fun getInstance(context: Context): DownloadDatabase {
12 | if (INSTANCE == null) {
13 | synchronized(DownloadDatabase::class) {
14 | if (INSTANCE == null) {
15 | INSTANCE = buildRoomDB(context)
16 | }
17 | }
18 | }
19 | return INSTANCE!!
20 | }
21 |
22 | private fun buildRoomDB(context: Context) =
23 | Room.databaseBuilder(
24 | context.applicationContext,
25 | DownloadDatabase::class.java,
26 | "ketch_downloader"
27 | ).fallbackToDestructiveMigration().build()
28 | }
29 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/utils/MapperUtil.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.utils
2 |
3 | import com.ketch.DownloadModel
4 | import com.ketch.Status
5 | import com.ketch.internal.database.DownloadEntity
6 |
7 | // Mapper function to convert DownloadEntity to DownloadModel
8 | internal fun DownloadEntity.toDownloadModel() =
9 | DownloadModel(
10 | url = url,
11 | path = path,
12 | fileName = fileName,
13 | tag = tag,
14 | id = id,
15 | headers = WorkUtil.jsonToHashMap(headersJson),
16 | timeQueued = timeQueued,
17 | status = Status.entries.find { it.name == status } ?: Status.DEFAULT,
18 | total = totalBytes,
19 | progress = if (totalBytes.toInt() != 0) ((downloadedBytes * 100) / totalBytes).toInt() else 0,
20 | speedInBytePerMs = speedInBytePerMs,
21 | lastModified = lastModified,
22 | eTag = eTag,
23 | metaData = metaData,
24 | failureReason = failureReason
25 | )
26 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/database/DownloadEntity.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.database
2 |
3 | import androidx.room.Entity
4 | import androidx.room.PrimaryKey
5 | import com.ketch.Status
6 | import com.ketch.internal.utils.UserAction
7 |
8 | @Entity(
9 | tableName = "downloads"
10 | )
11 | internal data class DownloadEntity(
12 | var url: String = "",
13 | var path: String = "",
14 | var fileName: String = "",
15 | var tag: String = "",
16 | @PrimaryKey
17 | var id: Int = 0,
18 | var headersJson: String = "",
19 | var timeQueued: Long = 0,
20 | var status: String = Status.DEFAULT.toString(),
21 | var totalBytes: Long = 0,
22 | var downloadedBytes: Long = 0,
23 | var speedInBytePerMs: Float = 0f,
24 | var uuid: String = "",
25 | var lastModified: Long = 0,
26 | var eTag: String = "",
27 | var userAction: String = UserAction.DEFAULT.toString(),
28 | var metaData: String = "",
29 | var failureReason: String = ""
30 | )
31 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/utils/DownloadConst.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.utils
2 |
3 | internal object DownloadConst {
4 | const val DEFAULT_VALUE_READ_TIMEOUT_MS = 10000L
5 | const val DEFAULT_VALUE_CONNECT_TIMEOUT_MS = 10000L
6 | const val BASE_URL = "http://localhost/"
7 | const val TAG_DOWNLOAD = "downloads"
8 | const val KEY_FILE_NAME = "key_fileName"
9 | const val KEY_STATE = "key_state"
10 | const val KEY_PROGRESS = "key_progress"
11 | const val MAX_VALUE_PROGRESS = 100
12 | const val PROGRESS = "progress"
13 | const val STARTED = "started"
14 | const val KEY_LENGTH = "key_length"
15 | const val DEFAULT_VALUE_LENGTH = 0L
16 | const val KEY_REQUEST_ID = "key_request_id"
17 | const val KEY_DOWNLOAD_REQUEST = "key_download_request"
18 | const val KEY_NOTIFICATION_CONFIG = "key_notification_config"
19 | const val ETAG_HEADER = "ETag"
20 | const val CONTENT_LENGTH = "Content-Length"
21 | const val RANGE_HEADER = "Range"
22 | const val HTTP_RANGE_NOT_SATISFY = 416
23 | }
24 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/network/RetrofitInstance.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.network
2 |
3 | import com.ketch.internal.utils.DownloadConst
4 | import okhttp3.OkHttpClient
5 | import retrofit2.Retrofit
6 | import java.util.concurrent.TimeUnit
7 |
8 | internal object RetrofitInstance {
9 |
10 | @Volatile
11 | private var downloadService: DownloadService? = null
12 |
13 | fun getDownloadService(
14 | okHttpClient: OkHttpClient =
15 | OkHttpClient
16 | .Builder()
17 | .connectTimeout(DownloadConst.DEFAULT_VALUE_CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
18 | .readTimeout(DownloadConst.DEFAULT_VALUE_READ_TIMEOUT_MS, TimeUnit.MILLISECONDS)
19 | .build()
20 | ): DownloadService {
21 | if (downloadService == null) {
22 | synchronized(this) {
23 | if (downloadService == null) {
24 | downloadService = Retrofit
25 | .Builder()
26 | .baseUrl(DownloadConst.BASE_URL)
27 | .client(okHttpClient)
28 | .build()
29 | .create(DownloadService::class.java)
30 | }
31 | }
32 | }
33 | return downloadService!!
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/ketch/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.library'
3 | id 'org.jetbrains.kotlin.android'
4 | id 'com.google.devtools.ksp'
5 | id 'org.jetbrains.kotlin.plugin.serialization'
6 | }
7 |
8 | android {
9 | namespace 'com.ketch'
10 | compileSdk 34
11 |
12 | defaultConfig {
13 | minSdk 21
14 |
15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
16 | consumerProguardFiles "consumer-rules.pro"
17 | }
18 |
19 | buildTypes {
20 | release {
21 | minifyEnabled false
22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
23 | }
24 | }
25 | compileOptions {
26 | sourceCompatibility JavaVersion.VERSION_17
27 | targetCompatibility JavaVersion.VERSION_17
28 | }
29 | kotlinOptions {
30 | jvmTarget = '17'
31 | }
32 | }
33 |
34 | dependencies {
35 | implementation 'androidx.core:core-ktx:1.13.1'
36 | testImplementation 'junit:junit:4.13.2'
37 | androidTestImplementation 'androidx.test.ext:junit:1.2.1'
38 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
39 |
40 | implementation "androidx.work:work-runtime-ktx:2.9.1"
41 | implementation 'com.squareup.retrofit2:retrofit:2.9.0'
42 | implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1'
43 |
44 | implementation 'androidx.room:room-runtime:2.6.1'
45 | ksp 'androidx.room:room-compiler:2.6.1'
46 | implementation 'androidx.room:room-ktx:2.6.1'
47 | }
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/utils/WorkUtil.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.utils
2 |
3 | import android.content.Context
4 | import androidx.core.app.NotificationManagerCompat
5 | import com.ketch.NotificationConfig
6 | import com.ketch.internal.download.DownloadRequest
7 | import kotlinx.serialization.encodeToString
8 | import kotlinx.serialization.json.Json
9 |
10 | internal object WorkUtil {
11 |
12 | fun DownloadRequest.toJson(): String {
13 | return Json.encodeToString(this)
14 | }
15 |
16 | fun jsonToDownloadRequest(jsonStr: String): DownloadRequest {
17 | return Json.decodeFromString(jsonStr)
18 | }
19 |
20 | fun NotificationConfig.toJson(): String {
21 | return Json.encodeToString(this)
22 | }
23 |
24 | fun jsonToNotificationConfig(jsonStr: String): NotificationConfig {
25 | if (jsonStr.isEmpty()) {
26 | return NotificationConfig(smallIcon = NotificationConst.DEFAULT_VALUE_NOTIFICATION_SMALL_ICON)
27 | }
28 | return Json.decodeFromString(jsonStr)
29 | }
30 |
31 | fun hashMapToJson(headers: HashMap): String {
32 | if (headers.isEmpty()) return ""
33 | return Json.encodeToString(headers)
34 | }
35 |
36 | fun jsonToHashMap(jsonString: String): HashMap {
37 | if (jsonString.isEmpty()) return hashMapOf()
38 | return Json.decodeFromString(jsonString)
39 | }
40 |
41 | fun removeNotification(context: Context, notificationId: Int) {
42 | NotificationManagerCompat.from(context).cancel(notificationId)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'org.jetbrains.kotlin.android'
4 | }
5 |
6 | android {
7 | namespace 'com.khush.sample'
8 | compileSdk 34
9 |
10 | defaultConfig {
11 | applicationId "com.khush.sample"
12 | minSdk 21
13 | targetSdk 34
14 | versionCode 1
15 | versionName "1.0"
16 |
17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
18 | }
19 |
20 | buildTypes {
21 | release {
22 | minifyEnabled false
23 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
24 | }
25 | }
26 | compileOptions {
27 | sourceCompatibility JavaVersion.VERSION_17
28 | targetCompatibility JavaVersion.VERSION_17
29 | }
30 | kotlinOptions {
31 | jvmTarget = '17'
32 | }
33 | buildFeatures {
34 | viewBinding true
35 | }
36 | }
37 |
38 | dependencies {
39 |
40 | implementation "androidx.core:core-ktx:1.13.1"
41 | implementation "androidx.appcompat:appcompat:1.7.0"
42 | implementation "com.google.android.material:material:1.12.0"
43 | implementation "androidx.activity:activity:1.9.0"
44 | implementation "androidx.constraintlayout:constraintlayout:2.1.4"
45 | testImplementation "junit:junit:4.13.2"
46 | androidTestImplementation "androidx.test.ext:junit:1.2.1"
47 | androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1"
48 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0"
49 |
50 | implementation(project(':ketch'))
51 | }
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/DownloadModel.kt:
--------------------------------------------------------------------------------
1 | package com.ketch
2 |
3 | /**
4 | * Download model: Data class sent to client mapped to each download
5 | *
6 | * @property url Download URL sent by client
7 | * @property path Download Path sent by client
8 | * @property fileName Name of the file sent by client
9 | * @property tag Optional tag for each download to group the download into category
10 | * @property id Unique download id created by the combination of url, path and filename
11 | * @property headers Optional headers sent when making api call for file download
12 | * @property timeQueued First time in millisecond when download was queued into the database
13 | * @property status Current [Status] of the download
14 | * @property total Total size of file in bytes
15 | * @property progress Current download progress in Int between 0 and 100
16 | * @property speedInBytePerMs Current speed of download in bytes per second
17 | * @property lastModified Last modified time of database for current download (any change update the time)
18 | * @property eTag ETag of the download file sent by API response headers
19 | * @property metaData Optional metaData set by client for adding any extra download info
20 | * @property failureReason Failure reason for failed download
21 | * @constructor Create empty Download model
22 | */
23 | data class DownloadModel(
24 | val url: String,
25 | val path: String,
26 | val fileName: String,
27 | val tag: String,
28 | val id: Int,
29 | val headers: HashMap,
30 | val timeQueued: Long,
31 | val status: Status,
32 | val total: Long,
33 | val progress: Int,
34 | val speedInBytePerMs: Float,
35 | val lastModified: Long,
36 | val eTag: String,
37 | val metaData: String,
38 | val failureReason: String
39 | )
40 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/utils/NotificationConst.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.utils
2 |
3 | internal object NotificationConst {
4 | const val NOTIFICATION_CHANNEL_ID = "download_channel"
5 | const val DEFAULT_VALUE_NOTIFICATION_ENABLED = false
6 | const val KEY_NOTIFICATION_CHANNEL_NAME = "key_notification_channel_name"
7 | const val DEFAULT_VALUE_NOTIFICATION_CHANNEL_NAME = "File Download"
8 | const val KEY_NOTIFICATION_CHANNEL_DESCRIPTION = "key_notification_channel_description"
9 | const val DEFAULT_VALUE_NOTIFICATION_CHANNEL_DESCRIPTION = "Notify file download status"
10 | const val KEY_NOTIFICATION_CHANNEL_IMPORTANCE = "key_notification_channel_importance"
11 | const val DEFAULT_VALUE_NOTIFICATION_CHANNEL_IMPORTANCE = 2 // LOW
12 | const val KEY_NOTIFICATION_SMALL_ICON = "key_small_notification_icon"
13 | const val DEFAULT_VALUE_NOTIFICATION_SMALL_ICON = -1
14 | const val KEY_NOTIFICATION_ID = "key_notification_id"
15 |
16 | // Actions
17 | const val ACTION_NOTIFICATION_DISMISSED = "ACTION_NOTIFICATION_DISMISSED"
18 | const val ACTION_DOWNLOAD_COMPLETED = "ACTION_DOWNLOAD_COMPLETED"
19 | const val ACTION_DOWNLOAD_FAILED = "ACTION_DOWNLOAD_FAILED"
20 | const val ACTION_DOWNLOAD_CANCELLED = "ACTION_DOWNLOAD_CANCELLED"
21 | const val ACTION_DOWNLOAD_PAUSED = "ACTION_NOTIFICATION_PAUSED"
22 | const val ACTION_NOTIFICATION_RESUME_CLICK = "ACTION_NOTIFICATION_RESUME_CLICK"
23 | const val ACTION_NOTIFICATION_RETRY_CLICK = "ACTION_NOTIFICATION_RETRY_CLICK"
24 | const val ACTION_NOTIFICATION_PAUSE_CLICK = "ACTION_NOTIFICATION_PAUSE_CLICK"
25 | const val ACTION_NOTIFICATION_CANCEL_CLICK = "ACTION_NOTIFICATION_CANCEL_CLICK"
26 |
27 | // Cancel
28 | const val CANCEL_BUTTON_TEXT = "Cancel"
29 | const val PAUSE_BUTTON_TEXT = "Pause"
30 | const val RESUME_BUTTON_TEXT = "Resume"
31 | const val RETRY_BUTTON_TEXT = "Retry"
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
10 |
12 |
14 |
15 |
28 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
43 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/utils/TextUtil.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.utils
2 |
3 | internal object TextUtil {
4 |
5 | private const val MAX_PERCENT = 100
6 | private const val VALUE_60 = 60
7 | private const val VALUE_3 = 3
8 | private const val VALUE_300 = 300
9 | private const val VALUE_500 = 500
10 | private const val VALUE_1024 = 1024
11 | private const val SEC_IN_MILLIS = 1000
12 |
13 | fun getTimeLeftText(speedInBPerMs: Float, progressPercent: Int, lengthInBytes: Long): String {
14 | if (speedInBPerMs == 0F) return ""
15 | val speedInBPerSecond = speedInBPerMs * SEC_IN_MILLIS
16 | val bytesLeft = (lengthInBytes * (MAX_PERCENT - progressPercent) / MAX_PERCENT).toFloat()
17 |
18 | val secondsLeft = bytesLeft / speedInBPerSecond
19 | val minutesLeft = secondsLeft / VALUE_60
20 | val hoursLeft = minutesLeft / VALUE_60
21 |
22 | return when {
23 | secondsLeft < VALUE_60 -> "%.0f s left".format(secondsLeft)
24 | minutesLeft < VALUE_3 -> "%.0f mins %.0f s left".format(minutesLeft, secondsLeft % VALUE_60)
25 | minutesLeft < VALUE_60 -> "%.0f mins left".format(minutesLeft)
26 | minutesLeft < VALUE_300 -> "%.0f hrs %.0f mins left".format(hoursLeft, minutesLeft % VALUE_60)
27 | else -> "%.0f hrs left".format(hoursLeft)
28 | }
29 | }
30 |
31 | fun getSpeedText(speedInBPerMs: Float): String {
32 | var value = speedInBPerMs * SEC_IN_MILLIS
33 | val units = arrayOf("b/s", "kb/s", "mb/s", "gb/s")
34 | var unitIndex = 0
35 |
36 | while (value >= VALUE_500 && unitIndex < units.size - 1) {
37 | value /= VALUE_1024
38 | unitIndex++
39 | }
40 |
41 | return "%.2f %s".format(value, units[unitIndex])
42 | }
43 |
44 | fun getTotalLengthText(lengthInBytes: Long): String {
45 | var value = lengthInBytes.toFloat()
46 | val units = arrayOf("b", "kb", "mb", "gb")
47 | var unitIndex = 0
48 |
49 | while (value >= VALUE_500 && unitIndex < units.size - 1) {
50 | value /= VALUE_1024
51 | unitIndex++
52 | }
53 |
54 | return "%.2f %s".format(value, units[unitIndex])
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khush/sample/Util.kt:
--------------------------------------------------------------------------------
1 | package com.khush.sample
2 |
3 | object Util {
4 |
5 | private const val MAX_PERCENT = 100
6 | private const val VALUE_60 = 60
7 | private const val VALUE_3 = 3
8 | private const val VALUE_300 = 300
9 | private const val VALUE_500 = 500
10 | private const val VALUE_1024 = 1024
11 | private const val SEC_IN_MILLIS = 1000
12 |
13 | private fun getTimeLeftText(
14 | speedInBPerMs: Float,
15 | progressPercent: Int,
16 | lengthInBytes: Long
17 | ): String {
18 | if (speedInBPerMs == 0F) return ""
19 | val speedInBPerSecond = speedInBPerMs * SEC_IN_MILLIS
20 | val bytesLeft = (lengthInBytes * (MAX_PERCENT - progressPercent) / MAX_PERCENT).toFloat()
21 |
22 | val secondsLeft = bytesLeft / speedInBPerSecond
23 | val minutesLeft = secondsLeft / VALUE_60
24 | val hoursLeft = minutesLeft / VALUE_60
25 |
26 | return when {
27 | secondsLeft < VALUE_60 -> "%.0f s left".format(secondsLeft)
28 | minutesLeft < VALUE_3 -> "%.0f mins %.0f s left".format(
29 | minutesLeft,
30 | secondsLeft % VALUE_60
31 | )
32 |
33 | minutesLeft < VALUE_60 -> "%.0f mins left".format(minutesLeft)
34 | minutesLeft < VALUE_300 -> "%.0f hrs %.0f mins left".format(
35 | hoursLeft,
36 | minutesLeft % VALUE_60
37 | )
38 |
39 | else -> "%.0f hrs left".format(hoursLeft)
40 | }
41 | }
42 |
43 | private fun getSpeedText(speedInBPerMs: Float): String {
44 | var value = speedInBPerMs * SEC_IN_MILLIS
45 | val units = arrayOf("b/s", "kb/s", "mb/s", "gb/s")
46 | var unitIndex = 0
47 |
48 | while (value >= VALUE_500 && unitIndex < units.size - 1) {
49 | value /= VALUE_1024
50 | unitIndex++
51 | }
52 |
53 | return "%.2f %s".format(value, units[unitIndex])
54 | }
55 |
56 | fun getTotalLengthText(lengthInBytes: Long): String {
57 | var value = lengthInBytes.toFloat()
58 | val units = arrayOf("b", "kb", "mb", "gb")
59 | var unitIndex = 0
60 |
61 | while (value >= VALUE_500 && unitIndex < units.size - 1) {
62 | value /= VALUE_1024
63 | unitIndex++
64 | }
65 |
66 | return "%.2f %s".format(value, units[unitIndex])
67 | }
68 |
69 | fun getCompleteText(
70 | speedInBPerMs: Float,
71 | progress: Int,
72 | length: Long
73 | ): String {
74 | val timeLeftText = getTimeLeftText(speedInBPerMs, progress, length)
75 | val speedText = getSpeedText(speedInBPerMs)
76 |
77 | val parts = mutableListOf()
78 |
79 | if (timeLeftText.isNotEmpty()) {
80 | parts.add(timeLeftText)
81 | }
82 |
83 | if (speedText.isNotEmpty()) {
84 | parts.add(speedText)
85 | }
86 |
87 | return parts.joinToString(", ")
88 | }
89 |
90 | }
91 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/utils/FileUtil.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.utils
2 |
3 | import android.os.Environment
4 | import android.webkit.URLUtil
5 | import java.io.File
6 | import java.security.MessageDigest
7 | import java.util.UUID
8 | import kotlin.experimental.and
9 |
10 | internal object FileUtil {
11 |
12 | fun getTempFileForFile(file: File): File {
13 | return File(file.absolutePath + ".temp")
14 | }
15 |
16 | fun getFileNameFromUrl(url: String): String {
17 | val guessFileName = URLUtil.guessFileName(url, null, null)
18 | return UUID.randomUUID().toString() + "-" + guessFileName
19 | }
20 |
21 | fun getDefaultDownloadPath(): String {
22 | return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path
23 | }
24 |
25 | fun getUniqueId(url: String, dirPath: String, fileName: String): Int {
26 | val string = url + File.separator + dirPath + File.separator + fileName
27 | val hash: ByteArray = try {
28 | MessageDigest.getInstance("MD5").digest(string.toByteArray(charset("UTF-8")))
29 | } catch (e: Exception) {
30 | return getUniqueIdFallback(url, dirPath, fileName)
31 | }
32 | val hex = StringBuilder(hash.size * 2)
33 | for (b in hash) {
34 | if (b and 0xFF.toByte() < 0x10) hex.append("0")
35 | hex.append(Integer.toHexString((b and 0xFF.toByte()).toInt()))
36 | }
37 | return hex.toString().hashCode()
38 | }
39 |
40 | private fun getUniqueIdFallback(url: String, dirPath: String, fileName: String): Int {
41 | return (url.hashCode() * 31 + dirPath.hashCode()) * 31 + fileName.hashCode()
42 | }
43 |
44 | fun deleteFileIfExists(path: String, name: String) {
45 | val file = File(path, name)
46 | if (file.exists()) {
47 | file.delete()
48 | }
49 |
50 | getTempFileForFile(file).let {
51 | if (it.exists()) it.delete()
52 | }
53 | }
54 |
55 | // If file name already exist at given path, generate new file name with (1), (2) etc. suffix
56 | fun resolveNamingConflicts(fileName: String, path: String): String {
57 | var newFileName = fileName
58 | var file = File(path, newFileName)
59 | var tempFile = getTempFileForFile(file)
60 | var counter = 1
61 |
62 | while (file.exists() || tempFile.exists()) {
63 | val name = fileName.substringBeforeLast(".")
64 | val extension = fileName.substringAfterLast(".")
65 | newFileName = "$name ($counter).$extension"
66 | file = File(path, newFileName)
67 | tempFile = getTempFileForFile(file)
68 | counter++
69 | }
70 |
71 | return newFileName
72 | }
73 |
74 | fun createTempFileIfNotExists(path: String, fileName: String) {
75 | val file = File(path, fileName)
76 | val tempFile = getTempFileForFile(file)
77 | if (!tempFile.exists()) {
78 | tempFile.createNewFile()
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/database/DownloadDao.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.database
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy
6 | import androidx.room.Query
7 | import androidx.room.Update
8 | import kotlinx.coroutines.flow.Flow
9 |
10 | @Dao
11 | internal interface DownloadDao {
12 | @Insert(onConflict = OnConflictStrategy.REPLACE)
13 | suspend fun insert(entity: DownloadEntity)
14 |
15 | @Update
16 | suspend fun update(entity: DownloadEntity)
17 |
18 | @Query("SELECT * FROM downloads WHERE id = :id")
19 | suspend fun find(id: Int): DownloadEntity?
20 |
21 | @Query("DELETE FROM downloads WHERE id = :id")
22 | suspend fun remove(id: Int)
23 |
24 | @Query("DELETE FROM downloads")
25 | suspend fun deleteAll()
26 |
27 | @Query("SELECT * FROM downloads ORDER BY timeQueued ASC")
28 | fun getAllEntityFlow(): Flow>
29 |
30 | @Query("SELECT * FROM downloads WHERE lastModified <= :timeMillis ORDER BY timeQueued ASC")
31 | fun getEntityTillTimeFlow(timeMillis: Long): Flow>
32 |
33 | @Query("SELECT * FROM downloads WHERE id = :id ORDER BY timeQueued ASC")
34 | fun getEntityByIdFlow(id: Int): Flow
35 |
36 | @Query("SELECT * FROM downloads WHERE id IN (:ids) ORDER BY timeQueued ASC")
37 | fun getAllEntityByIdsFlow(ids: List): Flow>
38 |
39 | @Query("SELECT * FROM downloads WHERE tag = :tag ORDER BY timeQueued ASC")
40 | fun getAllEntityByTagFlow(tag: String): Flow>
41 |
42 | @Query("SELECT * FROM downloads WHERE tag IN (:tags) ORDER BY timeQueued ASC")
43 | fun getAllEntityByTagsFlow(tags: List): Flow>
44 |
45 | @Query("SELECT * FROM downloads WHERE status = :status ORDER BY timeQueued ASC")
46 | fun getAllEntityByStatusFlow(status: String): Flow>
47 |
48 | @Query("SELECT * FROM downloads WHERE status IN (:statuses) ORDER BY timeQueued ASC")
49 | fun getAllEntityByStatusesFlow(statuses: List): Flow>
50 |
51 | @Query("SELECT * FROM downloads ORDER BY timeQueued ASC")
52 | suspend fun getAllEntity(): List
53 |
54 | @Query("SELECT * FROM downloads WHERE lastModified <= :timeMillis ORDER BY timeQueued ASC")
55 | suspend fun getEntityTillTime(timeMillis: Long): List
56 |
57 | @Query("SELECT * FROM downloads WHERE tag = :tag ORDER BY timeQueued ASC")
58 | suspend fun getAllEntityByTag(tag: String): List
59 |
60 | @Query("SELECT * FROM downloads WHERE tag IN (:tags) ORDER BY timeQueued ASC")
61 | suspend fun getAllEntityByTags(tags: List): List
62 |
63 | @Query("SELECT * FROM downloads WHERE id IN (:ids) ORDER BY timeQueued ASC")
64 | suspend fun getAllEntityByIds(ids: List): List
65 |
66 | @Query("SELECT * FROM downloads WHERE status = :status ORDER BY timeQueued ASC")
67 | suspend fun getAllEntityByStatus(status: String): List
68 |
69 | @Query("SELECT * FROM downloads WHERE status IN (:statuses) ORDER BY timeQueued ASC")
70 | suspend fun getAllEntityByStatuses(statuses: List): List
71 | }
72 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
15 |
16 |
23 |
24 |
31 |
32 |
39 |
40 |
41 |
42 |
50 |
51 |
58 |
59 |
66 |
67 |
74 |
75 |
76 |
77 |
86 |
87 |
--------------------------------------------------------------------------------
/app/src/main/java/com/khush/sample/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.khush.sample
2 |
3 | import android.app.NotificationManager
4 | import android.content.Intent
5 | import android.content.pm.PackageManager
6 | import android.os.Build
7 | import android.os.Bundle
8 | import android.os.Environment
9 | import android.provider.Settings
10 | import android.util.Log
11 | import android.widget.Toast
12 | import androidx.appcompat.app.AppCompatActivity
13 | import com.google.android.material.snackbar.Snackbar
14 | import com.khush.sample.databinding.ActivityMainBinding
15 |
16 | class MainActivity : AppCompatActivity() {
17 |
18 | private lateinit var binding: ActivityMainBinding
19 | private lateinit var snackbar: Snackbar
20 |
21 | override fun onCreate(savedInstanceState: Bundle?) {
22 | super.onCreate(savedInstanceState)
23 | Log.i("Testing", "RequestId " + intent.extras?.getInt("key_request_id"))
24 |
25 | binding = ActivityMainBinding.inflate(layoutInflater)
26 | setContentView(binding.root)
27 |
28 | if (savedInstanceState != null) return
29 |
30 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
31 | openTestFragment()
32 | return
33 | }
34 |
35 | var permissions = arrayOf()
36 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !getSystemService(NotificationManager::class.java).areNotificationsEnabled()) {
37 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
38 | permissions = permissions.plus(android.Manifest.permission.POST_NOTIFICATIONS)
39 | }
40 | }
41 |
42 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {
43 | snackbar =
44 | Snackbar.make(binding.root, "Allow storage permission", Snackbar.LENGTH_INDEFINITE)
45 | .setAction("Settings") {
46 | val getpermission = Intent()
47 | getpermission.action = Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION
48 | startActivity(getpermission)
49 | snackbar.dismiss()
50 | }
51 | snackbar.show()
52 | } else if (checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
53 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
54 | permissions = permissions.plus(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
55 | }
56 | }
57 |
58 | if (permissions.isNotEmpty()) {
59 | requestPermissions(permissions, 101)
60 | Toast.makeText(this, "Notification and Storage Permission Required", Toast.LENGTH_SHORT)
61 | .show()
62 | return
63 | }
64 |
65 | openTestFragment()
66 | }
67 |
68 | private fun openTestFragment() {
69 | //Test with your own url
70 | supportFragmentManager.beginTransaction()
71 | .replace(R.id.container, MainFragment.newInstance()).commit()
72 | }
73 |
74 | override fun onRequestPermissionsResult(
75 | requestCode: Int,
76 | permissions: Array, grantResults: IntArray
77 | ) {
78 | super.onRequestPermissionsResult(requestCode, permissions, grantResults)
79 |
80 | if (requestCode == 101) {
81 | if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
82 | openTestFragment()
83 | }
84 | }
85 | }
86 |
87 |
88 | }
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/download/DownloadTask.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.download
2 |
3 | import com.ketch.internal.network.DownloadService
4 | import com.ketch.internal.utils.DownloadConst
5 | import com.ketch.internal.utils.FileUtil
6 | import java.io.File
7 | import java.io.FileOutputStream
8 | import java.io.IOException
9 |
10 | internal class DownloadTask(
11 | private val url: String,
12 | private val path: String,
13 | private val fileName: String,
14 | private val supportPauseResume: Boolean = true,
15 | private val downloadService: DownloadService,
16 | ) {
17 |
18 | companion object {
19 | private const val VALUE_200 = 200
20 | private const val VALUE_299 = 299
21 | private const val TIME_TO_TRIGGER_PROGRESS = 1500
22 | }
23 |
24 | suspend fun download(
25 | headers: MutableMap = mutableMapOf(),
26 | onStart: suspend (totalBytes: Long) -> Unit,
27 | onProgress: suspend (progressBytes: Long, totalBytes: Long, speed: Float) -> Unit
28 | ): Long {
29 |
30 | var rangeStart = 0L
31 | val file = File(path, fileName)
32 | val tempFile = FileUtil.getTempFileForFile(file)
33 |
34 | if (tempFile.exists()) {
35 | rangeStart = tempFile.length()
36 | }
37 |
38 | if (rangeStart != 0L) {
39 | headers[DownloadConst.RANGE_HEADER] = "bytes=$rangeStart-"
40 | }
41 |
42 | var response = downloadService.getUrl(url, headers)
43 | if (response.code() == DownloadConst.HTTP_RANGE_NOT_SATISFY || isRedirection(
44 | response.raw().request().url().toString()
45 | )
46 | ) {
47 | FileUtil.deleteFileIfExists(path, fileName)
48 | headers.remove(DownloadConst.RANGE_HEADER)
49 | rangeStart = 0
50 | response = downloadService.getUrl(url, headers)
51 | }
52 |
53 | val responseBody = response.body()
54 |
55 | if (response.code() !in VALUE_200..VALUE_299 ||
56 | responseBody == null
57 | ) {
58 | throw IOException(
59 | "Something went wrong, response code: ${response.code()}, responseBody null: ${responseBody == null}"
60 | )
61 | }
62 |
63 | var totalBytes = responseBody.contentLength()
64 |
65 | // pause resume not supported if we can not get content length or not supported by user
66 | if (totalBytes < 0 || supportPauseResume.not()) {
67 | totalBytes = 0
68 | } else {
69 | totalBytes += rangeStart
70 | }
71 |
72 | var progressBytes = 0L
73 |
74 | responseBody.byteStream().use { inputStream ->
75 | FileOutputStream(tempFile,true).use { outputStream ->
76 |
77 | if (rangeStart != 0L) {
78 | progressBytes = rangeStart
79 | }
80 |
81 | onStart.invoke(totalBytes)
82 |
83 | val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
84 | var bytes = inputStream.read(buffer)
85 | var tempBytes = 0L
86 | var progressInvokeTime = System.currentTimeMillis()
87 | var speed: Float
88 |
89 | onProgress.invoke(0L, 0L, 0F)
90 |
91 | while (bytes >= 0) {
92 |
93 | outputStream.write(buffer, 0, bytes)
94 | progressBytes += bytes
95 | tempBytes += bytes
96 | bytes = inputStream.read(buffer)
97 | val finalTime = System.currentTimeMillis()
98 | if (finalTime - progressInvokeTime >= TIME_TO_TRIGGER_PROGRESS) {
99 |
100 | speed = tempBytes.toFloat() / ((finalTime - progressInvokeTime).toFloat())
101 | tempBytes = 0L
102 | progressInvokeTime = System.currentTimeMillis()
103 | if (progressBytes > totalBytes) progressBytes = totalBytes
104 | if(totalBytes > 0) {
105 | onProgress.invoke(
106 | progressBytes,
107 | totalBytes,
108 | speed
109 | )
110 | }
111 | }
112 | }
113 | onProgress.invoke(totalBytes, totalBytes, 0F)
114 | }
115 | }
116 |
117 | require(tempFile.renameTo(file)) { "Temp file rename failed" }
118 |
119 | return totalBytes
120 | }
121 |
122 | private fun isRedirection(requestUrl: String): Boolean {
123 | return requestUrl != url
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_file.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
22 |
23 |
31 |
32 |
41 |
42 |
51 |
52 |
62 |
63 |
74 |
75 |
86 |
87 |
98 |
99 |
111 |
112 |
124 |
125 |
136 |
137 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/ketch/src/main/java/com/ketch/internal/worker/DownloadWorker.kt:
--------------------------------------------------------------------------------
1 | package com.ketch.internal.worker
2 |
3 | import android.content.Context
4 | import androidx.work.CoroutineWorker
5 | import androidx.work.WorkerParameters
6 | import androidx.work.workDataOf
7 | import com.ketch.Status
8 | import com.ketch.internal.database.DatabaseInstance
9 | import com.ketch.internal.download.DownloadTask
10 | import com.ketch.internal.download.ApiResponseHeaderChecker
11 | import com.ketch.internal.network.RetrofitInstance
12 | import com.ketch.internal.notification.DownloadNotificationManager
13 | import com.ketch.internal.utils.DownloadConst
14 | import com.ketch.internal.utils.ExceptionConst
15 | import com.ketch.internal.utils.FileUtil
16 | import com.ketch.internal.utils.UserAction
17 | import com.ketch.internal.utils.WorkUtil
18 | import kotlinx.coroutines.CancellationException
19 | import kotlinx.coroutines.GlobalScope
20 | import kotlinx.coroutines.launch
21 | import java.io.File
22 |
23 | internal class DownloadWorker(
24 | private val context: Context,
25 | private val workerParameters: WorkerParameters
26 | ) :
27 | CoroutineWorker(context, workerParameters) {
28 |
29 | companion object {
30 | private const val MAX_PERCENT = 100
31 | }
32 |
33 | private var downloadNotificationManager: DownloadNotificationManager? = null
34 | private val downloadDao = DatabaseInstance.getInstance(context).downloadDao()
35 |
36 | override suspend fun doWork(): Result {
37 |
38 | val downloadRequest =
39 | WorkUtil.jsonToDownloadRequest(
40 | inputData.getString(DownloadConst.KEY_DOWNLOAD_REQUEST)
41 | ?: return Result.failure(
42 | workDataOf(ExceptionConst.KEY_EXCEPTION to ExceptionConst.EXCEPTION_FAILED_DESERIALIZE)
43 | )
44 | )
45 |
46 | val notificationConfig =
47 | WorkUtil.jsonToNotificationConfig(
48 | inputData.getString(DownloadConst.KEY_NOTIFICATION_CONFIG) ?: ""
49 | )
50 |
51 | val id = downloadRequest.id
52 | val url = downloadRequest.url
53 | val dirPath = downloadRequest.path
54 | val fileName = downloadRequest.fileName
55 | val headers = downloadRequest.headers
56 | val supportPauseResume = downloadRequest.supportPauseResume // in case of false, we will not store total length info in DB
57 |
58 | if (notificationConfig.enabled) {
59 | downloadNotificationManager = DownloadNotificationManager(
60 | context = context,
61 | notificationConfig = notificationConfig,
62 | requestId = id,
63 | fileName = fileName
64 | )
65 | }
66 |
67 | val downloadService = RetrofitInstance.getDownloadService()
68 |
69 | return try {
70 | downloadNotificationManager?.sendUpdateNotification()?.let {
71 | setForeground(
72 | it
73 | )
74 | }
75 |
76 | val latestETag =
77 | ApiResponseHeaderChecker(downloadRequest.url, downloadService, headers)
78 | .getHeaderValue(DownloadConst.ETAG_HEADER) ?: ""
79 |
80 | val existingETag = downloadDao.find(id)?.eTag ?: ""
81 |
82 | if (latestETag != existingETag) {
83 | FileUtil.deleteFileIfExists(path = dirPath, name = fileName)
84 | FileUtil.createTempFileIfNotExists(path = dirPath, fileName = fileName)
85 | downloadDao.find(id)?.copy(
86 | eTag = latestETag,
87 | lastModified = System.currentTimeMillis()
88 | )?.let { downloadDao.update(it) }
89 | }
90 |
91 | var progressPercentage = -1
92 |
93 | val totalLength = DownloadTask(
94 | url = url,
95 | path = dirPath,
96 | fileName = fileName,
97 | supportPauseResume = supportPauseResume,
98 | downloadService = downloadService
99 | ).download(
100 | headers = headers,
101 | onStart = { length ->
102 |
103 | downloadDao.find(id)?.copy(
104 | totalBytes = length,
105 | status = Status.STARTED.toString(),
106 | lastModified = System.currentTimeMillis()
107 | )?.let { downloadDao.update(it) }
108 |
109 | setProgress(
110 | workDataOf(
111 | DownloadConst.KEY_STATE to DownloadConst.STARTED
112 | )
113 | )
114 | },
115 | onProgress = { downloadedBytes, length, speed ->
116 |
117 | val progress = if (length != 0L) {
118 | ((downloadedBytes * 100) / length).toInt()
119 | } else {
120 | 0
121 | }
122 |
123 | if (progressPercentage != progress) {
124 |
125 | progressPercentage = progress
126 |
127 | downloadDao.find(id)?.copy(
128 | downloadedBytes = downloadedBytes,
129 | speedInBytePerMs = speed,
130 | status = Status.PROGRESS.toString(),
131 | lastModified = System.currentTimeMillis()
132 | )?.let { downloadDao.update(it) }
133 |
134 | }
135 |
136 | setProgress(
137 | workDataOf(
138 | DownloadConst.KEY_STATE to DownloadConst.PROGRESS,
139 | DownloadConst.KEY_PROGRESS to progress
140 | )
141 | )
142 | downloadNotificationManager?.sendUpdateNotification(
143 | progress = progress,
144 | speedInBPerMs = speed,
145 | length = length,
146 | update = true
147 | )?.let {
148 | setForeground(
149 | it
150 | )
151 | }
152 | }
153 | )
154 |
155 | downloadDao.find(id)?.copy(
156 | totalBytes = totalLength,
157 | status = Status.SUCCESS.toString(),
158 | lastModified = System.currentTimeMillis()
159 | )?.let { downloadDao.update(it) }
160 |
161 | downloadNotificationManager?.sendDownloadSuccessNotification(
162 | totalLength = if (totalLength > 0) totalLength else File(dirPath, fileName).length()
163 | )
164 | Result.success()
165 | } catch (e: Exception) {
166 | GlobalScope.launch {
167 | if (e is CancellationException) {
168 | if (downloadDao.find(id)?.userAction == UserAction.PAUSE.toString()) {
169 |
170 | downloadDao.find(id)?.copy(
171 | status = Status.PAUSED.toString(),
172 | lastModified = System.currentTimeMillis()
173 | )?.let { downloadDao.update(it) }
174 | val downloadEntity = downloadDao.find(id)
175 | if (downloadEntity != null) {
176 | val currentProgress = if (downloadEntity.totalBytes != 0L) {
177 | ((downloadEntity.downloadedBytes * MAX_PERCENT) / downloadEntity.totalBytes).toInt()
178 | } else {
179 | 0
180 | }
181 | downloadNotificationManager?.sendDownloadPausedNotification(
182 | currentProgress = currentProgress
183 | )
184 | }
185 |
186 | } else {
187 |
188 | downloadDao.find(id)?.copy(
189 | status = Status.CANCELLED.toString(),
190 | lastModified = System.currentTimeMillis()
191 | )?.let { downloadDao.update(it) }
192 | FileUtil.deleteFileIfExists(dirPath, fileName)
193 | downloadNotificationManager?.sendDownloadCancelledNotification()
194 |
195 | }
196 | } else {
197 |
198 | downloadDao.find(id)?.copy(
199 | status = Status.FAILED.toString(),
200 | failureReason = e.message ?: "",
201 | lastModified = System.currentTimeMillis()
202 | )?.let { downloadDao.update(it) }
203 | val downloadEntity = downloadDao.find(id)
204 | if (downloadEntity != null) {
205 | val currentProgress = if (downloadEntity.totalBytes != 0L) {
206 | ((downloadEntity.downloadedBytes * MAX_PERCENT) / downloadEntity.totalBytes).toInt()
207 | } else {
208 | 0
209 | }
210 | downloadNotificationManager?.sendDownloadFailedNotification(
211 | currentProgress = currentProgress
212 | )
213 | }
214 | }
215 | }
216 | Result.failure(
217 | workDataOf(ExceptionConst.KEY_EXCEPTION to e.message)
218 | )
219 | }
220 |
221 | }
222 |
223 | }
224 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://jitpack.io/#khushpanchal/Ketch)
2 | [](https://androidweekly.net/issues/issue-622)
3 |
4 | # Ketch
5 |
6 | ## An Android File downloader library based on WorkManager with pause and resume support
7 |
8 |
9 |
10 |
11 |
12 | # About Ketch
13 |
14 | Ketch is a simple, powerful, customisable file downloader library for Android built entirely in Kotlin. It simplifies the process of downloading files in Android applications by leveraging the power of WorkManager. Ketch guarantees the download irrespective of application state.
15 |
16 |
17 |
18 |
19 |
20 | # Why use Ketch
21 |
22 | - Ketch can download any type of file. (jpg, png, gif, mp4, mp3, pdf, apk and many more)
23 | - Ketch guarantees file download unless canceled explicitly or download is failed.
24 | - Ketch provides all download info including speed, file size, progress.
25 | - Ketch provides option to pause, resume, cancel, retry and delete the download file.
26 | - Ketch provides option to observe download items (or single download item) as Flow.
27 | - Ketch can download multiple files in parallel.
28 | - Ketch supports large file downloads.
29 | - Ketch provides various customisation including custom timeout, custom okhttp client and custom notification.
30 | - Ketch is simple and very easy to use.
31 | - Ketch provide notification for each download providing download info (speed, time left, total size, progress).
32 | - Ketch includes option to pause, resume, retry and cancel download from notification.
33 |
34 |
35 |
36 |
37 |
38 | # How to use Ketch
39 |
40 | ## Installation
41 |
42 | To integrate Ketch library into your Android project, follow these simple steps:
43 |
44 | - Update your settings.gradle file with the following dependency.
45 |
46 | ```Groovy
47 | dependencyResolutionManagement {
48 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
49 | repositories {
50 | google()
51 | mavenCentral()
52 | maven { url 'https://jitpack.io' } // this one
53 | }
54 | }
55 | ```
56 |
57 | - Update your module level build.gradle file with the following dependency.
58 |
59 | ```Groovy
60 | dependencies {
61 | implementation 'com.github.khushpanchal:Ketch:2.0.5' // Use latest available version
62 | }
63 | ```
64 |
65 | ## Usage
66 |
67 | - Simplest way to use Ketch:
68 |
69 | - Create the instance of Ketch in application onCreate. (Ketch is a singleton class and instance will create automatically on first use)
70 |
71 | ```Kotlin
72 | private lateinit var ketch: Ketch
73 | override fun onCreate() {
74 | super.onCreate()
75 | ketch = Ketch.builder().build(this)
76 | }
77 | ```
78 |
79 | - Call the download() function, pass the url, fileName, path and observe the download status
80 |
81 | ```Kotlin
82 | val id = ketch.download(url, fileName, path)
83 | lifecycleScope.launch {
84 | repeatOnLifecycle(Lifecycle.State.STARTED) {
85 | ketch.observeDownloadById(id)
86 | .flowOn(Dispatchers.IO)
87 | .collect { downloadModel ->
88 | // use downloadModel
89 | }
90 | }
91 | }
92 | ```
93 |
94 | #### Important Note 1: Add the appropriate storage permission based on API level or onFailure(error) callback will be triggered. Check out sample app for reference.
95 | #### Important Note 2: Add FOREGROUND_SERVICE_DATA_SYNC, WAKE_LOCK, INTERNET permission. Check out sample app for reference.
96 |
97 | - To cancel the download
98 |
99 | ```Kotlin
100 | ketch.cancel(downloadModel.id) // other options: cancel(tag), cancelAll()
101 | ```
102 |
103 | - To pause the download
104 |
105 | ```Kotlin
106 | ketch.pause(downloadModel.id) // other options: pause(tag), pauseAll()
107 | ```
108 |
109 | - To resume the download
110 |
111 | ```Kotlin
112 | ketch.resume(downloadModel.id) // other options: resume(tag), resumeAll()
113 | ```
114 |
115 | - To retry the download
116 |
117 | ```Kotlin
118 | ketch.retry(downloadModel.id) // other options: retry(tag), retryAll()
119 | ```
120 |
121 | - To delete the download
122 |
123 | ```Kotlin
124 | ketch.clearDb(downloadModel.id) // other options: clearDb(tag), clearAllDb(), clearDb(timeInMillis)
125 | ketch.clearDb(downloadModel.id, false) // Pass "false" to skip the actual file deletion (only clear entry from DB)
126 | ```
127 |
128 | - Observing: Provides state flow of download items (Each item carries download info like url, fileName, path, tag, id, timeQueued, status, progress, length, speed, lastModified, metaData, failureReason, eTag)
129 |
130 | ```Kotlin
131 | //To observe from Fragment
132 | viewLifecycleOwner.lifecycleScope.launch {
133 | repeatOnLifecycle(Lifecycle.State.STARTED) {
134 | ketch.observeDownloads()
135 | .flowOn(Dispatchers.IO)
136 | .collect {
137 | //set items to adapter
138 | }
139 | }
140 | }
141 | ```
142 |
143 | - To enable the notification:
144 |
145 | - Add the notification permission in manifest file.
146 |
147 | ```
148 |
149 | ```
150 |
151 | - Request permission from user (required from Android 13 (API level 33)). Check out sample app for reference.
152 | - Pass the notification config while initialization
153 |
154 | ```Kotlin
155 | ketch = Ketch.builder().setNotificationConfig(
156 | config = NotificationConfig(
157 | enabled = true,
158 | smallIcon = R.drawable.ic_launcher_foreground // It is required to pass the smallIcon for notification.
159 | )
160 | ).build(this)
161 | ```
162 |
163 | ## Customisation
164 |
165 | - Provide headers with network request.
166 |
167 | ```Kotlin
168 | ketch.download(url, fileName, path,
169 | headers = headers, //Default: Empty hashmap
170 | )
171 | ```
172 |
173 | - Tag: Group various downloads by providing additional Tag. (This tag can be use to cancel, pause, resume, delete the download as well)
174 |
175 | ```Kotlin
176 | ketch.download(url, fileName, path,
177 | tag = tag, //Default: null
178 | )
179 | ```
180 |
181 | - Download config: Provides custom connect and read timeout
182 |
183 | ```Kotlin
184 | ketch = Ketch.builder().setDownloadConfig(
185 | config = DownloadConfig(
186 | connectTimeOutInMs = 20000L, //Default: 10000L
187 | readTimeOutInMs = 15000L //Default: 10000L
188 | )
189 | ).build(this)
190 | ```
191 |
192 | - Custom OKHttp: Provides custom okhttp client
193 |
194 | ```Kotlin
195 | ketch = Ketch.builder().setOkHttpClient(
196 | okHttpClient = OkHttpClient
197 | .Builder()
198 | .connectTimeout(10000L)
199 | .readTimeout(10000L)
200 | .build()
201 | ).build(this)
202 | ```
203 |
204 | - Notification config: Provide custom notification config
205 |
206 | ```Kotlin
207 | ketch = Ketch.builder().setNotificationConfig(
208 | config = NotificationConfig(
209 | enabled = true, //Default: false
210 | channelName = channelName, //Default: "File Download"
211 | channelDescription = channelDescription, //Default: "Notify file download status"
212 | importance = importance, //Default: NotificationManager.IMPORTANCE_HIGH
213 | smallIcon = smallIcon, //It is required
214 | showSpeed = true, //Default: true
215 | showSize = true, //Default: true
216 | showTime = true //Default: true
217 | )
218 | ).build(this)
219 | ```
220 |
221 | # Blog
222 |
223 | Check out the blog to understand working of Ketch (High Level Design): [https://medium.com/@khush.panchal123/ketch-android-file-downloader-library-7369f7b93bd1](https://medium.com/@khush.panchal123/ketch-android-file-downloader-library-7369f7b93bd1)
224 |
225 | ### High level Design
226 |
227 |