├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── misc.xml └── vcs.xml ├── CHANGELOG.md ├── README.md ├── android-sample ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── billbook │ │ └── lib │ │ └── downloader │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── billbook │ │ │ └── lib │ │ │ └── downloader │ │ │ ├── AppModule.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainScreen.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── SampleApp.kt │ │ │ └── Util.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.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 │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── billbook │ └── lib │ └── downloader │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── buildSrc ├── .gitignore ├── build.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ └── Extentions.kt ├── deploy_docs.sh ├── docs ├── best_practices.md ├── change_logs.md ├── contributing.md ├── css │ └── site.css ├── downloader_pipeline.md ├── faq.md ├── getting_started.md ├── images │ └── logo.svg ├── in_android.md └── optional_settings.md ├── fakedata ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── java │ └── com │ └── billbook │ └── lib │ └── downloader │ └── FakeData.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── java-sample ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── java │ └── com │ └── billbook │ └── lib │ └── case │ ├── FileExistsInterceptor.kt │ ├── ReporterEventListener.kt │ └── UseCase.kt ├── mkdocs.yml ├── okdownloader-android ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── billbook │ │ └── lib │ │ └── downloader │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── com │ │ └── billbook │ │ └── lib │ │ └── downloader │ │ ├── CopyOnExistsInterceptor.kt │ │ ├── DownloadRequest.kt │ │ ├── NetworkInterceptor.kt │ │ ├── NetworkMonitor.kt │ │ ├── Observables.kt │ │ ├── StorageInterceptor.kt │ │ └── internal │ │ ├── Database.kt │ │ └── Record.kt │ └── test │ └── java │ └── com │ └── billbook │ └── lib │ └── downloader │ └── ExampleUnitTest.kt ├── okdownloader ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ └── java │ │ └── com │ │ └── billbook │ │ └── lib │ │ └── downloader │ │ ├── Download.kt │ │ ├── DownloadException.kt │ │ ├── DownloadPool.kt │ │ ├── Downloader.kt │ │ ├── ErrorCode.kt │ │ ├── EventListener.kt │ │ ├── Interceptor.kt │ │ └── internal │ │ ├── core │ │ ├── BuiltinInterceptor.kt │ │ ├── Dispatcher.kt │ │ ├── DownloadCall.kt │ │ ├── IOExchange.kt │ │ └── InterceptorChain.kt │ │ ├── exception │ │ └── InternalException.kt │ │ └── util │ │ ├── Http.kt │ │ ├── Preconditions.kt │ │ └── Util.kt │ └── test │ └── java │ └── com │ └── billbook │ └── lib │ └── downloader │ ├── DownloadUnitTest.kt │ └── FakeData.kt ├── screencaps └── 1.png └── settings.gradle.kts /.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 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | /downloads 17 | # Docs 18 | docs/api 19 | site 20 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## Version 1.0.1 4 | 5 | __2023-08-01__ 6 | 7 | - New: 支持分块下载(即多线程下载) 8 | - New:支持设置 DownloadPool 9 | - Remove:移除callbackOn API 10 | 11 | ## Version 1.0.0 12 | 13 | __2023-07-25__ 14 | 15 | - New:Version 1.0.0 发布 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | OkDownloader 2 | ============ 3 | 4 | A downloader library for Java and Android based on OkHttp. 5 | 6 | * Easy to use: Simple API similar to OkHttp. 7 | * Rich in features: Supports synchronous/asynchronous downloads, network restrictions, multithreading, task priorities, resource verification, and more. 8 | * Modern: Written in Kotlin and based on OkHttp. 9 | * Easy to extend: Supports adding interceptors through code and provides extension through the `SPI` mechanism. 10 | 11 | Download 12 | -------- 13 | 14 | OkDownloader is available on `mavenCentral()`. 15 | 16 | ```kotlin 17 | implementation("io.github.ydxlt:okdownloader:1.0.0") 18 | ``` 19 | 20 | Quick Start 21 | ----------- 22 | 23 | Build a downloader instance just like building an OkHttpClient. 24 | 25 | ```kotlin 26 | val downloader = Downloader.Builder().build() 27 | ``` 28 | 29 | Start download 30 | 31 | ```kotlin 32 | val request = Download.Request.Builder() 33 | .url(url) 34 | .into(path) // or into(file) 35 | .build() 36 | val call = downloader.newCall(request) 37 | val response = call.execute() // synchronous download 38 | // use response here 39 | ``` 40 | 41 | or 42 | 43 | ```kotlin 44 | call.enqueue() // Asynchronous download 45 | ``` 46 | 47 | Add callback listeners 48 | 49 | ```kotlin 50 | call.enqueue(object : Download.Callback { 51 | // ... 52 | override fun onSuccess(call: Download.Call, response: Download.Response) { 53 | // do your job 54 | } 55 | 56 | override fun onFailure(call: Download.Call, response: Download.Response) { 57 | // do your job 58 | } 59 | }) 60 | ``` 61 | 62 | Cancel download 63 | 64 | ```kotlin 65 | call.cancel() // or call.cancelSafely() 66 | ``` 67 | 68 | Check out OkDownloader's [full documentation here](https://ydxlt.github.io/okdownloader/getting_started/). 69 | 70 | How to Expand 71 | ------------- 72 | 73 | Add interceptors through code 74 | 75 | ```kotlin 76 | val downloader = Downloader.Builder() 77 | .addInterceptor(CustomInterceptor()) 78 | .build() 79 | ``` 80 | 81 | or 82 | 83 | Declare your interceptors in `META-INF/services/com.billbook.lib.Interceptor` using the `SPI` mechanism. 84 | 85 | ```kotlin 86 | com.example.CustomInterceptor1 87 | com.example.CustomInterceptor2 88 | com.example.CustomInterceptor3 89 | ``` 90 | 91 | R8/Proguard 92 | ----------- 93 | 94 | OkDownloader is fully compatible with R8 out of the box and doesn't require adding any extra rules. If you use Proguard, you may need to add rules for [OkHttp](https://github.com/square/okhttp/blob/master/okhttp/src/jvmMain/resources/META-INF/proguard/okhttp3.pro) and [Okio](https://github.com/square/okio/blob/master/okio/src/jvmMain/resources/META-INF/proguard/okio.pro). 95 | 96 | License 97 | ======= 98 | 99 | ``` 100 | Copyright 2023 Billbook, Inc. 101 | 102 | Licensed under the Apache License, Version 2.0 (the "License"); 103 | you may not use this file except in compliance with the License. 104 | You may obtain a copy of the License at 105 | 106 | http://www.apache.org/licenses/LICENSE-2.0 107 | 108 | Unless required by applicable law or agreed to in writing, software 109 | distributed under the License is distributed on an "AS IS" BASIS, 110 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 111 | See the License for the specific language governing permissions and 112 | limitations under the License. 113 | ``` 114 | -------------------------------------------------------------------------------- /android-sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /android-sample/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("org.jetbrains.kotlin.android") 4 | id("com.google.dagger.hilt.android") 5 | kotlin("kapt") 6 | } 7 | 8 | android { 9 | namespace = "com.billbook.lib.downloader" 10 | compileSdk = 33 11 | 12 | defaultConfig { 13 | applicationId = "com.billbook.lib.downloader" 14 | minSdk = 21 15 | targetSdk = 33 16 | versionCode = 1 17 | versionName = "1.0" 18 | 19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 20 | } 21 | 22 | buildTypes { 23 | release { 24 | isMinifyEnabled = false 25 | proguardFiles( 26 | getDefaultProguardFile("proguard-android-optimize.txt"), 27 | "proguard-rules.pro" 28 | ) 29 | } 30 | } 31 | 32 | buildFeatures { 33 | compose = true 34 | } 35 | 36 | composeOptions { 37 | kotlinCompilerExtensionVersion = "1.4.6" 38 | } 39 | } 40 | 41 | dependencies { 42 | implementation("androidx.core:core-ktx:1.8.0") 43 | implementation("androidx.appcompat:appcompat:1.6.1") 44 | implementation("com.google.android.material:material:1.9.0") 45 | testImplementation("junit:junit:4.13.2") 46 | androidTestImplementation("androidx.test.ext:junit:1.1.5") 47 | androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") 48 | implementation(project(path = ":okdownloader-android")) 49 | 50 | val composeBom = platform("androidx.compose:compose-bom:2023.05.01") 51 | implementation(composeBom) 52 | androidTestImplementation(composeBom) 53 | 54 | // Choose one of the following: 55 | // Material Design 3 56 | implementation("androidx.compose.material3:material3") 57 | // or skip Material Design and build directly on top of foundational components 58 | implementation("androidx.compose.foundation:foundation") 59 | // or only import the main APIs for the underlying toolkit systems, 60 | // such as input and measurement/layout 61 | implementation("androidx.compose.ui:ui") 62 | 63 | // Android Studio Preview support 64 | implementation("androidx.compose.ui:ui-tooling-preview") 65 | debugImplementation("androidx.compose.ui:ui-tooling") 66 | 67 | // UI Tests 68 | androidTestImplementation("androidx.compose.ui:ui-test-junit4") 69 | debugImplementation("androidx.compose.ui:ui-test-manifest") 70 | 71 | // Optional - Included automatically by material, only add when you need 72 | // the icons but not the material library (e.g. when using Material3 or a 73 | // custom design system based on Foundation) 74 | implementation("androidx.compose.material:material-icons-core") 75 | // Optional - Add full set of material icons 76 | implementation("androidx.compose.material:material-icons-extended") 77 | // Optional - Add window size utils 78 | implementation("androidx.compose.material3:material3-window-size-class") 79 | 80 | // Optional - Integration with activities 81 | implementation("androidx.activity:activity-compose:1.6.1") 82 | // Optional - Integration with ViewModels 83 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1") 84 | // Optional - Integration with LiveData 85 | implementation("androidx.compose.runtime:runtime-livedata") 86 | implementation("io.coil-kt:coil:2.4.0") 87 | implementation("io.coil-kt:coil-compose:2.4.0") 88 | 89 | implementation("com.google.dagger:hilt-android:2.44") 90 | kapt("com.google.dagger:hilt-android-compiler:2.44") 91 | implementation("androidx.hilt:hilt-navigation-compose:1.0.0") 92 | 93 | implementation(project(path = ":fakedata")) 94 | } -------------------------------------------------------------------------------- /android-sample/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 -------------------------------------------------------------------------------- /android-sample/src/androidTest/java/com/billbook/lib/downloader/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 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.billbook.lib.downloader", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /android-sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/billbook/lib/downloader/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import android.content.Context 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.android.qualifiers.ApplicationContext 8 | import dagger.hilt.components.SingletonComponent 9 | import javax.inject.Singleton 10 | 11 | /** 12 | * @author xluotong@gmail.com 13 | */ 14 | @Module 15 | @InstallIn(SingletonComponent::class) 16 | class AppModule { 17 | 18 | @Singleton 19 | @Provides 20 | fun provideDownloader(@ApplicationContext context: Context): Downloader { 21 | return Downloader.Builder() 22 | .addInterceptor(CopyOnExistsInterceptor(context, 1000)) 23 | .addInterceptor(NetworkInterceptor(context)) 24 | .build() 25 | } 26 | } -------------------------------------------------------------------------------- /android-sample/src/main/java/com/billbook/lib/downloader/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import android.os.Bundle 4 | import androidx.activity.compose.setContent 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.core.view.WindowCompat 7 | import androidx.fragment.app.FragmentActivity 8 | import dagger.hilt.android.AndroidEntryPoint 9 | 10 | /** 11 | * @author xluotong@gmail.com 12 | */ 13 | @AndroidEntryPoint 14 | class MainActivity : FragmentActivity() { 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | WindowCompat.setDecorFitsSystemWindows(window, false) 19 | setContent { 20 | MaterialTheme { 21 | MainScreen(beans = FakeData.resources) 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/billbook/lib/downloader/MainScreen.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import android.text.format.Formatter 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.foundation.lazy.items 15 | import androidx.compose.foundation.shape.RoundedCornerShape 16 | import androidx.compose.material3.Button 17 | import androidx.compose.material3.LinearProgressIndicator 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.Scaffold 20 | import androidx.compose.material3.SnackbarHost 21 | import androidx.compose.material3.SnackbarHostState 22 | import androidx.compose.material3.Text 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.collectAsState 25 | import androidx.compose.runtime.getValue 26 | import androidx.compose.runtime.remember 27 | import androidx.compose.ui.Alignment 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.draw.clip 30 | import androidx.compose.ui.layout.ContentScale 31 | import androidx.compose.ui.platform.LocalContext 32 | import androidx.compose.ui.text.style.TextOverflow 33 | import androidx.compose.ui.tooling.preview.Preview 34 | import androidx.compose.ui.unit.dp 35 | import androidx.hilt.navigation.compose.hiltViewModel 36 | import coil.compose.AsyncImage 37 | 38 | /** 39 | * @author xluotong@gmail.com 40 | */ 41 | @Composable 42 | internal fun MainScreen( 43 | beans: List, 44 | viewModel: MainViewModel = hiltViewModel() 45 | ) { 46 | val states by viewModel.states.collectAsState() 47 | val snackbarState = remember { SnackbarHostState() } 48 | Scaffold(snackbarHost = { SnackbarHost(hostState = snackbarState) }) { 49 | LazyColumn(modifier = Modifier.padding(it)) { 50 | items(items = beans) { item -> 51 | val state = states.getOrElse(item.url) { DownloadState.IDLE } 52 | ListItem( 53 | item = item, 54 | state = state, 55 | onClick = { 56 | when (state) { 57 | DownloadState.IDLE, is DownloadState.ERROR -> { 58 | viewModel.download(item) 59 | } 60 | 61 | is DownloadState.DOWNLOADING -> { 62 | viewModel.pause(item) 63 | } 64 | 65 | DownloadState.FINISH -> { 66 | viewModel.redownload(item) 67 | } 68 | 69 | else -> {} 70 | } 71 | } 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | 78 | @Composable 79 | private fun ListItem(item: ResourceBean, state: DownloadState, onClick: () -> Unit) { 80 | Row( 81 | modifier = Modifier 82 | .fillMaxWidth() 83 | .padding(horizontal = 16.dp, vertical = 16.dp) 84 | ) { 85 | AsyncImage( 86 | modifier = Modifier 87 | .size(60.dp) 88 | .clip(RoundedCornerShape(16.dp)), 89 | model = item.icon, 90 | contentDescription = "Icon", 91 | contentScale = ContentScale.Crop 92 | ) 93 | Spacer(modifier = Modifier.width(10.dp)) 94 | Column( 95 | modifier = Modifier 96 | .weight(1f) 97 | .height(70.dp) 98 | ) { 99 | Row(verticalAlignment = Alignment.CenterVertically) { 100 | Column(modifier = Modifier.weight(1f)) { 101 | Text(text = item.name, style = MaterialTheme.typography.bodyMedium) 102 | Spacer(modifier = Modifier.height(6.dp)) 103 | Text( 104 | text = "size: ${Formatter.formatFileSize(LocalContext.current, item.size)}", 105 | style = MaterialTheme.typography.labelSmall 106 | ) 107 | } 108 | Spacer(modifier = Modifier.width(10.dp)) 109 | Button( 110 | modifier = Modifier 111 | .align(Alignment.CenterVertically) 112 | .width(110.dp), 113 | onClick = onClick, 114 | enabled = (state is DownloadState.RETRYING || state is DownloadState.CHECKING || state is DownloadState.WAIT).not() 115 | ) { 116 | Text( 117 | text = when (state) { 118 | DownloadState.IDLE -> "Download" 119 | DownloadState.FINISH -> "Success" 120 | is DownloadState.ERROR -> "Retry" 121 | DownloadState.RETRYING -> "Retrying" 122 | DownloadState.CHECKING -> "Checking" 123 | DownloadState.WAIT -> "Waiting" 124 | is DownloadState.DOWNLOADING -> "Pause" 125 | }, 126 | style = MaterialTheme.typography.bodySmall, 127 | overflow = TextOverflow.Ellipsis, 128 | maxLines = 1 129 | ) 130 | } 131 | } 132 | when (state) { 133 | is DownloadState.DOWNLOADING -> { 134 | Spacer(modifier = Modifier.height(8.dp)) 135 | Row(verticalAlignment = Alignment.CenterVertically) { 136 | LinearProgressIndicator( 137 | modifier = Modifier.weight(1f), 138 | progress = state.progress 139 | ) 140 | Spacer(modifier = Modifier.width(8.dp)) 141 | Text( 142 | text = "${String.format("%.2f", state.speed)} M/s", 143 | style = MaterialTheme.typography.labelSmall 144 | ) 145 | } 146 | } 147 | 148 | is DownloadState.ERROR -> { 149 | Spacer(modifier = Modifier.height(10.dp)) 150 | Text( 151 | text = "error code: ${state.code}", 152 | style = MaterialTheme.typography.labelSmall 153 | ) 154 | } 155 | 156 | DownloadState.FINISH -> { 157 | Text( 158 | text = "click to download again", 159 | style = MaterialTheme.typography.labelSmall 160 | ) 161 | } 162 | 163 | else -> {} 164 | } 165 | } 166 | } 167 | } 168 | 169 | @Preview 170 | @Composable 171 | private fun MainScreenPreview() { 172 | MainScreen(beans = FakeData.resources) 173 | } -------------------------------------------------------------------------------- /android-sample/src/main/java/com/billbook/lib/downloader/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.StateFlow 12 | import kotlinx.coroutines.flow.update 13 | import kotlinx.coroutines.launch 14 | import kotlinx.coroutines.sync.Mutex 15 | import kotlinx.coroutines.sync.withLock 16 | import java.io.File 17 | import javax.inject.Inject 18 | 19 | private val Context.downloadDir: File get() = File(this.filesDir, "download") 20 | private const val TAG = "MainViewModel" 21 | 22 | /** 23 | * @author xluotong@gmail.com 24 | */ 25 | @HiltViewModel 26 | class MainViewModel @Inject constructor( 27 | @ApplicationContext private val context: Context, 28 | private val downloader: Downloader 29 | ) : ViewModel() { 30 | 31 | private val _states: MutableStateFlow> = MutableStateFlow(emptyMap()) 32 | val states: StateFlow> = _states 33 | private val calls = mutableMapOf() 34 | private val mutex = Mutex() 35 | 36 | fun cancel(bean: ResourceBean) = viewModelScope.launch(Dispatchers.IO) { 37 | mutex.withLock { 38 | calls[bean.url]?.let { 39 | it.cancelSafely() 40 | } 41 | } 42 | } 43 | 44 | fun redownload(bean: ResourceBean) = viewModelScope.launch(Dispatchers.IO) { 45 | download(bean, true) 46 | } 47 | 48 | fun pause(bean: ResourceBean) = viewModelScope.launch(Dispatchers.IO) { 49 | mutex.withLock { calls[bean.url]?.cancel() } 50 | updateState(bean, DownloadState.IDLE) 51 | } 52 | 53 | fun download(bean: ResourceBean, force: Boolean = false) = 54 | viewModelScope.launch(Dispatchers.IO) { 55 | val call = mutex.withLock { 56 | if (calls[bean.url] != null) return@launch 57 | val request = Download.Request.Builder() 58 | .url(bean.url) 59 | .into(File(context.downloadDir, bean.url.md5())) 60 | .apply { bean.md5?.let { md5(it) } } 61 | .build() 62 | downloader.newCall(request).also { calls[bean.url] = it } 63 | } 64 | if (force) { 65 | call.request.sourceFile().delete() 66 | call.request.destFile().delete() 67 | } 68 | updateState(bean, DownloadState.WAIT) 69 | call.execute(object : Download.Callback { 70 | 71 | private var lastByteSize: Long = 0L 72 | private var lastTime: Long = 0L 73 | 74 | override fun onStart(call: Download.Call) { 75 | updateState(bean, DownloadState.DOWNLOADING(0f, 0f)) 76 | lastByteSize = call.request.sourceFile().length() 77 | lastTime = System.currentTimeMillis() 78 | } 79 | 80 | override fun onLoading(call: Download.Call, current: Long, total: Long) { 81 | val currentTime = System.currentTimeMillis() 82 | if (currentTime - lastTime >= 1000) { 83 | val mb = (current - lastByteSize) * 1f / (1024 * 1024) 84 | val speed = mb / ((currentTime - lastTime) / 1000f) 85 | val progress = current * 1f / total 86 | updateState( 87 | bean, 88 | DownloadState.DOWNLOADING(progress, speed.coerceAtLeast(0f)) 89 | ) 90 | lastByteSize = current 91 | lastTime = currentTime 92 | } 93 | } 94 | 95 | override fun onCancel(call: Download.Call) { 96 | updateState(bean, DownloadState.IDLE) 97 | } 98 | 99 | override fun onChecking(call: Download.Call) { 100 | updateState(bean, DownloadState.CHECKING) 101 | } 102 | 103 | override fun onRetrying(call: Download.Call) { 104 | updateState(bean, DownloadState.RETRYING) 105 | } 106 | 107 | override fun onSuccess(call: Download.Call, response: Download.Response) { 108 | Log.i(TAG, "onSuccess response = $response") 109 | updateState(bean, DownloadState.FINISH) 110 | } 111 | 112 | override fun onFailure(call: Download.Call, response: Download.Response) { 113 | Log.e(TAG, "onFailure response = $response") 114 | updateState(bean, DownloadState.ERROR(response.code)) 115 | } 116 | }) 117 | mutex.withLock { calls -= bean.url } 118 | } 119 | 120 | private fun updateState(bean: ResourceBean, state: DownloadState) { 121 | val map = _states.value.toMutableMap() 122 | map[bean.url] = state 123 | _states.update { map } 124 | } 125 | 126 | override fun onCleared() { 127 | downloader.cancelAll() 128 | } 129 | } 130 | 131 | sealed interface DownloadState { 132 | 133 | object IDLE : DownloadState 134 | object WAIT : DownloadState 135 | class DOWNLOADING(val progress: Float, val speed: Float = 0f) : DownloadState 136 | object CHECKING : DownloadState 137 | object RETRYING : DownloadState 138 | object FINISH : DownloadState 139 | class ERROR(val code: Int) : DownloadState 140 | } 141 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/billbook/lib/downloader/SampleApp.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | /** 7 | * @author xluotong@gmail.com 8 | */ 9 | @HiltAndroidApp 10 | class SampleApp : Application() { 11 | 12 | override fun onCreate() { 13 | super.onCreate() 14 | } 15 | } 16 | 17 | 18 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/billbook/lib/downloader/Util.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import java.security.MessageDigest 4 | 5 | /** 6 | * @author xluotong@gmail.com 7 | */ 8 | inline fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } 9 | 10 | inline fun String.md5() = MessageDigest.getInstance("md5").digest(toByteArray()).toHex() -------------------------------------------------------------------------------- /android-sample/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /android-sample/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 | -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/android-sample/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/android-sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/android-sample/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/android-sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/android-sample/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/android-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/android-sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/android-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android-sample/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /android-sample/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /android-sample/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | android-sample 3 | -------------------------------------------------------------------------------- /android-sample/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /android-sample/src/test/java/com/billbook/lib/downloader/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 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 | } -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | import downloader.by 3 | import downloader.groupId 4 | import downloader.versionName 5 | import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile 6 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 7 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 8 | import com.vanniktech.maven.publish.MavenPublishBaseExtension 9 | import com.vanniktech.maven.publish.SonatypeHost 10 | import groovy.util.Node 11 | import groovy.util.NodeList 12 | 13 | buildscript { 14 | repositories { 15 | google() 16 | mavenCentral() 17 | } 18 | 19 | dependencies { 20 | classpath(libs.gradlePlugin.android) 21 | classpath(libs.gradlePlugin.kotlin) 22 | classpath(libs.gradlePlugin.mavenPublish) 23 | } 24 | } 25 | 26 | plugins { 27 | alias(libs.plugins.hilt) apply false 28 | } 29 | 30 | allprojects { 31 | // Necessary to publish to Maven. 32 | group = groupId 33 | version = versionName 34 | 35 | // Target JVM 8. 36 | tasks.withType().configureEach { 37 | sourceCompatibility = JavaVersion.VERSION_1_8.toString() 38 | targetCompatibility = JavaVersion.VERSION_1_8.toString() 39 | options.compilerArgs = options.compilerArgs + "-Xlint:-options" 40 | } 41 | tasks.withType().configureEach { 42 | compilerOptions.jvmTarget by JvmTarget.JVM_1_8 43 | } 44 | 45 | dependencies { 46 | modules { 47 | module("org.jetbrains.kotlin:kotlin-stdlib-jdk7") { 48 | replacedBy("org.jetbrains.kotlin:kotlin-stdlib") 49 | } 50 | module("org.jetbrains.kotlin:kotlin-stdlib-jdk8") { 51 | replacedBy("org.jetbrains.kotlin:kotlin-stdlib") 52 | } 53 | } 54 | } 55 | 56 | // Uninstall test APKs after running instrumentation tests. 57 | tasks.whenTaskAdded { 58 | if (name == "connectedDebugAndroidTest") { 59 | finalizedBy("uninstallDebugAndroidTest") 60 | } 61 | } 62 | } 63 | 64 | subprojects { 65 | tasks.withType { 66 | kotlinOptions { 67 | jvmTarget = JavaVersion.VERSION_1_8.toString() 68 | freeCompilerArgs = listOf( 69 | "-Xjvm-default=all", 70 | ) 71 | } 72 | } 73 | 74 | tasks.withType { 75 | sourceCompatibility = JavaVersion.VERSION_1_8.toString() 76 | targetCompatibility = JavaVersion.VERSION_1_8.toString() 77 | } 78 | 79 | plugins.withId("com.vanniktech.maven.publish.base") { 80 | val publishingExtension = extensions.getByType(PublishingExtension::class.java) 81 | configure { 82 | publishToMavenCentral(SonatypeHost.S01, automaticRelease = true) 83 | signAllPublications() 84 | pom { 85 | name.set(project.name) 86 | description.set("Square’s meticulous HTTP client for Java and Kotlin.") 87 | url.set("https://ydxlt.github.io/okdownloader/") 88 | licenses { 89 | license { 90 | name.set("The Apache Software License, Version 2.0") 91 | url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") 92 | distribution.set("repo") 93 | } 94 | } 95 | scm { 96 | connection.set("scm:git:https://github.com/ydxlt/okdownloader.git") 97 | developerConnection.set("scm:git:ssh://git@github.com/ydxlt/okdownloader.git") 98 | url.set("https://github.com/ydxlt/okdownloader") 99 | } 100 | developers { 101 | developer { 102 | name.set("Billbook, Inc.") 103 | } 104 | } 105 | } 106 | 107 | // Configure the kotlinMultiplatform artifact to depend on the JVM artifact in pom.xml only. 108 | // This hack allows Maven users to continue using our original OkDownloader artifact names (like 109 | // com.billbook.okdownloader:okdownloader:1.x.y) even though we changed that artifact from JVM-only 110 | // to Kotlin Multiplatform. Note that module.json doesn't need this hack. 111 | val mavenPublications = publishingExtension.publications.withType() 112 | mavenPublications.configureEach { 113 | if (name != "jvm") return@configureEach 114 | val jvmPublication = this 115 | val kmpPublication = mavenPublications.getByName("kotlinMultiplatform") 116 | kmpPublication.pom.withXml { 117 | val root = asNode() 118 | val dependencies = (root["dependencies"] as NodeList).firstOrNull() as Node? 119 | ?: root.appendNode("dependencies") 120 | for (child in dependencies.children().toList()) { 121 | dependencies.remove(child as Node) 122 | } 123 | dependencies.appendNode("dependency").apply { 124 | appendNode("groupId", jvmPublication.groupId) 125 | appendNode("artifactId", jvmPublication.artifactId) 126 | appendNode("version", jvmPublication.version) 127 | appendNode("scope", "compile") 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } -------------------------------------------------------------------------------- /buildSrc/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile 3 | 4 | plugins { 5 | `kotlin-dsl-base` 6 | } 7 | 8 | repositories { 9 | google() 10 | mavenCentral() 11 | } 12 | 13 | dependencies { 14 | implementation(libs.gradlePlugin.android) 15 | implementation(libs.gradlePlugin.kotlin) 16 | implementation(libs.gradlePlugin.mavenPublish) 17 | } 18 | 19 | // Target JVM 11. 20 | tasks.withType().configureEach { 21 | sourceCompatibility = JavaVersion.VERSION_11.toString() 22 | targetCompatibility = JavaVersion.VERSION_11.toString() 23 | } 24 | 25 | tasks.withType().configureEach { 26 | compilerOptions.jvmTarget.set(JvmTarget.JVM_11) 27 | } 28 | -------------------------------------------------------------------------------- /buildSrc/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "okdownloader" 2 | 3 | dependencyResolutionManagement { 4 | versionCatalogs { 5 | create("libs") { 6 | from(files("../gradle/libs.versions.toml")) 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Extentions.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("NOTHING_TO_INLINE") 2 | 3 | package downloader 4 | 5 | import kotlin.math.pow 6 | import org.gradle.api.Project 7 | import org.gradle.api.provider.Property 8 | 9 | val Project.minSdk: Int 10 | get() = intProperty("minSdk") 11 | 12 | val Project.targetSdk: Int 13 | get() = intProperty("targetSdk") 14 | 15 | val Project.compileSdk: Int 16 | get() = intProperty("compileSdk") 17 | 18 | val Project.groupId: String 19 | get() = stringProperty("POM_GROUP_ID") 20 | 21 | val Project.versionName: String 22 | get() = stringProperty("POM_VERSION") 23 | 24 | val Project.versionCode: Int 25 | get() = versionName 26 | .takeWhile { it.isDigit() || it == '.' } 27 | .split('.') 28 | .map { it.toInt() } 29 | .reversed() 30 | .sumByIndexed { index, unit -> 31 | // 1.2.3 -> 102030 32 | (unit * 10.0.pow(2 * index + 1)).toInt() 33 | } 34 | 35 | val publicModules = listOf( 36 | "okdownloader", 37 | "okdownloader-android", 38 | ) 39 | 40 | private fun Project.intProperty(name: String): Int { 41 | return (property(name) as String).toInt() 42 | } 43 | 44 | fun Project.stringProperty(name: String): String { 45 | return property(name) as String 46 | } 47 | 48 | private inline fun List.sumByIndexed(selector: (Int, T) -> Int): Int { 49 | var index = 0 50 | var sum = 0 51 | for (element in this) { 52 | sum += selector(index++, element) 53 | } 54 | return sum 55 | } 56 | 57 | inline infix fun Property.by(value: T) = set(value) 58 | -------------------------------------------------------------------------------- /deploy_docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | sed -e '/full documentation here/ { N; d; }' < README.md > docs/index.md 3 | 4 | cp -f CHANGELOG.md docs/change_logs.md 5 | 6 | mkdocs gh-deploy 7 | 8 | # Clean up. 9 | rm docs/index.md \ 10 | -------------------------------------------------------------------------------- /docs/best_practices.md: -------------------------------------------------------------------------------- 1 | Best Practices 2 | ============== 3 | 4 | Managing Downloader Objects 5 | --------------------------- 6 | 7 | Build and maintain a singleton downloader object to facilitate centralized management of download tasks. Different instances of the downloader have different download pools. 8 | 9 | In Android 🫴Hilt 10 | 11 | ```kotlin 12 | @Module 13 | @InstallIn(SingletonComponent::class) 14 | class AppModule { 15 | 16 | @Singleton 17 | @Provides 18 | fun provideDownloader(@ApplicationContext context: Context): Downloader { 19 | return Downloader.Builder() 20 | .addInterceptor(CopyOnExistsInterceptor(context, 1000)) 21 | .addInterceptor(NetworkInterceptor(context)) 22 | .build() 23 | } 24 | } 25 | ``` 26 | 27 | If you want to manage tasks for different business purposes within your app, you can initialize a downloader and create new downloaders using the `newBuilder` method of the downloader. This allows the new downloaders to have different download pools while reusing thread pools and other resources. 28 | 29 | ```kotlin 30 | val downloader = Downloader.Builder().build() 31 | ``` 32 | 33 | Business 1 34 | 35 | ```kotlin 36 | val downloaderForBiz1 = downloader.newBuilder().build() 37 | ``` 38 | 39 | Business 2 40 | 41 | ```kotlin 42 | val downloaderForBiz2 = downloader.newBuilder().build() 43 | ``` 44 | 45 | Using in Coroutines 46 | ------------------- 47 | 48 | Executing the call in the IO dispatcher 49 | 50 | ```kotlin 51 | withContext(Dispatchers.IO) { 52 | val request = Download.Request.Builder() 53 | .url(url) 54 | .into(file) 55 | .build() 56 | val response = downloader.newCall(request).execute() 57 | } 58 | ``` 59 | 60 | By using coroutines with the IO dispatcher, you can perform the download operation asynchronously without blocking the main thread. 61 | -------------------------------------------------------------------------------- /docs/change_logs.md: -------------------------------------------------------------------------------- 1 | ## Change Log 2 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Keeping the project small and stable limits our ability to accept new contributors. We are not seeking new committers at this time, but some small contributions are welcome. 4 | 5 | If you’ve found a security problem, please follow our [bug bounty](https://square.github.io/okhttp/security/) program. 6 | 7 | If you’ve found a bug, please contribute a failing test case so we can study and fix it. 8 | 9 | If you have a new feature idea, please build it in an external library. There are [many libraries](https://square.github.io/okhttp/works_with_okhttp/) that sit on top or hook in via existing APIs. If you build something that integrates with OkDownloader, tell us so that we can link it! 10 | 11 | *Modified from OkHttp's [Contributing](https://square.github.io/okhttp/contributing/) section.* 12 | -------------------------------------------------------------------------------- /docs/css/site.css: -------------------------------------------------------------------------------- 1 | .md-typeset h1, .md-typeset h2, .md-typeset h3, .md-typeset h4 { 2 | font-weight: bold; 3 | color: #353535; 4 | } -------------------------------------------------------------------------------- /docs/downloader_pipeline.md: -------------------------------------------------------------------------------- 1 | # How to Expand 2 | 3 | Add interceptors through code 4 | 5 | ```kotlin 6 | val downloader = Downloader.Builder() 7 | .addInterceptor(CustomInterceptor()) 8 | .build() 9 | ``` 10 | 11 | or 12 | 13 | Declare your interceptors in `META-INF/services/com.billbook.lib.Interceptor` using the `SPI` mechanism. 14 | 15 | ```kotlin 16 | com.example.CustomInterceptor1 17 | com.example.CustomInterceptor2 18 | com.example.CustomInterceptor3 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | 2 | FAQ 3 | === 4 | 5 | How to pause a download 6 | ----------------------- 7 | 8 | In OkDownloader, there is no specific `pause` method. Instead, you can use the `cancel` method, which cancels the download without deleting the already downloaded files, effectively pausing the download. 9 | 10 | ```kotlin 11 | call.cancel() 12 | ``` 13 | 14 | > The `cancelSafely()` method will deletes the downloaded temporary files. 15 | 16 | How to resume a download 17 | ------------------------ 18 | 19 | In OkDownloader, there is no dedicated `resume` method. Download tasks in OkDownloader are treated as one-time operations. If a task is canceled and you want to resume it, you need to create a new `call` object and execute it again. 20 | 21 | ```kotlin 22 | downloader.newCall(request).execute() // or enqueue() 23 | ``` 24 | 25 | Please note that this approach creates a new download task starting from the beginning. If you want to implement resumable downloads with support for partial downloads, you would need to handle the logic yourself, such as saving the progress and resuming from where it left off. 26 | 27 | Please refer to the specific documentation of the downloader library you are using for more detailed information on pausing and resuming download tasks. 28 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | Asynchronous Download 5 | --------------------- 6 | 7 | `Asynchronous download` means executing in an asynchronous thread. 8 | 9 | ```kotlin 10 | val request = Download.Request.Builder() 11 | .url(url) 12 | .into(file) 13 | .build() 14 | val call = downloader.newCall(request) 15 | call.enqueue() 16 | ``` 17 | 18 | Add callback listeners 19 | 20 | ```kotlin 21 | call.enqueue(object : Download.Callback { 22 | // ... 23 | override fun onSuccess(call: Download.Call, response: Download.Response) { 24 | // do your job 25 | } 26 | 27 | override fun onFailure(call: Download.Call, response: Download.Response) { 28 | // do your job 29 | } 30 | }) 31 | ``` 32 | 33 | Synchronous Download 34 | -------------------- 35 | 36 | `Synchronous download` means executing in the current thread, blocking the calling thread. 37 | 38 | ```kotlin 39 | val request = Download.Request.Builder() 40 | .url(url) 41 | .into(file) 42 | .build() 43 | val call = downloader.newCall(request) 44 | val response = call.execute() 45 | ``` 46 | 47 | Add callback listeners 48 | 49 | ```kotlin 50 | call.execute(object : Download.Callback { 51 | // ... 52 | override fun onSuccess(call: Download.Call, response: Download.Response) { 53 | // do your job 54 | } 55 | 56 | override fun onFailure(call: Download.Call, response: Download.Response) { 57 | // do your job 58 | } 59 | }) 60 | ``` 61 | 62 | Usually, synchronous download is used in coroutines: 63 | 64 | ```kotlin 65 | withContext(Dispatchers.IO) { 66 | val request = Download.Request.Builder() 67 | .url(url) 68 | .into(file) 69 | .build() 70 | val response = downloader.newCall(request).execute() 71 | } 72 | ``` 73 | 74 | Canceling Download 75 | ------------------ 76 | 77 | ```kotlin 78 | call.cancel() 79 | ``` 80 | 81 | or 82 | 83 | ```kotlin 84 | call.cancelSafely() 85 | ``` 86 | 87 | The difference between `cancel` and `cancelSafely` is that `cancelSafely()` will delete the downloaded temporary file. 88 | 89 | 90 | Canceling all download tasks 91 | ---------------------------- 92 | 93 | ```kotlin 94 | downloader.cancelAll() 95 | ``` 96 | 97 | or 98 | 99 | ```kotlin 100 | downloader.cancelAllSafely() 101 | ``` 102 | 103 | `cancelAllSafely` will delete the downloaded temporary files, including the breakpoint file. The next download will start from scratch. 104 | 105 | 106 | File Verification 107 | ----------------- 108 | 109 | Set the `MD5` value to perform MD5 verification upon download completion 110 | 111 | ```kotlin 112 | val request = Download.Request.Builder() 113 | // .. 114 | .md5(md5) 115 | .build() 116 | ``` 117 | 118 | Set the `size` of the file to verify the file size upon download completion: 119 | 120 | ```kotlin 121 | val request = Download.Request.Builder() 122 | // .. 123 | .size(size) 124 | .build() 125 | ``` 126 | 127 | Setting Retries 128 | --------------- 129 | 130 | Set the number of retries. The default number of retries is 3: 131 | 132 | ```kotlin 133 | val request = Download.Request.Builder() 134 | // .. 135 | .retry(5) 136 | .build() 137 | ``` 138 | 139 | Setting Priority 140 | ---------------- 141 | 142 | Supports three priority levels: High, Middle, and Low. The default priority is Middle 143 | 144 | ```kotlin 145 | val request = Download.Request.Builder() 146 | // .. 147 | .priority(Download.Priority.HIGH) 148 | .build() 149 | ``` 150 | 151 | Setting Tags 152 | ------------ 153 | 154 | Tags are used to label tasks and can be used to differentiate different tasks within the app for reporting purposes: 155 | 156 | ```kotlin 157 | val request = Download.Request.Builder() 158 | // .. 159 | .tag(tag) 160 | .build() 161 | ``` 162 | 163 | Task Subscription 164 | ----------------- 165 | 166 | In addition to task callbacks, task subscription is supported: 167 | 168 | ```kotlin 169 | val subscriber = object : Download.Subscriber { 170 | override fun onSuccess(call: Download.Call, response: Download.Response) { 171 | // do your job 172 | } 173 | 174 | override fun onFailure(call: Download.Call, response: Download.Response) { 175 | // do your job 176 | } 177 | } 178 | downloader.subscribe(subscriber) 179 | ``` 180 | 181 | Unsubscribe: 182 | 183 | ```kotlin 184 | downloader.unsubscribe(subscriber) 185 | ``` 186 | -------------------------------------------------------------------------------- /docs/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/in_android.md: -------------------------------------------------------------------------------- 1 | In Android 2 | ========== 3 | 4 | In Android, you can add the following dependency 5 | 6 | ```kotlin 7 | implementation("io.github.ydxlt:okdownloader-android:1.0.0") 8 | ``` 9 | 10 | To add a copy interceptor that records download tasks and prioritizes copying for subsequent downloads of the same resource to `prevent duplicate downloads`, use the `CopyOnExists` mechanism: 11 | 12 | ```kotlin 13 | val downloader = Downloader.Builder() 14 | .addInterceptor(CopyOnExistsInterceptor(context, 1000)) 15 | .build() 16 | ``` 17 | 18 | > Note: The task records use Google's modern [Room](https://developer.android.com/jetpack/androidx/releases/room) database. 19 | 20 | To add a network interceptor that supports network restrictions 21 | 22 | ```kotlin 23 | val downloader = Downloader.Builder() 24 | .addInterceptor(NetworkInterceptor(context)) 25 | .build() 26 | ``` 27 | 28 | Afterward, you can set network restrictions 29 | 30 | ```kotlin 31 | val request = DownloadRequest.Builder() 32 | .networkOn(DownloadRequest.NETWORK_WIFI or DownloadRequest.NETWORK_DATA) 33 | .build() 34 | ``` 35 | 36 | To add a storage interceptor that checks for insufficient disk space and avoids ineffective downloads 37 | 38 | ```kotlin 39 | val downloader = Downloader.Builder() 40 | .addInterceptor(StorageInterceptor(100 * 1024 * 1024)) // 100MB 41 | .build() 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/optional_settings.md: -------------------------------------------------------------------------------- 1 | Optional Settings 2 | ======================== 3 | 4 | The following settings are optional and can be customized according to your project needs. 5 | 6 | Event Listeners 7 | --------------- 8 | 9 | Set download event listeners using the `eventListenerFactory` method. For example: 10 | 11 | ```kotlin 12 | val downloader = Downloader.Builder() 13 | .eventListenerFactory { ReporterEventListener() } 14 | .build() 15 | ``` 16 | 17 | ReporterEventListener: 18 | 19 | ```kotlin 20 | class ReporterEventListener : EventListener() { 21 | override fun callSuccess(call: Download.Call, response: Download.Response) { 22 | // do your job 23 | } 24 | 25 | override fun callFailed(call: Download.Call, response: Download.Response) { 26 | // do your job 27 | } 28 | } 29 | ``` 30 | 31 | Idle Task Callback 32 | ------------------ 33 | 34 | Set idle task callback using the `idleCallback` method 35 | 36 | ```kotlin 37 | val downloader = Downloader.Builder() 38 | .idleCallback { // handle download pool idle } 39 | .build() 40 | ``` 41 | 42 | Custom OkHttpClient 43 | ------------------- 44 | 45 | Set a custom OkHttpClient using the `okHttpClientFactory` method 46 | 47 | ```kotlin 48 | val downloader = Downloader.Builder() 49 | .okHttpClientFactory { buildOkHttpClient() } 50 | .build() 51 | 52 | private fun buildOkHttpClient(): OkHttpClient { 53 | return OkHttpClient.Builder() 54 | .connectTimeout(15, TimeUnit.SECONDS) 55 | .readTimeout(15, TimeUnit.SECONDS) 56 | .writeTimeout(15, TimeUnit.SECONDS) 57 | .retryOnConnectionFailure(true) 58 | .cache(null) 59 | .build() 60 | } 61 | ``` 62 | 63 | Set Default Retry Count 64 | ----------------------- 65 | 66 | Set the default retry count. The default is 3 67 | 68 | ```kotlin 69 | val downloader = Downloader.Builder() 70 | .defaultMaxRetry(10) 71 | .build() 72 | ``` 73 | 74 | Set Task Execution Thread Pool 75 | ------------------------------ 76 | 77 | Set a custom asynchronous download task execution thread pool using the `executorService` method 78 | 79 | ```kotlin 80 | val downloader = Downloader.Builder() 81 | .executorService(CustomExecutorService()) 82 | .build() 83 | ``` -------------------------------------------------------------------------------- /fakedata/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /fakedata/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("java-library") 3 | id("org.jetbrains.kotlin.jvm") 4 | } 5 | 6 | -------------------------------------------------------------------------------- /fakedata/src/main/java/com/billbook/lib/downloader/FakeData.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | /** 4 | * @author xluotong@gmail.com 5 | */ 6 | object FakeData { 7 | 8 | val resources: List = listOf( 9 | ResourceBean( 10 | url = "http://cdn.billbook.net.cn/apk/Threads%2C%20an%20Instagram%20app_291.0.0.31.111_Apkpure.apk", 11 | name = "Threads", 12 | icon = "https://image.winudf.com/v2/image1/Y29tLmluc3RhZ3JhbS5iYXJjZWxvbmFfaWNvbl8xNjg4MjYzMjE4XzAyMg/icon.webp?w=280&fakeurl=1&type=.webp", 13 | size = 76639442, 14 | md5 = "9631fff7a586b9870fb0116b136cbfef" 15 | ), 16 | ResourceBean( 17 | url = "http://cdn.billbook.net.cn/apk/Twitter_9.96.0-release.0_Apkpure.apk", 18 | name = "Twitter", 19 | icon = "https://image.winudf.com/v2/image1/Y29tLnR3aXR0ZXIuYW5kcm9pZF9pY29uXzE1NTU0NjI4MTJfMDI2/icon.webp?w=280&fakeurl=1&type=.webp", 20 | size = 113837675, 21 | md5 = "25eb790c62edf3363ffd899ed8ea8a7a" 22 | ), 23 | ResourceBean( 24 | url = "http://cdn.billbook.net.cn/apk/Cash%E2%80%99em%20All_%20Play%20%26%20Win_4.8.1-CashemAll_Apkpure.apk", 25 | name = "Cash’em All: Play", 26 | icon = "https://image.winudf.com/v2/image1/b25saW5lLmNhc2hlbWFsbC5hcHBfaWNvbl8xNTk0NDA0NDY5XzAwOA/icon.webp?w=280&fakeurl=1&type=.webp", 27 | size = 56572333, 28 | md5 = "1627a6317e820347ebeeec1aef6e6df3" 29 | ), 30 | ResourceBean( 31 | url = "http://cdn.billbook.net.cn/apk/TikTok_30.3.4_Apkpure.xapk", 32 | name = "TikTok", 33 | icon = "https://image.winudf.com/v2/image1/Y29tLnpoaWxpYW9hcHAubXVzaWNhbGx5X2ljb25fMTY2NjcyMjU0MF8wOTY/icon.webp?w=280&fakeurl=1&type=.webp", 34 | size = 129126005, 35 | md5 = "067095681c5fa97def29f2b83b7e6803" 36 | ), 37 | ResourceBean( 38 | url = "http://cdn.billbook.net.cn/apk/Facebook_422.0.0.26.76_Apkpure.xapk", 39 | name = "Facebook", 40 | icon = "https://image.winudf.com/v2/image1/Y29tLmZhY2Vib29rLmthdGFuYV9pY29uXzE1NTc5OTAwMzBfMDIz/icon.webp?w=280&fakeurl=1&type=.webp", 41 | size = 54846526, 42 | md5 = "dc050e289d9fe0f10ba2321740301f6d" 43 | ), 44 | ResourceBean( 45 | url = "http://cdn.billbook.net.cn/apk/CapCut%20-%20Video%20Editor_8.7.0_Apkpure.apk", 46 | name = "CapCut ", 47 | icon = "https://image.winudf.com/v2/image1/Y29tLmxlbW9uLmx2b3ZlcnNlYXNfaWNvbl8xNjYwMjE4OTc4XzA1NA/icon.webp?w=280&fakeurl=1&type=.webp", 48 | size = 170715905, 49 | md5 = "f34dcf22ec82dfd82d4ab2f110a2c2de" 50 | ), 51 | ResourceBean( 52 | url = "http://cdn.billbook.net.cn/apk/Snapchat_12.42.0.58_Apkpure.xapk", 53 | name = "Snapchat", 54 | icon = "https://image.winudf.com/v2/image1/Y29tLnNuYXBjaGF0LmFuZHJvaWRfaWNvbl8xNTY2ODQ0NzEzXzA4Nw/icon.webp?w=280&fakeurl=1&type=.webp", 55 | size = 97595724, 56 | md5 = "99a6e36d5523d223a651e62d9a2cc052" 57 | ), 58 | ResourceBean( 59 | url = "http://cdn.billbook.net.cn/apk/WhatsApp%20Messenger_2.23.15.3_Apkpure.apk", 60 | name = "WhatsApp", 61 | icon = "https://image.winudf.com/v2/image1/Y29tLndoYXRzYXBwX2ljb25fMTU1OTg1MDA2NF8wNjI/icon.webp?w=280&fakeurl=1&type=.webp", 62 | size = 54225683, 63 | md5 = "57dfd00e1a5319cf8d5fa9b1f9e1a28e" 64 | ), 65 | ResourceBean( 66 | url = "http://cdn.billbook.net.cn/apk/Instagram_290.0.0.13.76_Apkpure.apk", 67 | name = "Instagram", 68 | icon = "https://image.winudf.com/v2/image1/Y29tLmluc3RhZ3JhbS5hbmRyb2lkX2ljb25fMTY3NjM0ODUzN18wMzI/icon.webp?w=280&fakeurl=1&type=.webp", 69 | size = 53887484, 70 | md5 = "43e780faac8092ec5f294fa3a67bc719" 71 | ) 72 | ) 73 | } 74 | 75 | data class ResourceBean( 76 | val url: String, 77 | val icon: String, 78 | val name: String, 79 | val size: Long, 80 | val md5: String 81 | ) -------------------------------------------------------------------------------- /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 24 | 25 | POM_GROUP_ID=io.github.ydxlt 26 | POM_VERSION=1.0.1 27 | 28 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | room = "2.5.1" 3 | commonsCodec = "1.15" 4 | okhttp = "4.10.0" 5 | okio = "3.3.0" 6 | androidGradlePlugin = "8.0.2" 7 | kotlin = "1.8.22" 8 | junit = "4.13.2" 9 | androidTestJunitExt = "1.1.3" 10 | androidTestEspresso = "3.4.0" 11 | hilt = "2.44" 12 | mavenPublish = "0.25.3" 13 | 14 | [libraries] 15 | commons-codec = { group = "commons-codec", name = "commons-codec", version.ref = "commonsCodec" } 16 | room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } 17 | room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } 18 | okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } 19 | okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" } 20 | test-junit = { group = "junit", name = "junit", version.ref = "junit" } 21 | android-test-junit-ext = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } 22 | android-test-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidTestEspresso" } 23 | 24 | gradlePlugin-mavenPublish = { group = "com.vanniktech", name = "gradle-maven-publish-plugin", version.ref = "mavenPublish" } 25 | gradlePlugin-android = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } 26 | gradlePlugin-kotlin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } 27 | 28 | [plugins] 29 | android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } 30 | android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } 31 | android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } 32 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 33 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 34 | hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } 35 | binaryCompatibility = "org.jetbrains.kotlinx.binary-compatibility-validator:0.13.2" 36 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Apr 21 11:52:31 CST 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /java-sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /java-sample/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("java-library") 3 | id("org.jetbrains.kotlin.jvm") 4 | application 5 | } 6 | 7 | dependencies { 8 | implementation(project(path = ":okdownloader")) 9 | } -------------------------------------------------------------------------------- /java-sample/src/main/java/com/billbook/lib/case/FileExistsInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.case 2 | 3 | import com.billbook.lib.downloader.Download 4 | import com.billbook.lib.downloader.ErrorCode 5 | import com.billbook.lib.downloader.Interceptor 6 | 7 | /** 8 | * @author xluotong@gmail.com 9 | */ 10 | class FileExistsInterceptor : Interceptor { 11 | override fun intercept(chain: Interceptor.Chain): Download.Response { 12 | if (chain.request().destFile().exists()) { 13 | return Download.Response.Builder() 14 | .code(ErrorCode.EXISTS_SUCCESS) 15 | .message("File already exists") 16 | .build() 17 | } 18 | return chain.proceed(chain.request()) 19 | } 20 | } -------------------------------------------------------------------------------- /java-sample/src/main/java/com/billbook/lib/case/ReporterEventListener.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.case 2 | 3 | import com.billbook.lib.downloader.Download 4 | import com.billbook.lib.downloader.EventListener 5 | 6 | /** 7 | * @author xluotong@gmail.com 8 | */ 9 | class ReporterEventListener : EventListener() { 10 | override fun callSuccess(call: Download.Call, response: Download.Response) { 11 | println("DownloadReporter: Download successful: call = $call, response = $response") 12 | } 13 | 14 | override fun callFailed(call: Download.Call, response: Download.Response) { 15 | println("DownloadReporter: Download failed: call = $call, response = $response") 16 | } 17 | } -------------------------------------------------------------------------------- /java-sample/src/main/java/com/billbook/lib/case/UseCase.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.case 2 | 3 | import com.billbook.lib.downloader.Download 4 | import com.billbook.lib.downloader.Downloader 5 | 6 | fun main() { 7 | val downloader = Downloader.Builder() 8 | .addInterceptor(FileExistsInterceptor()) 9 | .eventListenerFactory { ReporterEventListener() } 10 | .idleCallback { println("DownloadPool idle!") } 11 | .build() 12 | val subscriber = object : Download.Subscriber { 13 | override fun onSuccess(call: Download.Call, response: Download.Response) { 14 | super.onSuccess(call, response) 15 | println("GlobalSubscriber: onSuccess call = $call, response = $response") 16 | } 17 | 18 | override fun onFailure(call: Download.Call, response: Download.Response) { 19 | super.onFailure(call, response) 20 | println("GlobalSubscriber: onFailure call = $call, response = $response") 21 | } 22 | } 23 | downloader.subscribe(subscriber) 24 | downloader.unsubscribe(subscriber) 25 | val url = "https://wap.pp.cn/app/dl/fs08/2023/03/21/2/110_083a6016054e988728d7b6b36f1fdb4b.apk" 26 | downloader.download(url, System.getProperty("user.dir") + "/downloads/test.apk") 27 | } 28 | 29 | private fun Downloader.download(url: String, path: String, md5: String? = null) { 30 | val request = Download.Request.Builder() 31 | .url(url) 32 | .apply { md5?.let { md5(it) } } 33 | .into(path) 34 | .build() 35 | subscribe(url, object : Download.Subscriber { 36 | override fun onSuccess(call: Download.Call, response: Download.Response) { 37 | super.onSuccess(call, response) 38 | println("UrlSubscriber: onSuccess call = $call, response = $response") 39 | } 40 | 41 | override fun onFailure(call: Download.Call, response: Download.Response) { 42 | super.onFailure(call, response) 43 | println("UrlSubscriber: onFailure call = $call, response = $response") 44 | } 45 | }) 46 | newCall(request).enqueue(object : Download.Callback { 47 | 48 | override fun onLoading(call: Download.Call, current: Long, total: Long) { 49 | super.onLoading(call, current, total) 50 | println("Callback: onLoading call = $call, current = $current, total = $total") 51 | } 52 | 53 | override fun onRetrying(call: Download.Call) { 54 | println("Callback: onRetrying call = $call") 55 | } 56 | 57 | override fun onSuccess(call: Download.Call, response: Download.Response) { 58 | super.onSuccess(call, response) 59 | println("Callback: onSuccess call = $call, response = $response") 60 | } 61 | 62 | override fun onFailure(call: Download.Call, response: Download.Response) { 63 | super.onFailure(call, response) 64 | println("Callback: onFailure call =$call, response = $response") 65 | } 66 | }) 67 | } -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Project information 2 | site_name: 'OkDownloader' 3 | site_description: 'A downloader library base on OkHttp for Java and Android.' 4 | site_author: '2023 OkDownloader Contributors' 5 | site_url: 'https://ydxlt.github.io/okdownloader/' 6 | remote_branch: gh-pages 7 | edit_uri: "" 8 | 9 | # Repository 10 | repo_name: 'OkDownloader' 11 | repo_url: 'https://github.com/ydxlt/okdownloader' 12 | 13 | # Copyright 14 | copyright: 'Copyright © 2023 OkDownloader Contributors' 15 | 16 | # Configuration 17 | theme: 18 | name: 'material' 19 | language: 'en' 20 | favicon: 'images/logo.svg' 21 | logo: 'images/logo.svg' 22 | palette: 23 | primary: 'white' 24 | accent: 'white' 25 | font: 26 | text: 'Roboto' 27 | code: 'Roboto Mono' 28 | 29 | # Customization 30 | extra: 31 | social: 32 | - icon: 'fontawesome/brands/github' 33 | link: 'https://github.com/ydxlt/okdownloader' 34 | 35 | # Navigation 36 | nav: 37 | - 'Overview': index.md 38 | - 'Getting Started': getting_started.md 39 | - 'In Android': in_android.md 40 | - 'Optional Settings': optional_settings.md 41 | - 'How to Expand': downloader_pipeline.md 42 | - 'Best Practices': best_practices.md 43 | - 'FAQ': faq.md 44 | #- 'API ⏏': api/index.html 45 | - 'Change Logs': change_logs.md 46 | - 'Contributing': contributing.md 47 | 48 | # CSS 49 | extra_css: 50 | - 'css/site.css' 51 | 52 | # Extensions 53 | markdown_extensions: 54 | - admonition 55 | - footnotes 56 | - toc: 57 | permalink: true 58 | - pymdownx.highlight: 59 | anchor_linenums: true 60 | - pymdownx.inlinehilite 61 | - pymdownx.snippets 62 | - pymdownx.superfences 63 | 64 | # Plugins 65 | plugins: 66 | - search 67 | - minify: 68 | minify_html: true 69 | 70 | # Built with https://github.com/squidfunk/mkdocs-material -------------------------------------------------------------------------------- /okdownloader-android/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /okdownloader-android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("org.jetbrains.kotlin.android") 4 | id("com.vanniktech.maven.publish") 5 | id("kotlin-kapt") 6 | } 7 | 8 | android { 9 | namespace = "com.billbook.lib.downloader" 10 | compileSdk = 33 11 | 12 | defaultConfig { 13 | minSdk = 21 14 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 15 | consumerProguardFiles("consumer-rules.pro") 16 | } 17 | 18 | buildTypes { 19 | release { 20 | isMinifyEnabled = false 21 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 22 | } 23 | } 24 | } 25 | 26 | dependencies { 27 | testImplementation(libs.test.junit) 28 | androidTestImplementation(libs.android.test.junit.ext) 29 | androidTestImplementation(libs.android.test.espresso) 30 | implementation(libs.commons.codec) 31 | implementation(libs.room.runtime) 32 | kapt(libs.room.compiler) 33 | api(project(path = ":okdownloader")) 34 | } -------------------------------------------------------------------------------- /okdownloader-android/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/okdownloader-android/consumer-rules.pro -------------------------------------------------------------------------------- /okdownloader-android/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.kts. 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 -------------------------------------------------------------------------------- /okdownloader-android/src/androidTest/java/com/billbook/lib/downloader/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 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.billbook.lib.downloader.test", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /okdownloader-android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /okdownloader-android/src/main/java/com/billbook/lib/downloader/CopyOnExistsInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import android.content.Context 4 | import com.billbook.lib.downloader.internal.DownloadDatabase 5 | import com.billbook.lib.downloader.internal.RecordEntity 6 | import org.apache.commons.codec.digest.DigestUtils 7 | import java.io.File 8 | 9 | /** 10 | * @author xluotong@gmail.com 11 | */ 12 | class CopyOnExistsInterceptor( 13 | private val context: Context, 14 | private val recordCount: Int = 5000 15 | ) : Interceptor { 16 | 17 | override fun intercept(chain: Interceptor.Chain): Download.Response { 18 | val request = chain.request() 19 | val record = try { 20 | DownloadDatabase.getInstance(context).recordDao().queryByUrl(request.url) 21 | } catch (t: Throwable) { 22 | // ignore 23 | null 24 | } 25 | if (record != null && record.path != request.path) { 26 | val file = File(record.path) 27 | if (file.exists() && file.md5() == record.md5 && !request.destFile().exists()) { 28 | file.copyTo(request.destFile(), true) 29 | return Download.Response.Builder() 30 | .code(ErrorCode.COPY_SUCCESS) 31 | .output(request.destFile()) 32 | .totalSize(request.destFile().length()) 33 | .message("Copy file success") 34 | .build() 35 | } 36 | } 37 | val response = chain.proceed(request) 38 | if (response.isSuccessful() && request.md5 != null && request.destFile().exists()) { 39 | try { 40 | with(DownloadDatabase.getInstance(context).recordDao()) { 41 | insert(request.record()) 42 | deleteLessThen(getMaxId() - recordCount) 43 | } 44 | } catch (t: Throwable) { 45 | // ignore 46 | } 47 | } 48 | return response 49 | } 50 | 51 | private fun Download.Request.record(): RecordEntity { 52 | return RecordEntity(url = url, path = path, md5 = md5!!) 53 | } 54 | } 55 | 56 | internal fun File.md5(): String { 57 | return DigestUtils.md5Hex(this.readBytes()) 58 | } -------------------------------------------------------------------------------- /okdownloader-android/src/main/java/com/billbook/lib/downloader/DownloadRequest.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import com.billbook.lib.downloader.internal.util.requireNotNullOrEmpty 4 | import java.io.File 5 | 6 | /** 7 | * @author xluotong@gmail.com 8 | */ 9 | open class DownloadRequest protected constructor( 10 | url: String, 11 | path: String, 12 | md5: String?, 13 | tag: String?, 14 | size: Long?, 15 | retry: Int?, 16 | callbackExecutor: CallbackExecutor, 17 | priority: Download.Priority, 18 | @get:JvmName("network") internal val network: Int, 19 | ) : Download.Request(url, path, md5, tag, size, retry, callbackExecutor, priority) { 20 | 21 | override fun newBuilder(): Download.Request.Builder { 22 | return Builder(this) 23 | } 24 | 25 | override fun toString(): String { 26 | return "DownloadRequest(network=$network) ${super.toString()}" 27 | } 28 | 29 | open class Builder : Download.Request.Builder { 30 | 31 | protected var network: Int = NETWORK_WIFI or NETWORK_DATA 32 | 33 | constructor() 34 | 35 | constructor(request: DownloadRequest) : super(request) { 36 | this.network = request.network 37 | } 38 | 39 | override fun url(url: String): Builder = apply { 40 | this.url = url 41 | } 42 | 43 | override fun into(path: String): Builder = apply { 44 | this.path = path 45 | } 46 | 47 | override fun md5(md5: String): Builder = apply { 48 | this.md5 = md5 49 | } 50 | 51 | open fun networkOn(network: Int): Builder = apply { 52 | this.network = network 53 | } 54 | 55 | override fun tag(tag: String): Builder = apply { 56 | this.tag = tag 57 | } 58 | 59 | override fun size(size: Long): Builder = apply { 60 | this.size = size 61 | } 62 | 63 | override fun retry(retry: Int): Builder = apply { 64 | this.retry = retry 65 | } 66 | 67 | override fun priority(priority: Download.Priority): Builder = apply{ 68 | this.priority = priority 69 | } 70 | 71 | override fun callbackOn(executor: CallbackExecutor): Builder = apply { 72 | this.callbackExecutor = executor 73 | } 74 | 75 | override fun build(): DownloadRequest { 76 | return DownloadRequest( 77 | url = requireNotNullOrEmpty(url) { "Missing url!" }, 78 | path = requireNotNullOrEmpty(path) { "Missing path!" }, 79 | md5 = md5, 80 | size = size, 81 | retry = retry, 82 | callbackExecutor = callbackExecutor, 83 | tag = tag, 84 | priority = priority, 85 | network = network 86 | ) 87 | } 88 | } 89 | 90 | companion object { 91 | const val NETWORK_WIFI = 0x00000001 92 | const val NETWORK_DATA = 0x00000002 93 | } 94 | } -------------------------------------------------------------------------------- /okdownloader-android/src/main/java/com/billbook/lib/downloader/NetworkInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import android.content.Context 4 | 5 | /** 6 | * @author xluotong@gmail.com 7 | */ 8 | class NetworkInterceptor(private val context: Context) : Interceptor { 9 | override fun intercept(chain: Interceptor.Chain): Download.Response { 10 | val request = chain.request() 11 | if (!NetworkMonitor.getInstance(context).isNetworkAvailable()) { 12 | throw DownloadException(ErrorCode.NET_DISCONNECT, "Network not available") 13 | } 14 | if (request is DownloadRequest) { 15 | when { 16 | (request.network and DownloadRequest.NETWORK_DATA) != DownloadRequest.NETWORK_DATA 17 | && NetworkMonitor.getInstance(context).isMobileConnected() -> { 18 | throw DownloadException( 19 | ErrorCode.NETWORK_NOT_ALLOWED, 20 | "Expect network ${request.network}, but active network is ${ 21 | NetworkMonitor.getInstance(context).getActiveNetworkType() 22 | }" 23 | ) 24 | } 25 | (request.network and DownloadRequest.NETWORK_WIFI) != DownloadRequest.NETWORK_WIFI 26 | && NetworkMonitor.getInstance(context).isWifiConnected() -> { 27 | throw DownloadException( 28 | ErrorCode.NETWORK_NOT_ALLOWED, 29 | "Expect network ${request.network}, but active network is ${ 30 | NetworkMonitor.getInstance(context).getActiveNetworkType() 31 | }" 32 | ) 33 | } 34 | } 35 | } 36 | return chain.proceed(chain.request()) 37 | } 38 | } -------------------------------------------------------------------------------- /okdownloader-android/src/main/java/com/billbook/lib/downloader/NetworkMonitor.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.content.IntentFilter 8 | import android.net.* 9 | import android.os.Build 10 | import android.os.Handler 11 | import android.os.Looper 12 | import androidx.annotation.RequiresApi 13 | import java.util.concurrent.atomic.AtomicBoolean 14 | import java.util.concurrent.atomic.AtomicInteger 15 | 16 | /** 17 | * @author xluotong@gmail.com 18 | */ 19 | @SuppressLint("MissingPermission") 20 | class NetworkMonitor private constructor( 21 | private val mContext: Context 22 | ) : BaseObservable() { 23 | 24 | private val mHandler = Handler(Looper.getMainLooper()) 25 | 26 | companion object { 27 | const val NETWORK_NONE = -1 28 | const val NETWORK_WIFI = 1 29 | const val NETWORK_MOBILE = 2 30 | const val NETWORK_OTHER = 3 31 | const val NETWORK_ETHERNET = 4 32 | 33 | @Volatile 34 | private var instance: NetworkMonitor? = null 35 | 36 | fun getInstance(context: Context) = instance ?: synchronized(this) { 37 | instance ?: NetworkMonitor(context.applicationContext).also { instance = it } 38 | } 39 | } 40 | 41 | private var mRunning: AtomicBoolean = AtomicBoolean(false) 42 | private val mConnectivityManager by lazy { mContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager } 43 | private var mCurrentNetworkType: AtomicInteger = AtomicInteger(NETWORK_NONE) 44 | 45 | @RequiresApi(Build.VERSION_CODES.LOLLIPOP) 46 | private val mNetworkCallback = object : ConnectivityManager.NetworkCallback() { 47 | 48 | override fun onAvailable(network: Network) { 49 | super.onAvailable(network) 50 | onNetworkChanged() 51 | } 52 | 53 | override fun onUnavailable() { 54 | super.onUnavailable() 55 | onNetworkChanged() 56 | } 57 | 58 | override fun onLost(network: Network) { 59 | super.onLost(network) 60 | onNetworkChanged() 61 | } 62 | 63 | override fun onCapabilitiesChanged( 64 | network: Network, 65 | networkCapabilities: NetworkCapabilities 66 | ) { 67 | super.onCapabilitiesChanged(network, networkCapabilities) 68 | onNetworkChanged() 69 | } 70 | } 71 | 72 | private val mBroadcastReceiver = object : BroadcastReceiver() { 73 | override fun onReceive(context: Context?, intent: Intent?) { 74 | onNetworkChanged() 75 | } 76 | } 77 | 78 | private val mNetworkChangedRunnable = Runnable { 79 | val previous = mCurrentNetworkType.getAndSet(getActiveNetworkType()) 80 | notifyObservers( 81 | NetworkRecord( 82 | NetworkRecord.Item(previous, previous != NETWORK_NONE), 83 | NetworkRecord.Item( 84 | mCurrentNetworkType.get(), 85 | mCurrentNetworkType.get() != NETWORK_NONE 86 | ), 87 | ) 88 | ) 89 | } 90 | 91 | private fun onNetworkChanged() { 92 | mHandler.removeCallbacks(mNetworkChangedRunnable) 93 | mHandler.postDelayed(mNetworkChangedRunnable,1000) 94 | } 95 | 96 | fun getActiveNetworkType(): Int { 97 | val networkInfo = getActiveNetworkInfo() ?: return NETWORK_NONE 98 | if (!networkInfo.isConnected) return NETWORK_NONE 99 | return when (networkInfo.type) { 100 | ConnectivityManager.TYPE_WIFI -> NETWORK_WIFI 101 | ConnectivityManager.TYPE_MOBILE -> NETWORK_MOBILE 102 | ConnectivityManager.TYPE_ETHERNET -> NETWORK_ETHERNET 103 | else -> NETWORK_OTHER 104 | } 105 | } 106 | 107 | private fun getActiveNetworkInfo(): NetworkInfo? { 108 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 109 | mConnectivityManager.activeNetwork?.let { 110 | return mConnectivityManager.getNetworkInfo(it) 111 | } 112 | } 113 | return mConnectivityManager.activeNetworkInfo 114 | } 115 | 116 | fun startup() { 117 | if (mRunning.getAndSet(true)) return 118 | mCurrentNetworkType.getAndSet(getActiveNetworkType()) 119 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 120 | mConnectivityManager.registerNetworkCallback( 121 | NetworkRequest.Builder().build(), 122 | mNetworkCallback 123 | ) 124 | } else { 125 | // use broadcast 126 | mContext.registerReceiver( 127 | mBroadcastReceiver, 128 | IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) 129 | ) 130 | } 131 | } 132 | 133 | fun isWifiConnected(): Boolean { 134 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 135 | mConnectivityManager.activeNetwork?.let { 136 | val networkInfo = mConnectivityManager.getNetworkInfo(it) 137 | if (networkInfo != null && networkInfo.type == ConnectivityManager.TYPE_WIFI) { 138 | return networkInfo.isConnected 139 | } 140 | return false 141 | } 142 | } 143 | return mConnectivityManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI)?.isConnected 144 | ?: false 145 | } 146 | 147 | fun isMobileConnected(): Boolean { 148 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 149 | mConnectivityManager.activeNetwork?.let { 150 | val networkInfo = mConnectivityManager.getNetworkInfo(it) 151 | if (networkInfo != null && networkInfo.type == ConnectivityManager.TYPE_MOBILE) { 152 | return networkInfo.isConnected 153 | } 154 | return false 155 | } 156 | } 157 | return mConnectivityManager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE)?.isConnected 158 | ?: false 159 | } 160 | 161 | fun isNetworkAvailable(): Boolean = getActiveNetworkType() != NETWORK_NONE 162 | 163 | fun shutdown() { 164 | if (!mRunning.getAndSet(false)) return 165 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 166 | mConnectivityManager.unregisterNetworkCallback(mNetworkCallback) 167 | } else { 168 | mContext.unregisterReceiver(mBroadcastReceiver) 169 | } 170 | } 171 | 172 | data class NetworkRecord(val previous: Item, val current: Item) { 173 | data class Item( 174 | val type: Int, 175 | val available: Boolean 176 | ) 177 | } 178 | } -------------------------------------------------------------------------------- /okdownloader-android/src/main/java/com/billbook/lib/downloader/Observables.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import java.util.concurrent.CopyOnWriteArraySet 4 | 5 | /** 6 | * @author xluotong@gmail.com 7 | */ 8 | fun interface Observer { 9 | 10 | fun onChanged(data: T) 11 | } 12 | 13 | interface Observable { 14 | 15 | fun notifyObservers(data: T) 16 | 17 | fun notifyObservers() {} 18 | 19 | fun observe(observer: Observer) 20 | 21 | fun unObserve(observer: Observer) 22 | } 23 | 24 | abstract class BaseObservable : Observable { 25 | 26 | private val mObservers = CopyOnWriteArraySet>() 27 | 28 | override fun notifyObservers(data: T) { 29 | mObservers.forEach { it.onChanged(data) } 30 | } 31 | 32 | override fun observe(observer: Observer) { 33 | mObservers.add(observer) 34 | } 35 | 36 | override fun unObserve(observer: Observer) { 37 | mObservers.remove(observer) 38 | } 39 | } -------------------------------------------------------------------------------- /okdownloader-android/src/main/java/com/billbook/lib/downloader/StorageInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import android.os.Environment 4 | import android.os.StatFs 5 | 6 | /** 7 | * @author xluotong@gmail.com 8 | */ 9 | class StorageInterceptor( 10 | private val availableThreshold: Long = DEFAULT_MIN_AVAILABLE_SIZE 11 | ) : Interceptor { 12 | 13 | override fun intercept(chain: Interceptor.Chain): Download.Response { 14 | if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()) { 15 | val statFs = StatFs(Environment.getDataDirectory().path) 16 | val availableSpace = statFs.blockSizeLong * statFs.availableBlocksLong 17 | if (availableSpace <= availableThreshold) { 18 | throw DownloadException(ErrorCode.IO_STORAGE_FULL) 19 | } 20 | } 21 | return chain.proceed(chain.request()) 22 | } 23 | 24 | companion object { 25 | private const val DEFAULT_MIN_AVAILABLE_SIZE = 20L * 1024 * 1024 // 20MB 26 | } 27 | } -------------------------------------------------------------------------------- /okdownloader-android/src/main/java/com/billbook/lib/downloader/internal/Database.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | 8 | private const val VERSION_1 = 1 9 | 10 | /** 11 | * @author xluotong@gmail.com 12 | */ 13 | @Database(version = VERSION_1, entities = [RecordEntity::class]) 14 | internal abstract class DownloadDatabase : RoomDatabase() { 15 | 16 | abstract fun recordDao(): RecordDao 17 | 18 | companion object { 19 | @Volatile 20 | private var INSTANCE: DownloadDatabase? = null 21 | 22 | fun getInstance(context: Context) = INSTANCE ?: synchronized(this) { 23 | INSTANCE ?: Room.databaseBuilder(context, DownloadDatabase::class.java, "ok_downloader.db") 24 | .build().also { INSTANCE = it } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /okdownloader-android/src/main/java/com/billbook/lib/downloader/internal/Record.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal 2 | 3 | import androidx.room.* 4 | 5 | /** 6 | * @author xluotong@gmail.com 7 | */ 8 | @Entity(tableName = "download_record") 9 | internal data class RecordEntity( 10 | @PrimaryKey(autoGenerate = true) 11 | @ColumnInfo(name = "_id") 12 | val id: Long = 0, 13 | @ColumnInfo(name = "_url") 14 | val url: String, 15 | @ColumnInfo(name = "_path") 16 | val path: String, 17 | @ColumnInfo(name = "_md5") 18 | val md5: String, 19 | @ColumnInfo(name = "_flag") 20 | val flag: Int = 0 21 | ) 22 | 23 | @Dao 24 | internal interface RecordDao { 25 | 26 | @Query("select * from download_record where _url = :url") 27 | fun queryByUrl(url: String): RecordEntity? 28 | 29 | @Insert 30 | fun insert(entity: RecordEntity): Long 31 | 32 | @Query("delete from download_record where _id <= :id") 33 | fun deleteLessThen(id: Long): Int 34 | 35 | @Query("select max(_id) from download_record") 36 | fun getMaxId(): Long 37 | } 38 | -------------------------------------------------------------------------------- /okdownloader-android/src/test/java/com/billbook/lib/downloader/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 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 | } -------------------------------------------------------------------------------- /okdownloader/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /okdownloader/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("java-library") 3 | id("org.jetbrains.kotlin.jvm") 4 | id("com.vanniktech.maven.publish") 5 | } 6 | 7 | dependencies { 8 | testImplementation(project(path = ":fakedata")) 9 | testImplementation(libs.test.junit) 10 | api(libs.okhttp) 11 | implementation(libs.commons.codec) 12 | implementation(libs.okio) 13 | } -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/Download.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import com.billbook.lib.downloader.internal.util.requireNotNullOrEmpty 4 | import java.io.File 5 | 6 | interface Download { 7 | 8 | open class Request protected constructor( 9 | @get:JvmName("url") val url: String, 10 | @get:JvmName("path") val path: String, 11 | @get:JvmName("md5") val md5: String?, 12 | @get:JvmName("tag") val tag: String?, 13 | @get:JvmName("size") val size: Long?, 14 | @get:JvmName("retry") val retry: Int?, 15 | @get:JvmName("priority") val priority: Priority, 16 | ) { 17 | 18 | open fun newBuilder(): Builder = Builder(this) 19 | 20 | open fun sourceFile(): File = File("$path.tmp") 21 | 22 | open fun destFile(): File = File(path) 23 | 24 | override fun toString(): String { 25 | return "Request(url='$url', path='$path', md5=$md5, tag=$tag, size=$size, retry=$retry, priority=$priority)" 26 | } 27 | 28 | open class Builder { 29 | protected var url: String? = null 30 | protected var path: String? = null 31 | protected var md5: String? = null 32 | protected var tag: String? = null 33 | protected var size: Long? = null 34 | protected var retry: Int? = null 35 | protected var priority: Priority = Priority.MIDDLE 36 | 37 | constructor() 38 | 39 | constructor(request: Request) { 40 | this.url = request.url 41 | this.path = request.path 42 | this.md5 = request.md5 43 | this.tag = request.tag 44 | this.size = request.size 45 | this.priority = request.priority 46 | } 47 | 48 | open fun url(url: String): Builder = apply { 49 | this.url = url 50 | } 51 | 52 | open fun md5(md5: String): Builder = apply { 53 | this.md5 = md5 54 | } 55 | 56 | open fun tag(tag: String): Builder = apply { 57 | this.tag = tag 58 | } 59 | 60 | open fun size(size: Long): Builder = apply { 61 | this.size = size 62 | } 63 | 64 | open fun into(path: String): Builder = apply { 65 | this.path = path 66 | } 67 | 68 | open fun into(file: File): Builder = apply { 69 | this.path = file.absolutePath 70 | } 71 | 72 | open fun retry(retry: Int): Builder = apply { 73 | this.retry = retry 74 | } 75 | 76 | open fun priority(priority: Priority): Builder = apply { 77 | this.priority = priority 78 | } 79 | 80 | open fun build(): Request { 81 | return Request( 82 | url = requireNotNullOrEmpty(url) { "Missing url!" }, 83 | path = requireNotNullOrEmpty(path) { "Missing path!" }, 84 | md5 = md5, 85 | size = size, 86 | retry = retry, 87 | tag = tag, 88 | priority = priority 89 | ) 90 | } 91 | } 92 | } 93 | 94 | class Response internal constructor( 95 | @get:JvmName("code") val code: Int, 96 | @get:JvmName("message") val message: String?, 97 | @get:JvmName("output") val output: File?, 98 | @get:JvmName("retryCount") val retryCount: Int, 99 | @get:JvmName("downloadLength") val downloadLength: Long, 100 | @get:JvmName("totalSize") val totalSize: Long, 101 | ) { 102 | fun isSuccessful(): Boolean = this.code in ErrorCode.SUCCESS..ErrorCode.EXISTS_SUCCESS 103 | 104 | fun newBuilder(): Builder = Builder(this) 105 | 106 | fun isBreakpoint(): Boolean = this.code == ErrorCode.APPEND_SUCCESS 107 | 108 | override fun toString(): String { 109 | return "Response(code=$code, message=$message, output=$output, retryCount=$retryCount, downloadLength=$downloadLength, totalSize=$totalSize)" 110 | } 111 | 112 | class Builder { 113 | private var code: Int = ErrorCode.SUCCESS 114 | private var message: String? = null 115 | private var output: File? = null 116 | private var downloadLength: Long = 0L 117 | private var retryCount: Int = 0 118 | private var totalSize: Long = 0 119 | 120 | constructor() 121 | 122 | internal constructor(response: Response) { 123 | this.code = response.code 124 | this.message = response.message 125 | this.output = response.output 126 | this.downloadLength = response.downloadLength 127 | this.retryCount = response.retryCount 128 | this.totalSize = response.totalSize 129 | } 130 | 131 | fun code(code: Int): Builder = apply { 132 | this.code = code 133 | } 134 | 135 | fun message(message: String): Builder = apply { 136 | this.message = message 137 | } 138 | 139 | fun output(output: File): Builder = apply { 140 | this.output = output 141 | } 142 | 143 | fun downloadLength(downloadLength: Long): Builder = apply { 144 | this.downloadLength = downloadLength 145 | } 146 | 147 | fun retryCount(retryCount: Int): Builder = apply { 148 | this.retryCount = retryCount 149 | } 150 | 151 | fun totalSize(totalSize: Long): Builder = apply { 152 | this.totalSize = totalSize 153 | } 154 | 155 | fun build(): Response { 156 | return Response( 157 | code = this.code, 158 | message = this.message, 159 | output = this.output, 160 | downloadLength = this.downloadLength, 161 | retryCount = this.retryCount, 162 | totalSize = this.totalSize, 163 | ) 164 | } 165 | } 166 | } 167 | 168 | interface Call { 169 | val request: Request 170 | 171 | fun execute(): Response 172 | 173 | fun execute(callback: Callback): Response 174 | 175 | fun enqueue() 176 | 177 | fun enqueue(callback: Callback) 178 | 179 | fun cancel() 180 | 181 | fun cancelSafely() 182 | 183 | fun isExecuted(): Boolean 184 | 185 | fun isCanceled(): Boolean 186 | 187 | fun interface Factory { 188 | fun newCall(request: Request): Call 189 | } 190 | } 191 | 192 | interface Callback { 193 | fun onStart(call: Call) {} 194 | fun onLoading(call: Call, current: Long, total: Long) {} 195 | fun onCancel(call: Call) {} 196 | fun onChecking(call: Call) {} 197 | fun onRetrying(call: Call) {} 198 | fun onSuccess(call: Call, response: Response) {} 199 | fun onFailure(call: Call, response: Response) {} 200 | 201 | companion object { 202 | @JvmField 203 | val NOOP: Callback = object : Callback {} 204 | } 205 | } 206 | 207 | enum class Priority { 208 | LOW_LOW, LOW, MIDDLE, HIGH, HIGH_HIGH 209 | } 210 | 211 | interface Subjection { 212 | fun subscribe(subscriber: Subscriber) 213 | fun unsubscribe(subscriber: Subscriber) 214 | fun subscribe(url: String, subscriber: Subscriber) 215 | fun unsubscribe(url: String, subscriber: Subscriber) 216 | } 217 | 218 | interface Subscriber { 219 | fun onSuccess(call: Call, response: Response) {} 220 | fun onFailure(call: Call, response: Response) {} 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/DownloadException.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | open class DownloadException(val code: Int, message: String? = null, cause: Throwable? = null) : 4 | IllegalStateException(message, cause) { 5 | 6 | override fun toString(): String { 7 | return "DownloadException(code=$code, message = $message, cause = $cause)" 8 | } 9 | } -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/DownloadPool.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import com.billbook.lib.downloader.internal.core.DefaultDownloadCall 4 | import com.billbook.lib.downloader.internal.util.CPU_COUNT 5 | import java.util.* 6 | import java.util.concurrent.ExecutorService 7 | import java.util.concurrent.LinkedBlockingQueue 8 | import java.util.concurrent.ThreadPoolExecutor 9 | import java.util.concurrent.TimeUnit 10 | 11 | class DownloadPool( 12 | private val executorService: ExecutorService = ThreadPoolExecutor( 13 | CPU_COUNT, CPU_COUNT * 2, 5, TimeUnit.SECONDS, 14 | LinkedBlockingQueue() 15 | ), 16 | private val idleCallback: Runnable? = null 17 | ) { 18 | 19 | private val readyAsyncCalls = PriorityQueue() 20 | private val runningAsyncCalls = ArrayDeque() 21 | private val runningSyncCalls = ArrayDeque() 22 | 23 | @get:Synchronized 24 | var maxRequests = 64 25 | set(maxRequests) { 26 | require(maxRequests >= 1) { "max < 1: $maxRequests" } 27 | synchronized(this) { 28 | field = maxRequests 29 | } 30 | promoteAndExecute() 31 | } 32 | 33 | internal fun copy() = DownloadPool(this.executorService, null) 34 | 35 | @Synchronized 36 | internal fun executed(call: Download.Call, eventListener: EventListener) { 37 | if (findHitCall(call) != null) { 38 | eventListener.callHit(call) 39 | } 40 | runningSyncCalls.add(call) 41 | } 42 | 43 | internal fun enqueue(call: DefaultDownloadCall.AsyncCall, eventListener: EventListener) { 44 | if (findHitCall(call.call) != null) { 45 | eventListener.callHit(call.call) 46 | } 47 | synchronized(this) { 48 | readyAsyncCalls.add(call) 49 | } 50 | promoteAndExecute() 51 | } 52 | 53 | private fun findHitCall(call: Download.Call): Download.Call? { 54 | synchronized(this) { 55 | val hitCall = readyAsyncCalls.find { it.call.request.url == call.request.url }?.call 56 | ?: runningSyncCalls.find { it.request.url == call.request.url } 57 | ?: runningAsyncCalls.find { it.call.request.url == call.request.url }?.call 58 | if (hitCall != null && hitCall != call) { 59 | return hitCall 60 | } 61 | } 62 | return null 63 | } 64 | 65 | private fun promoteAndExecute(): Boolean { 66 | val executableCalls = mutableListOf() 67 | val isRunning: Boolean 68 | synchronized(this) { 69 | while (!readyAsyncCalls.isEmpty()) { 70 | if (runningAsyncCalls.size >= this.maxRequests) break // Max capacity. 71 | val asyncCall = readyAsyncCalls.poll() 72 | executableCalls.add(asyncCall) 73 | runningAsyncCalls.add(asyncCall) 74 | } 75 | isRunning = runningCallsCount() > 0 76 | } 77 | for (i in 0 until executableCalls.size) { 78 | val asyncCall = executableCalls[i] 79 | asyncCall.executeOn(executorService) 80 | } 81 | 82 | return isRunning 83 | } 84 | 85 | @Synchronized 86 | fun queuedCalls(): List { 87 | return Collections.unmodifiableList(readyAsyncCalls.map { it.call }) 88 | } 89 | 90 | /** Returns a snapshot of the calls currently being executed. */ 91 | @Synchronized 92 | fun runningCalls(): List { 93 | return Collections.unmodifiableList(runningSyncCalls + runningAsyncCalls.map { it.call }) 94 | } 95 | 96 | @Synchronized 97 | fun queuedCallsCount(): Int = readyAsyncCalls.size 98 | 99 | @Synchronized 100 | fun runningCallsCount(): Int = runningAsyncCalls.size + runningSyncCalls.size 101 | 102 | internal fun finished(call: Download.Call) { 103 | finished(runningSyncCalls, call) 104 | } 105 | 106 | internal fun finished(call: DefaultDownloadCall.AsyncCall) { 107 | finished(runningAsyncCalls, call) 108 | } 109 | 110 | @Synchronized 111 | internal fun cancelAll() { 112 | runningAsyncCalls.forEach { it.call.cancel() } 113 | runningSyncCalls.forEach { it.cancel() } 114 | } 115 | 116 | @Synchronized 117 | internal fun cancelAllSafely() { 118 | runningAsyncCalls.forEach { it.call.cancelSafely() } 119 | runningSyncCalls.forEach { it.cancelSafely() } 120 | } 121 | 122 | private fun finished(calls: Deque, call: T) { 123 | val idleCallback: Runnable? 124 | synchronized(this) { 125 | if (!calls.remove(call)) throw AssertionError("Call wasn't in-flight!") 126 | idleCallback = this.idleCallback 127 | } 128 | 129 | val isRunning = promoteAndExecute() 130 | if (!isRunning && idleCallback != null) { 131 | idleCallback.run() 132 | } 133 | } 134 | } -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/Downloader.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import com.billbook.lib.downloader.internal.core.DefaultDownloadCall 4 | import com.billbook.lib.downloader.internal.core.Dispatcher 5 | import com.billbook.lib.downloader.internal.util.DefaultOkhttpClient 6 | import com.billbook.lib.downloader.internal.util.asFactory 7 | import okhttp3.OkHttpClient 8 | import okhttp3.internal.toImmutableList 9 | import java.util.* 10 | 11 | class Downloader internal constructor( 12 | builder: Builder 13 | ) : Download.Call.Factory, Download.Subjection { 14 | 15 | @get:JvmName("eventListenerFactory") 16 | val eventListenerFactory: EventListener.Factory = builder.eventListenerFactory 17 | 18 | @get:JvmName("defaultMaxRetry") 19 | val defaultMaxRetry: Int = builder.defaultMaxRetry 20 | 21 | @get:JvmName("okHttpClientFactory") 22 | val okHttpClientFactory: Factory = builder.okHttpClientFactory 23 | 24 | @get:JvmName("downloadPool") 25 | val downloadPool: DownloadPool = builder.downloadPool ?: DownloadPool() 26 | 27 | @get:JvmName("interceptors") 28 | val interceptors: List = builder.interceptors.toImmutableList() 29 | 30 | internal val okhttpClient by lazy { builder.okHttpClientFactory.create() } 31 | 32 | private val dispatcher: Dispatcher = Dispatcher() 33 | 34 | fun newBuilder(): Builder { 35 | return Builder(this) 36 | } 37 | 38 | override fun newCall(request: Download.Request): Download.Call { 39 | return CallWrapper(DefaultDownloadCall(this, request), dispatcher) 40 | } 41 | 42 | override fun subscribe(subscriber: Download.Subscriber) { 43 | dispatcher.subscribe(subscriber) 44 | } 45 | 46 | override fun subscribe(url: String, subscriber: Download.Subscriber) { 47 | dispatcher.subscribe(url, subscriber) 48 | } 49 | 50 | override fun unsubscribe(subscriber: Download.Subscriber) { 51 | dispatcher.unsubscribe(subscriber) 52 | } 53 | 54 | override fun unsubscribe(url: String, subscriber: Download.Subscriber) { 55 | dispatcher.unsubscribe(url, subscriber) 56 | } 57 | 58 | fun cancelAll() { 59 | downloadPool.cancelAll() 60 | } 61 | 62 | fun cancelAllSafely() { 63 | downloadPool.cancelAllSafely() 64 | } 65 | 66 | class Builder { 67 | internal var eventListenerFactory: EventListener.Factory = EventListener.NONE.asFactory() 68 | internal var defaultMaxRetry: Int = 3 69 | internal var okHttpClientFactory: Factory = DefaultOkhttpClient.asFactory() 70 | internal var downloadPool: DownloadPool? = null 71 | internal var interceptors: MutableList = mutableListOf() 72 | 73 | constructor() 74 | 75 | internal constructor(downloader: Downloader) { 76 | this.eventListenerFactory = downloader.eventListenerFactory 77 | this.defaultMaxRetry = downloader.defaultMaxRetry 78 | this.okHttpClientFactory = downloader.okHttpClientFactory 79 | this.downloadPool = downloader.downloadPool.copy() 80 | this.interceptors = downloader.interceptors.toMutableList() 81 | } 82 | 83 | fun eventListenerFactory(factory: EventListener.Factory): Builder = apply { 84 | this.eventListenerFactory = factory 85 | } 86 | 87 | fun defaultMaxRetry(retry: Int): Builder = apply { 88 | this.defaultMaxRetry = retry 89 | } 90 | 91 | fun okHttpClientFactory(factory: Factory): Builder = apply { 92 | this.okHttpClientFactory = factory 93 | } 94 | 95 | fun downloadPool(downloadPool: DownloadPool): Builder = apply { 96 | this.downloadPool = downloadPool 97 | } 98 | 99 | fun addInterceptor(interceptor: Interceptor): Builder = apply { 100 | this.interceptors += interceptor 101 | } 102 | 103 | fun build(): Downloader { 104 | return Downloader(this) 105 | } 106 | } 107 | 108 | fun interface Factory { 109 | fun create(): T 110 | } 111 | 112 | private class CallWrapper( 113 | private val call: Download.Call, 114 | private val subscriber: Download.Subscriber 115 | ) : Download.Call by call { 116 | 117 | override fun execute(callback: Download.Callback): Download.Response { 118 | return call.execute(CallbackWrapper(callback, subscriber)) 119 | } 120 | 121 | override fun enqueue(callback: Download.Callback) { 122 | call.enqueue(CallbackWrapper(callback, subscriber)) 123 | } 124 | } 125 | 126 | private class CallbackWrapper( 127 | private val callback: Download.Callback, 128 | private val subscriber: Download.Subscriber, 129 | ) : Download.Callback by callback { 130 | 131 | override fun onSuccess(call: Download.Call, response: Download.Response) { 132 | callback.onSuccess(call, response) 133 | subscriber.onSuccess(call, response) 134 | } 135 | 136 | override fun onFailure(call: Download.Call, response: Download.Response) { 137 | callback.onFailure(call, response) 138 | subscriber.onFailure(call, response) 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/ErrorCode.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | interface ErrorCode { 4 | companion object { 5 | const val SUCCESS: Int = 100 6 | const val APPEND_SUCCESS: Int = 101 7 | const val COPY_SUCCESS: Int = 102 8 | const val EXISTS_SUCCESS: Int = 103 9 | const val IO_CREATE_FILE_ERROR: Int = 201 10 | const val IO_CREATE_DIRECTORY_ERROR: Int = 202 11 | const val IO_STORAGE_FULL: Int = 203 12 | const val IO_INTERRUPTED: Int = 204 13 | const val IO_EXCEPTION: Int = 205 14 | const val NET_DISCONNECT: Int = 301 15 | const val NET_STREAM_RESET: Int = 302 16 | const val REMOTE_CONNECT_ERROR: Int = 401 17 | const val REMOTE_CONTENT_EMPTY: Int = 402 18 | const val VERIFY_FILE_NOT_EXISTS: Int = 501 19 | const val VERIFY_FILE_NOT_FILE: Int = 502 20 | const val VERIFY_MD5_NOT_MATCHED: Int = 503 21 | const val VERIFY_SIZE_NOT_MATCHED: Int = 504 22 | const val CANCEL: Int = 601 23 | const val PAUSE: Int = 602 24 | const val INTERRUPTED: Int = 603 25 | const val FILE_NOT_FOUND: Int = 606 26 | const val NETWORK_NOT_ALLOWED: Int = 701 27 | const val ARGUMENT_EXCEPTION: Int = 702 28 | const val MALFORMED_URL: Int = 703 29 | const val UNKNOWN: Int = -1 30 | } 31 | } -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/EventListener.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | abstract class EventListener { 4 | 5 | open fun callHit(call: Download.Call) {} 6 | 7 | open fun callStart(call: Download.Call) { 8 | 9 | } 10 | 11 | open fun callCanceled(call: Download.Call) { 12 | 13 | } 14 | 15 | open fun callSuccess(call: Download.Call, response: Download.Response) { 16 | 17 | } 18 | 19 | open fun callFailed(call: Download.Call, response: Download.Response) { 20 | 21 | } 22 | 23 | open fun callEnd(call: Download.Call) { 24 | 25 | } 26 | 27 | fun interface Factory { 28 | fun create(call: Download.Call): EventListener 29 | } 30 | 31 | companion object { 32 | @JvmField 33 | val NONE: EventListener = object : EventListener() { 34 | } 35 | } 36 | } 37 | 38 | internal fun EventListener.asFactory() = EventListener.Factory { this } 39 | -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/Interceptor.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import kotlin.jvm.Throws 4 | 5 | fun interface Interceptor { 6 | 7 | @Throws(DownloadException::class) 8 | fun intercept(chain: Chain): Download.Response 9 | 10 | companion object { 11 | /** 12 | * Constructs an interceptor for a lambda. This compact syntax is most useful for inline 13 | * interceptors. 14 | * 15 | * ``` 16 | * val interceptor = Interceptor { chain: Interceptor.Chain -> 17 | * chain.proceed(chain.request()) 18 | * } 19 | * ``` 20 | */ 21 | inline operator fun invoke(crossinline block: (chain: Chain) -> Download.Response): Interceptor = 22 | Interceptor { block(it) } 23 | } 24 | 25 | interface Chain { 26 | fun request(): Download.Request 27 | 28 | fun call(): Download.Call 29 | 30 | fun callback(): Download.Callback 31 | 32 | @Throws(DownloadException::class) 33 | fun proceed(request: Download.Request): Download.Response 34 | } 35 | } -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/internal/core/BuiltinInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal.core 2 | 3 | import com.billbook.lib.downloader.Download 4 | import com.billbook.lib.downloader.DownloadException 5 | import com.billbook.lib.downloader.Downloader 6 | import com.billbook.lib.downloader.ErrorCode 7 | import com.billbook.lib.downloader.Interceptor 8 | import com.billbook.lib.downloader.internal.exception.CancelException 9 | import com.billbook.lib.downloader.internal.exception.TerminalException 10 | import com.billbook.lib.downloader.internal.util.contentLength 11 | import com.billbook.lib.downloader.internal.util.deleteIfExists 12 | import com.billbook.lib.downloader.internal.util.makeNewFile 13 | import com.billbook.lib.downloader.internal.util.md5 14 | import com.billbook.lib.downloader.internal.util.ofRangeStart 15 | import com.billbook.lib.downloader.internal.util.renameToTarget 16 | import okhttp3.Request 17 | import okhttp3.Response 18 | import okhttp3.internal.http2.StreamResetException 19 | import java.io.FileNotFoundException 20 | import java.io.IOException 21 | import java.io.InterruptedIOException 22 | import java.net.MalformedURLException 23 | 24 | internal class RetryInterceptor(private val client: Downloader) : Interceptor { 25 | 26 | override fun intercept(chain: Interceptor.Chain): Download.Response { 27 | var retry = chain.request().retry ?: client.defaultMaxRetry 28 | var retryCount = 0 29 | while (true) { 30 | val response = chain.proceed(chain.request()) 31 | if (response.isSuccessful() || retry-- < 1 || chain.call().isCanceled()) { 32 | return response.newBuilder() 33 | .retryCount(retryCount) 34 | .build() 35 | } 36 | try { 37 | Thread.sleep(3000) 38 | } catch (ex: InterruptedException) { 39 | // ignore 40 | } 41 | chain.callback().onRetrying(chain.call()) 42 | retryCount++ 43 | } 44 | } 45 | } 46 | 47 | internal fun Interceptor.Chain.checkTerminal() { 48 | val call = this.call() 49 | if (call is InternalCall && call.isCancelSafely()) { 50 | throw TerminalException("Call terminal!") 51 | } 52 | if (call.isCanceled()) { 53 | throw CancelException("Call canceled!") 54 | } 55 | } 56 | 57 | internal class VerifierInterceptor : Interceptor { 58 | override fun intercept(chain: Interceptor.Chain): Download.Response { 59 | val request = chain.request() 60 | val destFile = request.destFile() 61 | val response = chain.proceed(request) 62 | if (!response.isSuccessful()) return response 63 | chain.callback().onChecking(chain.call()) 64 | if (destFile.exists().not()) { 65 | throw DownloadException(ErrorCode.VERIFY_FILE_NOT_EXISTS, "File not exists") 66 | } 67 | if (destFile.isFile.not()) { 68 | throw DownloadException(ErrorCode.VERIFY_FILE_NOT_FILE, "Not file") 69 | } 70 | if (!request.md5.isNullOrEmpty() && destFile.md5() != request.md5) { 71 | throw DownloadException(ErrorCode.VERIFY_MD5_NOT_MATCHED, "MD5 not matched") 72 | } 73 | if (request.size != null && destFile.length() != request.size) { 74 | throw DownloadException(ErrorCode.VERIFY_SIZE_NOT_MATCHED, "Size not matched") 75 | } 76 | return response 77 | } 78 | } 79 | 80 | internal class SynchronousInterceptor : Interceptor { 81 | override fun intercept(chain: Interceptor.Chain): Download.Response { 82 | val request = chain.request() 83 | val (resLock, fileLock) = synchronized(SynchronousInterceptor::class.java) { 84 | sResLocks.getOrPut(request.url) { Any() } to sFileLock.getOrPut(request.path) { Any() } 85 | } 86 | return synchronized(fileLock) { 87 | synchronized(resLock) { 88 | chain.proceed(chain.request()) 89 | } 90 | }.also { 91 | synchronized(SynchronousInterceptor::class.java) { 92 | sResLocks -= request.url 93 | sFileLock -= request.path 94 | } 95 | } 96 | } 97 | 98 | companion object { 99 | private val sResLocks = mutableMapOf() 100 | private val sFileLock = mutableMapOf() 101 | } 102 | } 103 | 104 | internal class LocalExistsInterceptor : Interceptor { 105 | override fun intercept(chain: Interceptor.Chain): Download.Response { 106 | val destFile = chain.request().destFile() 107 | val md5 = chain.request().md5 108 | if (md5 != null && destFile.exists() && destFile.md5() == md5) { 109 | return Download.Response.Builder() 110 | .code(ErrorCode.EXISTS_SUCCESS) 111 | .totalSize(destFile.length()) 112 | .message("File exists and MD5 matched, don`t need download!") 113 | .build() 114 | } 115 | return chain.proceed(chain.request()) 116 | } 117 | } 118 | 119 | internal class ExceptionInterceptor : Interceptor { 120 | override fun intercept(chain: Interceptor.Chain): Download.Response { 121 | return try { 122 | try { 123 | val response = chain.proceed(chain.request()) 124 | if (response.isSuccessful().not()) chain.checkTerminal() 125 | response 126 | } catch (t: Throwable) { 127 | chain.checkTerminal() 128 | throw t 129 | } 130 | } catch (ex: CancelException) { 131 | chain.callback().onCancel(chain.call()) 132 | Download.Response.Builder().code(ErrorCode.CANCEL) 133 | .messageWith(ex) 134 | .build() 135 | } catch (ex: TerminalException) { 136 | chain.callback().onCancel(chain.call()) 137 | chain.request().sourceFile().deleteIfExists() 138 | Download.Response.Builder().code(ErrorCode.CANCEL) 139 | .messageWith(ex) 140 | .build() 141 | } catch (ex: StreamResetException) { 142 | Download.Response.Builder().code(ErrorCode.NET_STREAM_RESET) 143 | .messageWith(ex) 144 | .build() 145 | } catch (ex: InterruptedIOException) { 146 | Download.Response.Builder().code(ErrorCode.IO_INTERRUPTED) 147 | .messageWith(ex) 148 | .build() 149 | } catch (ex: InterruptedException) { 150 | Download.Response.Builder().code(ErrorCode.INTERRUPTED) 151 | .messageWith(ex) 152 | .build() 153 | } catch (ex: IllegalArgumentException) { 154 | Download.Response.Builder().code(ErrorCode.ARGUMENT_EXCEPTION) 155 | .messageWith(ex) 156 | .build() 157 | } catch (ex: MalformedURLException) { 158 | Download.Response.Builder().code(ErrorCode.MALFORMED_URL) 159 | .messageWith(ex) 160 | .build() 161 | } catch (ex: FileNotFoundException) { 162 | Download.Response.Builder().code(ErrorCode.FILE_NOT_FOUND) 163 | .messageWith(ex) 164 | .build() 165 | } catch (ex: IOException) { 166 | Download.Response.Builder().code(ErrorCode.IO_EXCEPTION) 167 | .messageWith(ex) 168 | .build() 169 | } catch (ex: DownloadException) { 170 | Download.Response.Builder().code(ex.code) 171 | .messageWith(ex) 172 | .build() 173 | } catch (t: Throwable) { 174 | Download.Response.Builder().code(ErrorCode.UNKNOWN) 175 | .messageWith(t) 176 | .build() 177 | } 178 | } 179 | 180 | private inline fun Download.Response.Builder.messageWith(t: Throwable): Download.Response.Builder { 181 | return message(t.toString()) 182 | } 183 | } 184 | 185 | internal class ExchangeInterceptor(private val client: Downloader) : Interceptor { 186 | 187 | override fun intercept(chain: Interceptor.Chain): Download.Response { 188 | val downloadRequest = chain.request() 189 | val sourceFile = downloadRequest.sourceFile() 190 | if (!sourceFile.exists()) sourceFile.makeNewFile() 191 | chain.checkTerminal() 192 | val httpResponse = getHttpResponse(chain, downloadRequest) 193 | chain.checkTerminal() 194 | if (!httpResponse.isSuccessful) throw DownloadException( 195 | ErrorCode.REMOTE_CONNECT_ERROR, 196 | "Http connection failed, message = ${httpResponse.message}, httpCode = ${httpResponse.code}" 197 | ) 198 | val body = httpResponse.body 199 | ?: throw DownloadException(ErrorCode.REMOTE_CONTENT_EMPTY, "Remote source body is null") 200 | var downloadLength = 0L 201 | val startLength = sourceFile.length() 202 | val contentLength = startLength + httpResponse.contentLength() 203 | try { 204 | IOExchange().exchange(sourceFile, body.source()) { 205 | if (it > 0) { 206 | downloadLength += it 207 | } 208 | chain.callback() 209 | .onLoading(chain.call(), startLength + downloadLength, contentLength) 210 | } 211 | } catch (e: IOException) { 212 | chain.checkTerminal() 213 | throw e 214 | } 215 | sourceFile.renameToTarget(downloadRequest.destFile()) 216 | return Download.Response.Builder() 217 | .code(if (startLength > 0) ErrorCode.APPEND_SUCCESS else ErrorCode.SUCCESS) 218 | .downloadLength(downloadLength) 219 | .totalSize(contentLength) 220 | .output(downloadRequest.destFile()) 221 | .message("Success") 222 | .build() 223 | } 224 | 225 | private fun getHttpResponse(chain: Interceptor.Chain, request: Download.Request): Response { 226 | val httpRequest = Request.Builder().url(request.url) 227 | .ofRangeStart(request.sourceFile().length()) 228 | .get() 229 | .build() 230 | try { 231 | val httpCall = client.okhttpClient.newCall(httpRequest) 232 | val call = chain.call() 233 | if (call is InternalCall) { 234 | call.httpCall = httpCall 235 | } 236 | return httpCall.execute() 237 | } catch (e: IOException) { 238 | chain.checkTerminal() 239 | throw DownloadException(ErrorCode.REMOTE_CONNECT_ERROR, "Connection failed: $e", e) 240 | } 241 | } 242 | } -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/internal/core/Dispatcher.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal.core 2 | 3 | import com.billbook.lib.downloader.Download 4 | import java.util.concurrent.CopyOnWriteArrayList 5 | 6 | internal class Dispatcher : Download.Subjection, Download.Subscriber { 7 | 8 | private val observers: MutableMap> = HashMap() 9 | private val globalObservers = CopyOnWriteArrayList() 10 | 11 | override fun subscribe(subscriber: Download.Subscriber) { 12 | globalObservers += subscriber 13 | } 14 | 15 | override fun subscribe(url: String, subscriber: Download.Subscriber) { 16 | synchronized(observers) { 17 | observers.getOrPut(url) { mutableSetOf() } += subscriber 18 | } 19 | } 20 | 21 | override fun unsubscribe(subscriber: Download.Subscriber) { 22 | globalObservers -= subscriber 23 | } 24 | 25 | override fun unsubscribe(url: String, subscriber: Download.Subscriber) { 26 | synchronized(observers) { 27 | observers.getOrPut(url) { mutableSetOf() } -= subscriber 28 | } 29 | } 30 | 31 | private fun Download.Call.dispatch(block: Download.Subscriber.() -> Unit) { 32 | synchronized(observers) { 33 | observers[request.url]?.forEach(block) 34 | } 35 | globalObservers.forEach(block) 36 | } 37 | 38 | override fun onSuccess(call: Download.Call, response: Download.Response) { 39 | call.dispatch { onSuccess(call, response) } 40 | } 41 | 42 | override fun onFailure(call: Download.Call, response: Download.Response) { 43 | call.dispatch { onFailure(call, response) } 44 | } 45 | } -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/internal/core/DownloadCall.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal.core 2 | 3 | import com.billbook.lib.downloader.Download 4 | import com.billbook.lib.downloader.Downloader 5 | import com.billbook.lib.downloader.EventListener 6 | import com.billbook.lib.downloader.Interceptor 7 | import okhttp3.Call 8 | import java.util.ServiceLoader 9 | import java.util.concurrent.ExecutorService 10 | import java.util.concurrent.atomic.AtomicBoolean 11 | 12 | internal interface InternalCall : Download.Call { 13 | 14 | var httpCall: Call? 15 | fun isCancelSafely(): Boolean 16 | } 17 | 18 | internal class DefaultDownloadCall( 19 | private val client: Downloader, 20 | private val originalRequest: Download.Request 21 | ) : InternalCall { 22 | 23 | private val eventListener: EventListener = client.eventListenerFactory.create(this) 24 | private val canceled = AtomicBoolean(false) 25 | private val canceledSafely = AtomicBoolean(false) 26 | private val executed = AtomicBoolean(false) 27 | override var httpCall: Call? = null 28 | 29 | override val request: Download.Request get() = originalRequest 30 | 31 | override fun execute(): Download.Response { 32 | return execute(Download.Callback.NOOP) 33 | } 34 | 35 | override fun execute(callback: Download.Callback): Download.Response { 36 | check(executed.compareAndSet(false, true)) { "Already Executed" } 37 | callback.onStart(this) 38 | eventListener.callStart(this) 39 | try { 40 | client.downloadPool.executed(this, eventListener) 41 | val response = getResponseWithInterceptorChain(callback) 42 | if (response.isSuccessful()) { 43 | callback.onSuccess(this, response) 44 | eventListener.callSuccess(this, response) 45 | } else if (isCanceled()) { 46 | callback.onCancel(this) 47 | eventListener.callCanceled(this) 48 | } else { 49 | callback.onFailure(this, response) 50 | eventListener.callFailed(this, response) 51 | } 52 | return response 53 | } finally { 54 | eventListener.callEnd(this) 55 | client.downloadPool.finished(this) 56 | } 57 | } 58 | 59 | private fun getResponseWithInterceptorChain(callback: Download.Callback): Download.Response { 60 | val interceptors = mutableListOf() 61 | interceptors += RetryInterceptor(client) 62 | interceptors += ExceptionInterceptor() 63 | interceptors += LocalExistsInterceptor() 64 | interceptors += SynchronousInterceptor() 65 | interceptors += client.interceptors 66 | interceptors += extInterceptors 67 | interceptors += VerifierInterceptor() 68 | interceptors += ExchangeInterceptor(client) 69 | val chain = DefaultInterceptorChain(0, originalRequest, this, callback, interceptors) 70 | return chain.proceed(originalRequest) 71 | } 72 | 73 | override fun enqueue() { 74 | enqueue(Download.Callback.NOOP) 75 | } 76 | 77 | override fun enqueue(callback: Download.Callback) { 78 | check(executed.compareAndSet(false, true)) { "Already Executed" } 79 | client.downloadPool.enqueue(AsyncCall(callback, request.priority), eventListener) 80 | } 81 | 82 | override fun cancel() { 83 | if (isCanceled()) return 84 | this.httpCall?.cancel() 85 | this.canceled.getAndSet(true) 86 | } 87 | 88 | override fun cancelSafely() { 89 | cancel() 90 | this.canceledSafely.getAndSet(true) 91 | } 92 | 93 | override fun isExecuted(): Boolean { 94 | return this.executed.get() 95 | } 96 | 97 | override fun isCanceled(): Boolean { 98 | return this.canceled.get() 99 | } 100 | 101 | override fun isCancelSafely(): Boolean { 102 | return this.canceledSafely.get() 103 | } 104 | 105 | override fun toString(): String { 106 | return "DefaultDownloadCall(request=$request)" 107 | } 108 | 109 | internal inner class AsyncCall( 110 | private val callback: Download.Callback, 111 | private val priority: Download.Priority 112 | ) : Runnable, Comparable { 113 | 114 | val call: Download.Call 115 | get() = this@DefaultDownloadCall 116 | 117 | override fun run() { 118 | callback.onStart(call) 119 | eventListener.callStart(call) 120 | try { 121 | val response = getResponseWithInterceptorChain(callback) 122 | if (response.isSuccessful()) { 123 | callback.onSuccess(call, response) 124 | eventListener.callSuccess(call, response) 125 | } else if (call.isCanceled()) { 126 | callback.onCancel(call) 127 | eventListener.callCanceled(call) 128 | } else { 129 | callback.onFailure(call, response) 130 | eventListener.callFailed(call, response) 131 | } 132 | } finally { 133 | eventListener.callEnd(call) 134 | client.downloadPool.finished(this) 135 | } 136 | } 137 | 138 | fun executeOn(executorService: ExecutorService) { 139 | var success = false 140 | try { 141 | executorService.execute(this) 142 | success = true 143 | } finally { 144 | if (!success) { 145 | client.downloadPool.finished(this) 146 | } 147 | } 148 | } 149 | 150 | override fun compareTo(other: Download.Priority): Int { 151 | return this.priority.ordinal - other.ordinal 152 | } 153 | } 154 | 155 | companion object { 156 | private val extInterceptors: List by lazy(LazyThreadSafetyMode.NONE) { 157 | ServiceLoader.load(Interceptor::class.java).toList() 158 | } 159 | } 160 | } 161 | 162 | -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/internal/core/IOExchange.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal.core 2 | 3 | import okio.* 4 | import java.io.File 5 | import java.io.IOException 6 | import kotlin.jvm.Throws 7 | 8 | internal class IOExchange { 9 | 10 | @Throws(IOException::class) 11 | fun exchange(file: File, source: BufferedSource, onRead: (Long) -> Unit) { 12 | file.appendingSink().buffer().use { 13 | it.writeAll(object : ForwardingSource(source) { 14 | override fun read(sink: Buffer, byteCount: Long): Long { 15 | val bytes = super.read(sink, byteCount) 16 | onRead(bytes) 17 | return bytes 18 | } 19 | }) 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/internal/core/InterceptorChain.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal.core 2 | 3 | import com.billbook.lib.downloader.Download 4 | import com.billbook.lib.downloader.Interceptor 5 | 6 | internal class DefaultInterceptorChain( 7 | private val index: Int, 8 | private val request: Download.Request, 9 | private val call: Download.Call, 10 | private val callback: Download.Callback, 11 | private val interceptors: List 12 | ) : Interceptor.Chain { 13 | 14 | private fun copy(index: Int) = DefaultInterceptorChain(index, request, call, callback, interceptors) 15 | 16 | override fun request(): Download.Request { 17 | return this.request 18 | } 19 | 20 | override fun call(): Download.Call { 21 | return this.call 22 | } 23 | 24 | override fun callback(): Download.Callback { 25 | return this.callback 26 | } 27 | 28 | override fun proceed(request: Download.Request): Download.Response { 29 | check(index < interceptors.size) 30 | return interceptors[index].intercept(copy(index + 1)) 31 | } 32 | } -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/internal/exception/InternalException.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal.exception 2 | 3 | internal open class TerminalException(override val message: String?) : RuntimeException(message) 4 | 5 | internal class CancelException(override val message: String?) : TerminalException(message) 6 | -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/internal/util/Http.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal.util 2 | 3 | import com.billbook.lib.downloader.Downloader 4 | import okhttp3.OkHttpClient 5 | import okhttp3.Request 6 | import okhttp3.Response 7 | import java.util.concurrent.TimeUnit 8 | 9 | private const val HEADER_CONTENT_LENGTH = "Content-Length" 10 | private const val HEADER_RANGE = "Range" 11 | private const val RANGE_START_PARAM = "bytes=%s-" 12 | 13 | internal fun Response.contentLength() = this.header(HEADER_CONTENT_LENGTH)?.toLongOrNull() ?: 0L 14 | 15 | internal inline fun Request.Builder.ofRangeStart(start: Long): Request.Builder { 16 | if (start != 0L) { 17 | this.addHeader(HEADER_RANGE, String.format(RANGE_START_PARAM, start)) 18 | } 19 | return this 20 | } 21 | 22 | internal fun OkHttpClient.asFactory(): Downloader.Factory = Downloader.Factory { this } 23 | 24 | internal val DefaultOkhttpClient: OkHttpClient by lazy { 25 | OkHttpClient.Builder() 26 | .connectTimeout(15, TimeUnit.SECONDS) 27 | .readTimeout(15, TimeUnit.SECONDS) 28 | .writeTimeout(15, TimeUnit.SECONDS) 29 | .retryOnConnectionFailure(true) 30 | .cache(null) 31 | .build() 32 | } -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/internal/util/Preconditions.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal.util 2 | 3 | import java.lang.IllegalArgumentException 4 | 5 | inline fun requireNotNullOrEmpty(value: String?, lazyMessage: () -> Any): String { 6 | if (value.isNullOrEmpty()) { 7 | val message = lazyMessage() 8 | throw IllegalArgumentException(message.toString()) 9 | } else { 10 | return value 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /okdownloader/src/main/java/com/billbook/lib/downloader/internal/util/Util.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader.internal.util 2 | 3 | import com.billbook.lib.downloader.DownloadException 4 | import com.billbook.lib.downloader.ErrorCode 5 | import org.apache.commons.codec.digest.DigestUtils 6 | import java.io.File 7 | 8 | internal val CPU_COUNT = Runtime.getRuntime().availableProcessors() 9 | 10 | @Throws(DownloadException::class) 11 | internal fun File.makeNewFile(): Boolean { 12 | if (this.exists()) return true 13 | this.parentFile?.mkdirs() 14 | if (this.parentFile?.exists() == false || this.parentFile?.isDirectory == false) { 15 | throw DownloadException(ErrorCode.IO_CREATE_DIRECTORY_ERROR, "Directory could not be created") 16 | } 17 | return this.createNewFile() 18 | } 19 | 20 | @Throws(DownloadException::class) 21 | internal fun File.renameToTarget(target: File): Boolean { 22 | val result = this.renameTo(target) 23 | if (!result) { 24 | target.deleteIfExists() 25 | this.copyTo(target, true) 26 | return true 27 | } 28 | return true 29 | } 30 | 31 | internal inline fun File.deleteIfExists() { 32 | try { 33 | if (this.exists()) this.delete() 34 | } catch (e: Throwable) { 35 | e.printStackTrace() 36 | } 37 | } 38 | 39 | internal fun File.md5(): String { 40 | return DigestUtils.md5Hex(this.readBytes()) 41 | } 42 | 43 | -------------------------------------------------------------------------------- /okdownloader/src/test/java/com/billbook/lib/downloader/DownloadUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | import com.billbook.lib.downloader.internal.util.md5 4 | import okhttp3.internal.notifyAll 5 | import okhttp3.internal.wait 6 | import org.junit.Assert 7 | import org.junit.Test 8 | import java.lang.IllegalStateException 9 | import java.nio.file.Files 10 | import java.util.UUID 11 | 12 | /** 13 | * @author xluotong@gmail.com 14 | */ 15 | class DownloadUnitTest { 16 | 17 | private val downloader by lazy { Downloader.Builder().build() } 18 | private val downloader2 by lazy { downloader.newBuilder().build() } 19 | 20 | @Test 21 | fun argument_is_correct() { 22 | Assert.assertThrows(IllegalArgumentException::class.java) { 23 | Download.Request.Builder().build() 24 | } 25 | Assert.assertThrows(IllegalArgumentException::class.java) { 26 | Download.Request.Builder().url("") 27 | .build() 28 | } 29 | Assert.assertThrows(IllegalArgumentException::class.java) { 30 | Download.Request.Builder().url("xxx") 31 | .build() 32 | } 33 | Assert.assertThrows(IllegalArgumentException::class.java) { 34 | Download.Request.Builder().into("").build() 35 | } 36 | val request = Download.Request.Builder() 37 | .url("xxx") 38 | .into("xxx") 39 | .build() 40 | val response = downloader.newCall(request).execute() 41 | Assert.assertFalse(response.isSuccessful()) 42 | } 43 | 44 | @Test 45 | fun execute_is_correct() { 46 | val request = buildRequest() 47 | val call = downloader.newCall(request) 48 | call.execute() 49 | Assert.assertThrows(IllegalStateException::class.java) { 50 | call.execute() 51 | } 52 | } 53 | 54 | private fun buildRequest(): Download.Request { 55 | return Download.Request.Builder() 56 | .url(FakeData.resources[0].url) 57 | .into(Files.createTempFile(UUID.randomUUID().toString(), ".apk").toFile()) 58 | .build() 59 | } 60 | 61 | @Test 62 | fun source_file_exists() { 63 | val request = buildRequest() 64 | val call = downloader.newCall(request) 65 | call.enqueue(object : Download.Callback { 66 | override fun onLoading(tmp: Download.Call, current: Long, total: Long) { 67 | super.onLoading(tmp, current, total) 68 | synchronized(call) { call.notifyAll() } 69 | } 70 | }) 71 | synchronized(call) { call.wait() } 72 | Assert.assertTrue(request.sourceFile().exists()) 73 | } 74 | 75 | @Test 76 | fun cancel_is_correct() { 77 | val request = buildRequest() 78 | val call = downloader.newCall(request) 79 | call.enqueue(object : Download.Callback { 80 | override fun onLoading(tmp: Download.Call, current: Long, total: Long) { 81 | super.onLoading(tmp, current, total) 82 | synchronized(call) { call.notifyAll() } 83 | } 84 | }) 85 | synchronized(call) { call.wait() } 86 | call.cancel() 87 | Thread.sleep(1000) 88 | Assert.assertTrue(request.sourceFile().exists()) 89 | } 90 | 91 | @Test 92 | fun cancelSafely_is_correct() { 93 | val request = buildRequest() 94 | val call = downloader.newCall(request) 95 | call.enqueue(object : Download.Callback { 96 | override fun onLoading(tmp: Download.Call, current: Long, total: Long) { 97 | super.onLoading(tmp, current, total) 98 | synchronized(call) { call.notifyAll() } 99 | } 100 | }) 101 | synchronized(call) { call.wait() } 102 | call.cancelSafely() 103 | Assert.assertTrue(call.isCanceled()) 104 | Thread.sleep(1000) 105 | Assert.assertFalse(request.sourceFile().exists()) 106 | } 107 | 108 | @Test 109 | fun cancelAll_is_correct() { 110 | val request = buildRequest() 111 | val call = downloader.newCall(request) 112 | call.enqueue(object : Download.Callback { 113 | override fun onLoading(tmp: Download.Call, current: Long, total: Long) { 114 | super.onLoading(tmp, current, total) 115 | synchronized(call) { call.notifyAll() } 116 | } 117 | }) 118 | synchronized(call) { call.wait() } 119 | downloader.cancelAll() 120 | Thread.sleep(1000) 121 | Assert.assertTrue(call.isCanceled()) 122 | Assert.assertTrue(request.sourceFile().exists()) 123 | } 124 | 125 | @Test 126 | fun cancelAllSafely_is_correct() { 127 | val request = buildRequest() 128 | val call = downloader.newCall(request) 129 | call.enqueue(object : Download.Callback { 130 | override fun onLoading(tmp: Download.Call, current: Long, total: Long) { 131 | super.onLoading(tmp, current, total) 132 | synchronized(call) { call.notifyAll() } 133 | } 134 | }) 135 | synchronized(call) { call.wait() } 136 | downloader.cancelAllSafely() 137 | Thread.sleep(1000) 138 | Assert.assertTrue(call.isCanceled()) 139 | Assert.assertFalse(request.sourceFile().exists()) 140 | } 141 | 142 | @Test 143 | fun downloadPool_is_correct() { 144 | val request = buildRequest() 145 | val call = downloader.newCall(request) 146 | call.enqueue(object : Download.Callback { 147 | override fun onLoading(tmp: Download.Call, current: Long, total: Long) { 148 | super.onLoading(tmp, current, total) 149 | synchronized(call) { call.notifyAll() } 150 | } 151 | }) 152 | synchronized(call) { call.wait() } 153 | downloader2.cancelAll() 154 | Thread.sleep(1000) 155 | Assert.assertFalse(call.isCanceled()) 156 | Assert.assertTrue(request.sourceFile().exists()) 157 | } 158 | 159 | @Test 160 | fun callback_is_correct() { 161 | val request = buildRequest() 162 | val call = downloader.newCall(request) 163 | val methodCount = mutableMapOf() 164 | call.execute(object : Download.Callback { 165 | override fun onStart(call: Download.Call) { 166 | super.onStart(call) 167 | methodCount["onStart"] = methodCount["onStart"] ?: 0 + 1 168 | } 169 | 170 | override fun onCancel(call: Download.Call) { 171 | super.onCancel(call) 172 | methodCount["onCancel"] = methodCount["onCancel"] ?: 0 + 1 173 | } 174 | 175 | override fun onChecking(call: Download.Call) { 176 | super.onChecking(call) 177 | methodCount["onChecking"] = methodCount["onChecking"] ?: 0 + 1 178 | } 179 | 180 | override fun onRetrying(call: Download.Call) { 181 | super.onRetrying(call) 182 | methodCount["onRetrying"] = methodCount["onRetrying"] ?: 0 + 1 183 | } 184 | 185 | override fun onSuccess(call: Download.Call, response: Download.Response) { 186 | super.onSuccess(call, response) 187 | println("onSuccess") 188 | methodCount["onSuccess"] = methodCount["onSuccess"] ?: 0 + 1 189 | } 190 | 191 | override fun onFailure(call: Download.Call, response: Download.Response) { 192 | super.onFailure(call, response) 193 | println("onFailure") 194 | methodCount["onFailure"] = methodCount["onFailure"] ?: 0 + 1 195 | } 196 | 197 | override fun onLoading(tmp: Download.Call, current: Long, total: Long) { 198 | } 199 | }) 200 | Assert.assertEquals(1, methodCount["onStart"]) 201 | Assert.assertEquals(1, methodCount["onSuccess"]) 202 | } 203 | 204 | @Test 205 | fun download_is_correct() { 206 | val request = Download.Request.Builder() 207 | .url(FakeData.resources[0].url) 208 | .into(Files.createTempFile(UUID.randomUUID().toString(), ".apk").toFile()) 209 | .build() 210 | val response = downloader.newCall(request).execute() 211 | Assert.assertTrue(response.isSuccessful()) 212 | Assert.assertFalse(response.isBreakpoint()) 213 | Assert.assertTrue(response.output?.exists() == true) 214 | Assert.assertEquals(FakeData.resources[0].size, response.downloadLength) 215 | Assert.assertEquals(FakeData.resources[0].size, response.totalSize) 216 | Assert.assertEquals(FakeData.resources[0].md5, response.output?.md5()) 217 | } 218 | 219 | @Test 220 | fun retry_count_is_correct() { 221 | val request = Download.Request.Builder() 222 | .url("https://xxx") 223 | .into(Files.createTempFile(UUID.randomUUID().toString(), ".apk").toFile()) 224 | .build() 225 | val response = downloader.newCall(request).execute() 226 | Assert.assertFalse(response.isSuccessful()) 227 | Assert.assertFalse(response.output?.exists() == true) 228 | Assert.assertEquals(3, response.retryCount) 229 | Assert.assertEquals(0, response.downloadLength) 230 | Assert.assertEquals(0, response.totalSize) 231 | } 232 | 233 | @Test 234 | fun download_pool_is_correct() { 235 | val downloadPool = DownloadPool() 236 | val downloader = Downloader.Builder().downloadPool(downloadPool).build() 237 | val downloader2 = downloader.newBuilder().build() 238 | val downloader3 = downloader.newBuilder().downloadPool(downloadPool).build() 239 | val downloader4 = Downloader.Builder().downloadPool(downloadPool).build() 240 | val downloader5 = Downloader.Builder().build() 241 | Assert.assertFalse(downloader.downloadPool === downloader2.downloadPool) 242 | Assert.assertSame(downloader.downloadPool, downloader3.downloadPool) 243 | Assert.assertSame(downloader.downloadPool, downloader4.downloadPool) 244 | Assert.assertFalse(downloader.downloadPool === downloader5.downloadPool) 245 | } 246 | } -------------------------------------------------------------------------------- /okdownloader/src/test/java/com/billbook/lib/downloader/FakeData.kt: -------------------------------------------------------------------------------- 1 | package com.billbook.lib.downloader 2 | 3 | /** 4 | * @author xluotong@gmail.com 5 | */ 6 | object FakeData { 7 | 8 | val resources: List = listOf( 9 | ResourceBean( 10 | url = "http://cdn.billbook.net.cn/apk/Threads%2C%20an%20Instagram%20app_291.0.0.31.111_Apkpure.apk", 11 | name = "Threads", 12 | icon = "https://image.winudf.com/v2/image1/Y29tLmluc3RhZ3JhbS5iYXJjZWxvbmFfaWNvbl8xNjg4MjYzMjE4XzAyMg/icon.webp?w=280&fakeurl=1&type=.webp", 13 | size = 76639442, 14 | md5 = "9631fff7a586b9870fb0116b136cbfef" 15 | ), 16 | ResourceBean( 17 | url = "http://cdn.billbook.net.cn/apk/Twitter_9.96.0-release.0_Apkpure.apk", 18 | name = "Twitter", 19 | icon = "https://image.winudf.com/v2/image1/Y29tLnR3aXR0ZXIuYW5kcm9pZF9pY29uXzE1NTU0NjI4MTJfMDI2/icon.webp?w=280&fakeurl=1&type=.webp", 20 | size = 113837675, 21 | md5 = "25eb790c62edf3363ffd899ed8ea8a7a" 22 | ), 23 | ResourceBean( 24 | url = "http://cdn.billbook.net.cn/apk/Cash%E2%80%99em%20All_%20Play%20%26%20Win_4.8.1-CashemAll_Apkpure.apk", 25 | name = "Cash’em All: Play & Win", 26 | icon = "https://image.winudf.com/v2/image1/b25saW5lLmNhc2hlbWFsbC5hcHBfaWNvbl8xNTk0NDA0NDY5XzAwOA/icon.webp?w=280&fakeurl=1&type=.webp", 27 | size = 56572333, 28 | md5 = "1627a6317e820347ebeeec1aef6e6df3" 29 | ), 30 | ResourceBean( 31 | url = "http://cdn.billbook.net.cn/apk/TikTok_30.3.4_Apkpure.xapk", 32 | name = "TikTok", 33 | icon = "https://image.winudf.com/v2/image1/Y29tLnpoaWxpYW9hcHAubXVzaWNhbGx5X2ljb25fMTY2NjcyMjU0MF8wOTY/icon.webp?w=280&fakeurl=1&type=.webp", 34 | size = 129126005, 35 | md5 = "067095681c5fa97def29f2b83b7e6803" 36 | ), 37 | ResourceBean( 38 | url = "http://cdn.billbook.net.cn/apk/Facebook_422.0.0.26.76_Apkpure.xapk", 39 | name = "Facebook", 40 | icon = "https://image.winudf.com/v2/image1/Y29tLmZhY2Vib29rLmthdGFuYV9pY29uXzE1NTc5OTAwMzBfMDIz/icon.webp?w=280&fakeurl=1&type=.webp", 41 | size = 54846526, 42 | md5 = "dc050e289d9fe0f10ba2321740301f6d" 43 | ), 44 | ResourceBean( 45 | url = "http://cdn.billbook.net.cn/apk/CapCut%20-%20Video%20Editor_8.7.0_Apkpure.apk", 46 | name = "CapCut ", 47 | icon = "https://image.winudf.com/v2/image1/Y29tLmxlbW9uLmx2b3ZlcnNlYXNfaWNvbl8xNjYwMjE4OTc4XzA1NA/icon.webp?w=280&fakeurl=1&type=.webp", 48 | size = 170715905, 49 | md5 = "f34dcf22ec82dfd82d4ab2f110a2c2de" 50 | ), 51 | ResourceBean( 52 | url = "http://cdn.billbook.net.cn/apk/Snapchat_12.42.0.58_Apkpure.xapk", 53 | name = "Snapchat", 54 | icon = "https://image.winudf.com/v2/image1/Y29tLnNuYXBjaGF0LmFuZHJvaWRfaWNvbl8xNTY2ODQ0NzEzXzA4Nw/icon.webp?w=280&fakeurl=1&type=.webp", 55 | size = 97595724, 56 | md5 = "99a6e36d5523d223a651e62d9a2cc052" 57 | ), 58 | ResourceBean( 59 | url = "http://cdn.billbook.net.cn/apk/WhatsApp%20Messenger_2.23.15.3_Apkpure.apk", 60 | name = "WhatsApp Messenger", 61 | icon = "https://image.winudf.com/v2/image1/Y29tLndoYXRzYXBwX2ljb25fMTU1OTg1MDA2NF8wNjI/icon.webp?w=280&fakeurl=1&type=.webp", 62 | size = 54225683, 63 | md5 = "57dfd00e1a5319cf8d5fa9b1f9e1a28e" 64 | ), 65 | ResourceBean( 66 | url = "http://cdn.billbook.net.cn/apk/Instagram_290.0.0.13.76_Apkpure.apk", 67 | name = "Instagram", 68 | icon = "https://image.winudf.com/v2/image1/Y29tLmluc3RhZ3JhbS5hbmRyb2lkX2ljb25fMTY3NjM0ODUzN18wMzI/icon.webp?w=280&fakeurl=1&type=.webp", 69 | size = 53887484, 70 | md5 = "43e780faac8092ec5f294fa3a67bc719" 71 | ) 72 | ) 73 | } 74 | 75 | data class ResourceBean( 76 | val url: String, 77 | val icon: String, 78 | val name: String, 79 | val size: Long, 80 | val md5: String 81 | ) -------------------------------------------------------------------------------- /screencaps/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ydxlt/okdownloader/6a1da801a092fc6ee91c73310537d70cde269692/screencaps/1.png -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 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 = "okdownloader" 17 | include(":okdownloader") 18 | include(":okdownloader-android") 19 | include(":java-sample") 20 | include(":android-sample") 21 | include(":fakedata") 22 | --------------------------------------------------------------------------------