├── .gitignore ├── FILE.md ├── OAUTH.md ├── PROGRESS.md ├── README.md ├── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── http ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── cn │ └── numeron │ ├── okhttp │ ├── InternalUtil.kt │ ├── RetryInterceptor.kt │ ├── file │ │ ├── BreakpointResumeInterceptor.kt │ │ ├── ContinuableUpProgressCallback.kt │ │ ├── DlProgressCallback.kt │ │ ├── FileResponseBody.kt │ │ ├── ProgressInterceptor.kt │ │ ├── ProgressRequestBody.kt │ │ ├── ProgressResponseBody.kt │ │ └── UpProgressCallback.kt │ ├── log │ │ ├── LogLevel.kt │ │ └── TextLogInterceptor.kt │ └── oauth │ │ ├── OAuthClientInterceptor.kt │ │ └── OAuthProvider.kt │ └── retrofit │ ├── DateConverterFactory.kt │ ├── DynamicTimeoutInterceptor.kt │ ├── DynamicUrlInterceptor.kt │ ├── FileConverter.kt │ ├── Port.kt │ ├── RequestTimeout.kt │ └── Url.kt └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | # Project exclude paths 2 | /.gradle/ 3 | /plugin/build/ -------------------------------------------------------------------------------- /FILE.md: -------------------------------------------------------------------------------- 1 | ### 文件上传/下载 2 | * 通过`OkHttp`的拦截器实现的下载、上传断点续传功能,同时支持`OkHttp`和`Retrofit`。 3 | 4 | * 断点续传 5 | - 无需额外的操作,默认即支持下载的断点续传; 6 | - 当文件存在时,并且与响应信息中的文件大小一致时,不会重复下载文件; 7 | - 当文件错误时,或无法从响应信息中获取到文件信息时,完整的下载并覆盖原文件; 8 | - 当文件只有一部分时,会下载剩余的部分数据,并追加到文件中。 9 | 10 | ### OkHttp 11 | 12 | ##### 安装方法 13 | * 在构建`OkHttpClient`时,添加[`BreakpointResumeInterceptor`](https://github.com/xiazunyang/http/blob/master/http/src/main/kotlin/cn/numeron/okhttp/file/BreakpointResumeInterceptor.kt)拦截器。 14 | 15 | ##### 使用方法 16 | * 指定文件的位置可以用`tag(Class, T)`方法向请求实例中添加一个`File`实例,此参数可以是一个文件,也可以是一个目录: 17 | - 当`File`参数是文件时,下载的数据会写入到该文件中 18 | - 当`File`参数是目录时,会自动从响应信息或请求信息中获取文件名,并在该目录下创建一个文件,下载的数据会写入到该文件中 19 | 20 | ```kotlin 21 | val file = File("文件或存放目录") 22 | val request = Request.Builder() 23 | .tag(File::Class.java, file) 24 | ... 25 | val response = okHttpClient.newCall(request).execute() 26 | if (response.isSuccessful) { 27 | TODO("操作file即可,下载的数据已写入file文件中或目录下") 28 | } 29 | ``` 30 | 31 | ### Retrofit 32 | 33 | ##### 安装方法 34 | * 在构建`OkHttpClient`时,添加[`BreakpointResumeInterceptor`](https://github.com/xiazunyang/http/blob/master/http/src/main/kotlin/cn/numeron/okhttp/file/BreakpointResumeInterceptor.kt)拦截器。 35 | * 在构建`Retrofit`时,添加[`FileConverter`](https://github.com/xiazunyang/http/blob/master/http/src/main/kotlin/cn/numeron/retrofit/FileConverter.kt)转换器。 36 | 37 | ##### 使用方法 38 | * 在接口中使用`@Tag`注解标记一个`File`类型的参数用于指定要写入的文件或存放目录 39 | 40 | ```kotlin 41 | interface FileApi { 42 | 43 | /** 44 | * url 文件的下载地址 45 | * fileOrDir 要写入的文件或存放目录 46 | * callback 下载进度的监听接口 47 | * */ 48 | @GET 49 | @Streaming 50 | suspend fun download(@Url url: String, @Tag fileOrDir: File, @Tag callback: DlProgressCallback): File 51 | } 52 | 53 | // 创建API接口的实例 54 | val fileApi = retrofit.create() 55 | // 创建参数实例 56 | val downloadUrl = "文件的下载地址" 57 | val file = File("文件位置或存放目录") 58 | val callback = DlProgressCallback { progress -> 59 | TODO("处理下载进度") 60 | } 61 | // 请求网络,注:添加`FileConverter`后,可使用File类型直接接收文件 62 | val file = fileApi.download(downloadUrl, file, callback) 63 | TODO("处理file文件") 64 | ``` 65 | 66 | #### 上传断点续传 67 | * 上传的断点续传需要服务端的支持,一般的处理逻辑如下: 68 | 1. app端计算文件的`MD5`值; 69 | 2. 将`MD5`值提交到服务器,服务器返回`已存在的文件长度`等信息; 70 | 3. app将文件转为输入流,并忽略服务器上已存在的长度,将剩余的数据提交到服务器。 71 | * 鉴于服务端的实现各有不同,所以此处只提供基于`Retrofit`的部分的逻辑,剩余逻辑请自行处理。 72 | * 处理方法参考如下: 73 | ```kotlin 74 | val file = File("要上传的文件路径") 75 | //获取文件的MD5值 76 | val fileMd5 = file.getMd5() 77 | //将MD5值提交到服务器查询服务器上已存在的数据长度 78 | val existLength: Long = uploadApi.getExistLength(fileMd5) 79 | //创建上传进度监听器 80 | val upProgressCallback = UpProgressCallback { progress -> 81 | TODO("处理上传进度") 82 | } 83 | val mediaType = TODO("获取MediaType.") 84 | if(existLength <= 0) { 85 | //正常上传 86 | val requestBody = file.asRequestBody(mediaType) 87 | uploadApi.upload(requestBody, upProgressCallback) 88 | } else { 89 | //断点续传,忽略掉前existLength的数据 90 | val fileBytes = file.readBytes() 91 | val requestBody = fileBytes.toRequestBoey(mediaType, existLength.toInt(), fileBytes.size - existLength.toInt()) 92 | uploadApi.upload(file, ContinuableUpProgressCallback(existLength, upProgressCallback)) 93 | } 94 | ``` -------------------------------------------------------------------------------- /OAUTH.md: -------------------------------------------------------------------------------- 1 | ### Oauth授权管理 2 | 3 | * 通过`OkHttp`的拦截器实现的Oauth授权管理工具 4 | * 可添加其它头信息到每个请求当中 5 | * 可以服务端返回401时,尝试重新获取token并重试请求 6 | 7 | #### 使用方法 8 | 9 | * 实现`OauthProvider`接口 10 | * 创建`OauthInterceptor`实例并添加到`OkHttp`中 11 | 12 | ```kotlin 13 | //1.使用object单例实现OauthProvider接口 14 | object AuthManagement : OauthProvider 15 | 16 | /** 登录成功后,把权限Token保存到此处 */ 17 | override val accessToken: String? = null 18 | 19 | /** 其它要添加的请求头均添加到此处 */ 20 | override val headers = mutableMapOf() 21 | 22 | override fun refreshToken(): String? { 23 | TODO("获取新的TOKEN,并作为返回值返回") 24 | } 25 | 26 | } 27 | 28 | //2.创建OAuthClientInterceptor的实例 29 | val oauthClientInterceptor = OAuthClientInterceptor(AuthManagement) 30 | 31 | //3.添加到OkHttp中 32 | val okHttpClient = OkHttpClient.Builder() 33 | ... 34 | .addInterceptor(oauthClientInterceptor) 35 | ... 36 | .build() 37 | ``` 38 | -------------------------------------------------------------------------------- /PROGRESS.md: -------------------------------------------------------------------------------- 1 | ### 下载/上传进度监听 2 | * 通过`OkHttp`的拦截器实现的下载、上传进度监听功能,同时支持`OkHttp`和`Retrofit`。 3 | 4 | ### `OkHttpClient`的使用方法 5 | 6 | ##### 安装方法 7 | * 将 [`ProgressInterceptor`](https://github.com/xiazunyang/http/blob/master/http/src/main/kotlin/cn/numeron/okhttp/file/ProgressInterceptor.kt) 8 | 添加到`OkHttpClient`中即可。 9 | 10 | ##### 监听下载进度 11 | 1. 在构建`Request`对象时,构建一个`DlProgressCallback`实例,并通过`tag(Class, T)`方法添加到`Request.Builder`中。 12 | 2. 在下载文件或请求网络时,服务器数据传输到本地时会调用该回调实例的`update`方法,参数是一个`float`值,可通过`(progress * 100).toInt()`来得到下载进度的百分比。 13 | 3. 注意:`update`方法运行在子线程中(与`Interceptor.intercept`方法的调用线程一致)。 14 | ```kotlin 15 | val request = Request.Builder() 16 | .tag(DlProgressCallback::class.java, DlProgressCallback { progress -> 17 | val percent = (progress * 100).toInt() 18 | TODO("处理下载进度监听") 19 | }) 20 | ... 21 | ``` 22 | 23 | ##### 监听上传进度 24 | 1. 上传进度则构建一个`UpProgressCallback`实例,并通过`tag(Class, T)`方法添加到`Request.Builder`中。 25 | 2. 在上传文件或请求网络时,本地数据传输到服务器时会调用该回调实例的`update`方法,参数是一个`float`值,可通过`(progress * 100).toInt()`来得到下载进度的百分比。 26 | 3. 注意:`update`方法运行在子线程中(与`Interceptor.intercept`方法的调用线程一致)。 27 | ```kotlin 28 | val request = Request.Builder() 29 | .tag(UpProgressCallback::class.java, UpProgressCallback { progress -> 30 | val percent = (progress * 100).toInt() 31 | TODO("处理上传进度监听") 32 | }) 33 | ... 34 | ``` 35 | 36 | ### `OkHttpClient`的使用方法 37 | 38 | #### `Retrofit`的安装方法 39 | * 参考`OkHttpClient`的安装方法,向`OkHttpClient`中添加[`ProgressInterceptor`](https://github.com/xiazunyang/http/blob/master/http/src/main/kotlin/cn/numeron/okhttp/file/ProgressInterceptor.kt)监听器,并添加到`Retrofit`中。 40 | 41 | ##### 监听下载进度 42 | 1. 在声明的接口中添加`DlProgressCallback`类型的参数,并标记`@Tag`注解。 43 | 2. 在调用该接口时,创建`DlProgressCallback`实例,并作为参数传递给该接口中即可。 44 | ```kotlin 45 | /** 46 | * url 文件的下载地址 47 | * callback 下载进度的监听接口 48 | * */ 49 | @GET 50 | @Streaming 51 | suspend fun download(@Url url: String, @Tag callback: DlProgressCallback): Call 52 | ``` 53 | 54 | ##### 监听上传进度 55 | 1. 在声明的接口中添加`UpProgressCallback`类型的参数,并标记`@Tag`注解。 56 | 2. 在调用该接口时,创建`UpProgressCallback`实例,并作为参数传递给该接口中即可。 57 | 58 | ```kotlin 59 | @POST 60 | @Streaming 61 | suspend fun upload(@Url url: String, @Tag callback: UpProgressCallback): Call 62 | ``` 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### http开源工具包 2 | 3 | 适用于`OkHttp`以及`Retrofit`的一些开源工具,包括文件的上传与下载的进度回调、`Oauth2`的`token`管理、请求与响应记录的日志输出等。 4 | 5 | #### 安装方法 6 | 当前最新版本号:[![](https://jitpack.io/v/cn.numeron/http.svg)](https://jitpack.io/#cn.numeron/http) 7 | 8 | 1. 在你的android工程的根目录下的build.gradle文件中的适当的位置添加以下代码: 9 | ```groovy 10 | allprojects { 11 | repositories { 12 | maven { url 'https://jitpack.io' } 13 | } 14 | } 15 | ``` 16 | 2. 在你的android工程中对应的android模块的build.gradle文件中的适当位置添加以下代码: 17 | ```groovy 18 | implementation 'cn.numeron:http:latest_version' 19 | ``` 20 | 21 | #### 进度回调 22 | * 通过`OkHttp`的拦截器实现的下载、上传进度监听功能 23 | * 支持`OkHttp`和`Retrofit` 24 | 25 | [点击此处](https://github.com/xiazunyang/http/blob/master/PROGRESS.md) 查看文档 26 | 27 | #### 文件上传/下载 28 | * 通过`OkHttp`的拦截器实现的下载、上传进度监听功能 29 | * 同时支持断点续传 30 | * 无需IO操作过程 31 | * 支持`OkHttp`和`Retrofit` 32 | 33 | [点击此处](https://github.com/xiazunyang/http/blob/master/FILE.md) 查看文档 34 | 35 | #### Oauth授权管理 36 | * 适用于`Oauth2`授权的APP端token管理工具 37 | * 可添加其它请求头 38 | * 可在服务端返回401响应码时尝试刷新token 39 | 40 | [点击此处](https://github.com/xiazunyang/http/blob/master/OAUTH.md) 查看文档 41 | 42 | #### 网络连接失败重试拦截器 43 | * 可在网络连接超时、网络不稳定导致的错误时,重新发起连接请求。 44 | * 自定义重试次数。 45 | * 使用方法:在构建`OkHttpClient`实例时,添加`RetryInterceptor`实例即可。 46 | 47 | #### Http请求响应日志输出工具 48 | * 其实就是`HttpLoggingInterceptor`,但是解决了在传输非文本数据时,日志输出乱码的问题。 49 | * 解决了`HttpLoggingInterceptor`导致的上传、下载文件时无法触发回调的问题。 50 | * 使用方法:在构建`OkHttpClient`实例时,添加`TextLogInterceptor`实例即可。 51 | 52 | ### Retrofit 动态Url、Port方案 53 | * 使用`Port`或`Url`注解为Api指定访问端口或地址。 54 | * 在初始化`OkHttpClient`时,添加`DynamicUrlInterceptor`拦截器,并放在靠前的位置。 55 | * 示例: 56 | ```kotlin 57 | /** 此接口下所有的方法均通过指定的url地址访问,优先级低于方法上的注解 */ 58 | @Url("http://192.168.1.111:8081/") 59 | interface LoginApi { 60 | 61 | /** 指定此方法在调用时,访问服务器的8080端口 */ 62 | @Port(8080) 63 | @POST("api/user/login") 64 | suspend fun login(@Body payload: LoginPayload): LoginResponse 65 | 66 | /** 指定此方法在调用时,访问指定url地址 */ 67 | @Url("http://192.168.1.111:8081/") 68 | @POST("api/user/login") 69 | suspend fun logout(@Body payload: LoginPayload): LogoutResponse 70 | 71 | } 72 | ``` 73 | 74 | ### Retrofit 动态定制请求超时方案 75 | * 使用`RequestTimeout`注解为Api指定请求超时时间,可标记在方法和接口上。 76 | * 在初始化`OkHttpClient`时,添加`DynamicTimeoutInterceptor`拦截器。 77 | * 示例: 78 | ```kotlin 79 | interface LoginApi { 80 | 81 | /** 此请求在访问API时,读取、写入、连接的超时时间均为8秒 */ 82 | @RequestTimeout(value = 8, unit = TimeUnit.SECONDS) 83 | @POST("api/user/login") 84 | suspend fun login(@Body payload: LoginPayload): LoginResponse 85 | 86 | } 87 | ``` 88 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | maven("https://maven.aliyun.com/repository/google") 4 | maven("https://maven.aliyun.com/repository/jcenter") 5 | } 6 | dependencies { 7 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10") 8 | classpath("com.github.dcendents:android-maven-gradle-plugin:2.1") 9 | } 10 | } 11 | 12 | subprojects { 13 | repositories { 14 | maven("https://maven.aliyun.com/repository/google") 15 | maven("https://maven.aliyun.com/repository/jcenter") 16 | mavenCentral() 17 | } 18 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xiazunyang/http/422633e1ce250949674c0217859f5667a57ce395/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Oct 19 17:32:40 CST 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /http/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | id("kotlin") 5 | id("com.github.dcendents.android-maven") 6 | } 7 | 8 | group = "cn.numeron" 9 | version = "1.0.10" 10 | 11 | tasks.withType { 12 | kotlinOptions { 13 | jvmTarget = "1.8" 14 | } 15 | } 16 | 17 | dependencies { 18 | api("com.j256.simplemagic:simplemagic:1.17") 19 | compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) 20 | compileOnly("com.squareup.retrofit2:retrofit:2.9.0") 21 | compileOnly("com.squareup.okhttp3:okhttp:4.9.1") 22 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/InternalUtil.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp 2 | 3 | import okhttp3.Headers 4 | import okhttp3.HttpUrl 5 | import okhttp3.Request 6 | import java.io.UnsupportedEncodingException 7 | import java.net.URLDecoder 8 | 9 | /** 从请求头中获取文件名 */ 10 | fun Headers.getFileName(): String? { 11 | val contentDisposition = get("Content-Disposition") ?: return null 12 | val properties = contentDisposition 13 | .splitToList(';') 14 | .associate { 15 | it.splitToList('=').toPair() 16 | } 17 | val encodedFilename = properties["filename*"] 18 | if (!encodedFilename.isNullOrEmpty()) { 19 | var (coding, fileName) = encodedFilename.splitToList('\'').toPair() 20 | if (fileName == null) { 21 | fileName = coding 22 | coding = "utf-8" 23 | } 24 | try { 25 | return URLDecoder.decode(fileName, coding) 26 | } catch (_: UnsupportedEncodingException) { 27 | } 28 | } 29 | val filename = properties["filename"] 30 | if (!filename.isNullOrEmpty()) { 31 | return filename 32 | } 33 | return null 34 | } 35 | 36 | internal fun List.toPair(): Pair { 37 | return get(0) to getOrNull(1) 38 | } 39 | 40 | internal fun String.splitToList(char: Char): List { 41 | return split(char).map(String::trim).filter(String::isNotEmpty) 42 | } 43 | 44 | /** 45 | * 从下载地址中获取文件名称 46 | * 优先用filename参数的值作为文件名 47 | * 其次用name参数的值作为文件名 48 | * 拿不到则用下载地址的最后一位的pathSegment 49 | * */ 50 | internal fun HttpUrl.getFileName(): String { 51 | return queryParameter("filename") ?: queryParameter("name") ?: pathSegments.last() 52 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/RetryInterceptor.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Request 5 | import okhttp3.Response 6 | import java.io.IOException 7 | 8 | class RetryInterceptor(private val retryCount: Int = 2) : Interceptor { 9 | 10 | override fun intercept(chain: Interceptor.Chain): Response { 11 | return try { 12 | chain.proceed(chain.request()) 13 | } catch (exception: IOException) { 14 | if (isAllowRetry(chain.request())) { 15 | retry(0, exception, chain) 16 | } else throw exception 17 | } 18 | } 19 | 20 | /** 是否是GET请求 */ 21 | private fun isAllowRetry(request: Request): Boolean { 22 | if (request.method == "GET") { 23 | return true 24 | } 25 | return request.url.pathSegments.any(::isGetUrl) 26 | } 27 | 28 | /** 是否是获取数据的url */ 29 | private fun isGetUrl(segment: String) = segment.startsWith("get") 30 | 31 | private fun retry(count: Int, exception: IOException, chain: Interceptor.Chain): Response { 32 | return if (count < retryCount) { 33 | try { 34 | chain.proceed(chain.request()) 35 | } catch (exception: IOException) { 36 | retry(count + 1, exception, chain) 37 | } 38 | } else throw exception 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/file/BreakpointResumeInterceptor.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.file 2 | 3 | import cn.numeron.okhttp.getFileName 4 | import com.j256.simplemagic.ContentType 5 | import okhttp3.Interceptor 6 | import okhttp3.Request 7 | import okhttp3.Response 8 | import okhttp3.ResponseBody 9 | import okhttp3.internal.closeQuietly 10 | import java.io.File 11 | import java.io.RandomAccessFile 12 | 13 | class BreakpointResumeInterceptor : Interceptor { 14 | 15 | override fun intercept(chain: Interceptor.Chain): Response { 16 | //获取原始请求 17 | var request = chain.request() 18 | //取出文件参数 19 | val fileOrDir = request.tag(File::class.java) 20 | //获取原请求 21 | var response = chain.proceed(request) 22 | if (fileOrDir == null) { 23 | //如果没有文件参数,则直接返回该响应 24 | return response 25 | } 26 | //获取响应体的信息 27 | var responseBody = response.body!! 28 | val contentLength = responseBody.contentLength() 29 | val contentType = responseBody.contentType() 30 | //判断要保存到哪个位置 31 | val file = getStoredFile(fileOrDir, response, request) 32 | //检测、创建存放文件夹 33 | val parentDirectory = file.parentFile 34 | if (parentDirectory != null && !parentDirectory.exists()) { 35 | parentDirectory.mkdirs() 36 | } 37 | 38 | var existLength = file.length() 39 | //如果文件存在,并且与要下载的文件一致,则直接返回 40 | if (contentLength > 0 && existLength == contentLength) { 41 | val fileResponseBody = FileResponseBody(file, contentType) 42 | return response.newBuilder().body(fileResponseBody).build() 43 | } 44 | 45 | //如果未能获取到contentLength,或者已存在的文件大于contentLength 46 | if (contentLength == -1L || existLength > contentLength) { 47 | //处理文件名重复的错误文件 48 | if (file.exists()) { 49 | file.delete() 50 | } 51 | //因为已经已删除,所以要将此变量置为0 52 | existLength = 0 53 | } 54 | //如果文件已存在一部分,则重新发起请求,获取其余数据 55 | if (existLength > 0) { 56 | //获取剩余数据的请求体 57 | request = request.newBuilder() 58 | .removeHeader("range") 59 | .addHeader("range", "bytes=${existLength}-") 60 | .build() 61 | response.closeQuietly() 62 | response = chain.proceed(request) 63 | responseBody = response.body!! 64 | if (responseBody.contentLength() == contentLength) { 65 | // 如果剩余数据的大小与原大小相同,则说明服务器不支持Range参数,则忽略掉已存在的部分 66 | responseBody.source().skip(existLength) 67 | } 68 | if (responseBody is ProgressResponseBody) { 69 | //把已有的部分,算作已下载的进度,以处理正确的进度 70 | responseBody.setExistLength(existLength) 71 | } 72 | } 73 | 74 | //将请求体中的数据定入到文件中 75 | responseBody.writeTo(file, existLength) 76 | 77 | //写完文件数据后,构建一个新的回调并返回 78 | val fileResponseBody = FileResponseBody(file, contentType) 79 | return response.newBuilder().body(fileResponseBody).build() 80 | } 81 | 82 | /** 83 | * 使用RandomAccessFile将数据写入到文件 84 | */ 85 | private fun ResponseBody.writeTo(file: File, existLength: Long) { 86 | //使用RandomAccessFile将数据写入到文件 87 | val outputFile = RandomAccessFile(file, "rws") 88 | if (existLength > 0) { 89 | outputFile.seek(existLength) 90 | } 91 | //执行写入操作 92 | val buffer = ByteArray(DEFAULT_BUFFER_SIZE) 93 | var readLength = source().read(buffer) 94 | while (readLength > 0) { 95 | outputFile.write(buffer, 0, readLength) 96 | readLength = source().read(buffer) 97 | } 98 | outputFile.closeQuietly() 99 | closeQuietly() 100 | } 101 | 102 | /** 103 | * 根据[fileOrDir]的类型、响应信息以及请求信息中判断文件名及保存位置 104 | */ 105 | private fun getStoredFile(fileOrDir: File, response: Response, request: Request): File { 106 | return if (fileOrDir.isFile || !fileOrDir.exists() && fileOrDir.extension.isNotEmpty()) { 107 | //如果是一个文件,或者文件不存在并且有扩展名,则将其作为保存数据的文件 108 | fileOrDir 109 | } else { 110 | //否则就是存放的目录,获取文件名并在该目录下创建文件 111 | var fileName = response.headers.getFileName() ?: request.url.getFileName() 112 | var extension: String? = fileName.substringAfterLast('.', "") 113 | val hasNotExtension = extension.isNullOrEmpty() 114 | if (extension.isNullOrEmpty()) { 115 | //如果获取到的文件名没有扩展名,则尝试通过Content-Type的内容推断出扩展名 116 | val mimeType = response.header("Content-Type") 117 | if (!mimeType.isNullOrEmpty()) { 118 | val contentType = ContentType.fromMimeType(mimeType) 119 | extension = contentType.fileExtensions.firstOrNull() 120 | } 121 | } 122 | if (hasNotExtension && !extension.isNullOrEmpty()) { 123 | fileName += ".$extension" 124 | } 125 | File(fileOrDir, fileName) 126 | } 127 | } 128 | 129 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/file/ContinuableUpProgressCallback.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.file 2 | 3 | /** 4 | * 当上传使用断点伟传时,可使用此对象告知进度回调已经上传了多少数据 5 | * @property existLength Long 已经上传的数据长度 6 | */ 7 | class ContinuableUpProgressCallback( 8 | 9 | /** 10 | * 表示已提交的文件长度 11 | */ 12 | val existLength: Long, 13 | 14 | /** 15 | * 进度回调 16 | */ 17 | callback: UpProgressCallback 18 | 19 | ) : UpProgressCallback by callback -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/file/DlProgressCallback.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.file 2 | 3 | /** 4 | * 下载进度回调 5 | */ 6 | fun interface DlProgressCallback { 7 | /** progress是进度的百分比,是从0到1的浮点数值 */ 8 | fun update(progress: Float) 9 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/file/FileResponseBody.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.file 2 | 3 | import okhttp3.MediaType 4 | import okhttp3.ResponseBody 5 | import okio.BufferedSource 6 | import okio.buffer 7 | import okio.source 8 | import java.io.File 9 | 10 | class FileResponseBody(val file: File, private val contentType: MediaType?) : ResponseBody() { 11 | 12 | override fun contentLength(): Long = file.length() 13 | 14 | override fun source(): BufferedSource = file.source().buffer() 15 | 16 | override fun contentType(): MediaType? = contentType 17 | 18 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/file/ProgressInterceptor.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.file 2 | 3 | import okhttp3.* 4 | import java.io.File 5 | 6 | /** 7 | * 用于支持上传或下载(支持断点续传)功能、并且支持进度回调的拦截器 8 | * 适用于OkHttp以及Retrofit。 9 | * 只需要在构造Request实例时: 10 | * 通过tag方法添加[UpProgressCallback]接口的实例以支持上传进度回调 11 | * 通过tag方法添加[DlProgressCallback]接口的实例以支持下载进度回调 12 | * 任意http请求均可添加进度回调,非下载、上传文件时也可以。 13 | * ``` 14 | * Request.Builder() 15 | * .tag(DlProgressCallback::class.java, DlProgressCallback { progress: Float -> 16 | * //处理下载进度 17 | * }) 18 | * .tag(UpProgressCallback::class.java, UpProgressCallback { progress: Float -> 19 | * //处理上传进度 20 | * } 21 | * ... 22 | * ``` 23 | * 24 | * 如果需要断点续传,需要通过tag方法添加[File]类型的实例来触发断点续传逻辑 25 | * 断点续传功能遵循以下逻辑: 26 | * 当[File]实例是文件时,会将下载的数据写入到该文件 27 | * 当[File]实例是文件夹时,会自动从响应信息中获取文件名,并在该[File]实例的路径下创建新的文件 28 | * 当文件存在时,会根据该文件的大小判断是否是符合要求的文件,如果符合则不重复下载,直接返回该数据 29 | * 当文件数据不符合响应中的数据时,会重新下载全新的数据,并覆盖原文件的数据 30 | * 当文件体积小于响应中的大小时,会从剩余的部分(响应大小-文件大小)开始继续下载,并续写到文件 31 | * 使用此文件下载文件后,在获取到[Response]实例后,不需要再对[ResponseBody]的数据进行操作 32 | * 可将[ResponseBody]强转为[FileResponseBody],然后从中取出[File]实例既是下载完成的文件。 33 | * 34 | */ 35 | class ProgressInterceptor : Interceptor { 36 | 37 | override fun intercept(chain: Interceptor.Chain): Response { 38 | //获取原始请求 39 | var request = chain.request() 40 | //取出进度回调参数 41 | val upProgressCallback = request.tag(UpProgressCallback::class.java) 42 | val dlProgressCallback = request.tag(DlProgressCallback::class.java) 43 | if (upProgressCallback != null && request.body != null) { 44 | //如果有上传进度回调,并且有请求体,则构建新的请求体实例,以监听进度回调 45 | val progressRequestBody = ProgressRequestBody(request.body!!, upProgressCallback) 46 | if (upProgressCallback is ContinuableUpProgressCallback) { 47 | //如果回调支持断点续传,则设置已上传的数据长度 48 | val existLength = upProgressCallback.existLength 49 | progressRequestBody.setExistLength(existLength) 50 | } 51 | request = request.newBuilder() 52 | .method(request.method, progressRequestBody) 53 | .build() 54 | } 55 | //获取原始响应 56 | var response = chain.proceed(request) 57 | if (dlProgressCallback != null && response.body != null) { 58 | //如果有下载进度回调,并且有响应体,则构建新的响应体以监听进度回调 59 | val progressResponseBody = ProgressResponseBody(response.body!!, dlProgressCallback) 60 | response = response.newBuilder() 61 | .body(progressResponseBody) 62 | .build() 63 | } 64 | return response 65 | } 66 | 67 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/file/ProgressRequestBody.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.file 2 | 3 | import okhttp3.MediaType 4 | import okhttp3.RequestBody 5 | import okio.* 6 | 7 | class ProgressRequestBody( 8 | private val delegate: RequestBody, 9 | private val callback: UpProgressCallback, 10 | ) : RequestBody() { 11 | 12 | private var writtenLength = 0L 13 | 14 | private var contentLength = contentLength().toFloat() 15 | 16 | override fun isDuplex(): Boolean = delegate.isDuplex() 17 | 18 | override fun isOneShot(): Boolean = delegate.isOneShot() 19 | 20 | override fun contentLength(): Long = delegate.contentLength() 21 | 22 | override fun contentType(): MediaType? = delegate.contentType() 23 | 24 | override fun writeTo(sink: BufferedSink) { 25 | object : ForwardingSink(sink) { 26 | override fun write(source: Buffer, byteCount: Long) { 27 | super.write(source, byteCount) 28 | writtenLength += byteCount 29 | callback.update(writtenLength / contentLength) 30 | } 31 | }.buffer().let { 32 | delegate.writeTo(it) 33 | it.close() 34 | } 35 | } 36 | 37 | fun setExistLength(existLength: Long) { 38 | writtenLength = existLength 39 | contentLength += existLength 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/file/ProgressResponseBody.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.file 2 | 3 | import okhttp3.MediaType 4 | import okhttp3.ResponseBody 5 | import okio.Buffer 6 | import okio.BufferedSource 7 | import okio.ForwardingSource 8 | import okio.buffer 9 | 10 | class ProgressResponseBody( 11 | private val delegate: ResponseBody, 12 | private val callback: DlProgressCallback 13 | ) : ResponseBody() { 14 | 15 | /** 16 | * 当前已处理的数据大小 17 | */ 18 | private var readLength = 0L 19 | 20 | private var contentLength = contentLength().toFloat() 21 | 22 | private val source = object : ForwardingSource(delegate.source()) { 23 | override fun read(sink: Buffer, byteCount: Long): Long { 24 | val length = super.read(sink, byteCount) 25 | if (contentLength > 0 && length > 0) { 26 | readLength += length 27 | callback.update(readLength / contentLength) 28 | } 29 | return length 30 | } 31 | }.buffer() 32 | 33 | override fun contentLength(): Long = delegate.contentLength() 34 | 35 | override fun contentType(): MediaType? = delegate.contentType() 36 | 37 | override fun source(): BufferedSource = source 38 | 39 | /** 40 | * 设置已存在的文件长度,以正确的获取下载进度 41 | */ 42 | internal fun setExistLength(existLength: Long) { 43 | readLength = existLength 44 | contentLength += existLength 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/file/UpProgressCallback.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.file 2 | 3 | /** 4 | * 上传进度回调 5 | */ 6 | fun interface UpProgressCallback { 7 | /** progress是进度的百分比,是从0到1的浮点数值 */ 8 | fun update(progress: Float) 9 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/log/LogLevel.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.log 2 | 3 | enum class LogLevel { 4 | 5 | /** No logs. */ 6 | NONE, 7 | 8 | /** 9 | * Logs request and response lines. 10 | * 11 | * Example: 12 | * ``` 13 | * --> POST /greeting http/1.1 (3-byte body) 14 | * 15 | * <-- 200 OK (22ms, 6-byte body) 16 | * ``` 17 | */ 18 | BASIC, 19 | 20 | /** 21 | * Logs request and response lines and their respective headers. 22 | * 23 | * Example: 24 | * ``` 25 | * --> POST /greeting http/1.1 26 | * Host: example.com 27 | * Content-Type: plain/text 28 | * Content-Length: 3 29 | * --> END POST 30 | * 31 | * <-- 200 OK (22ms) 32 | * Content-Type: plain/text 33 | * Content-Length: 6 34 | * <-- END HTTP 35 | * ``` 36 | */ 37 | HEADERS, 38 | 39 | /** 40 | * Logs request and response lines and their respective headers and bodies (if present). 41 | * 42 | * Example: 43 | * ``` 44 | * --> POST /greeting http/1.1 45 | * Host: example.com 46 | * Content-Type: plain/text 47 | * Content-Length: 3 48 | * 49 | * Hi? 50 | * --> END POST 51 | * 52 | * <-- 200 OK (22ms) 53 | * Content-Type: plain/text 54 | * Content-Length: 6 55 | * 56 | * Hello! 57 | * <-- END HTTP 58 | * ``` 59 | */ 60 | BODY; 61 | 62 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/log/TextLogInterceptor.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.log 2 | 3 | import okhttp3.Headers 4 | import okhttp3.Interceptor 5 | import okhttp3.MediaType 6 | import okhttp3.Response 7 | import okhttp3.internal.http.promisesBody 8 | import okhttp3.internal.platform.Platform 9 | import okio.Buffer 10 | import okio.GzipSource 11 | import java.io.EOFException 12 | import java.io.IOException 13 | import java.nio.charset.Charset 14 | import java.nio.charset.StandardCharsets.UTF_8 15 | import java.util.concurrent.TimeUnit 16 | 17 | /** 18 | * 从[okhttp3.logging.HttpLoggingInterceptor]抄来的代码 19 | * 主要改变是当传输的数据是非文本数据时,不会显示请求体与响应体。 20 | * 以解决下载文件时遇到的问题。 21 | * */ 22 | class TextLogInterceptor @JvmOverloads constructor( 23 | private val logger: Logger = Logger.DEFAULT 24 | ) : Interceptor { 25 | 26 | @Volatile 27 | private var headersToRedact = emptySet() 28 | 29 | @Volatile 30 | @set:JvmName("requestLevel") 31 | var requestLevel = LogLevel.NONE 32 | 33 | @Volatile 34 | @set:JvmName("responseLevel") 35 | var responseLevel = LogLevel.NONE 36 | 37 | fun interface Logger { 38 | fun log(message: String) 39 | 40 | companion object { 41 | /** A [Logger] defaults output appropriate for the current platform. */ 42 | @JvmField 43 | val DEFAULT: Logger = object : Logger { 44 | override fun log(message: String) { 45 | Platform.get().log(message, Platform.INFO, null) 46 | } 47 | } 48 | } 49 | } 50 | 51 | fun setRequestLevel(level: LogLevel): TextLogInterceptor = apply { 52 | this.requestLevel = level 53 | } 54 | 55 | fun setResponseLevel(level: LogLevel): TextLogInterceptor = apply { 56 | this.responseLevel = level 57 | } 58 | 59 | @Throws(IOException::class) 60 | override fun intercept(chain: Interceptor.Chain): Response { 61 | val requestLevel = this.requestLevel 62 | val responseLevel = this.responseLevel 63 | 64 | val request = chain.request() 65 | if (requestLevel == LogLevel.NONE && responseLevel == LogLevel.NONE) { 66 | return chain.proceed(request) 67 | } 68 | 69 | val logRequestBody = requestLevel == LogLevel.BODY 70 | val logRequestHeaders = logRequestBody || requestLevel == LogLevel.HEADERS 71 | 72 | val requestBody = request.body 73 | 74 | val isTextRequestBody = requestBody?.contentType().isTextContent() 75 | 76 | val connection = chain.connection() 77 | var requestStartMessage = 78 | ("--> ${request.method} ${request.url}${if (connection != null) " " + connection.protocol() else ""}") 79 | if (!logRequestHeaders && requestBody != null) { 80 | requestStartMessage += " (${requestBody.contentLength()}-byte body)" 81 | } 82 | logger.log(requestStartMessage) 83 | 84 | if (logRequestHeaders) { 85 | val headers = request.headers 86 | 87 | if (requestBody != null) { 88 | // Request body headers are only present when installed as a network interceptor. When not 89 | // already present, force them to be included (if available) so their values are known. 90 | requestBody.contentType()?.let { 91 | if (headers["Content-Type"] == null) { 92 | logger.log("Content-Type: $it") 93 | } 94 | } 95 | if (requestBody.contentLength() != -1L) { 96 | if (headers["Content-Length"] == null) { 97 | logger.log("Content-Length: ${requestBody.contentLength()}") 98 | } 99 | } 100 | } 101 | 102 | for (i in 0 until headers.size) { 103 | logHeader(headers, i) 104 | } 105 | 106 | if (!logRequestBody || requestBody == null) { 107 | logger.log("--> END ${request.method}") 108 | } else if (bodyHasUnknownEncoding(request.headers)) { 109 | logger.log("--> END ${request.method} (encoded body omitted)") 110 | } else if (requestBody.isDuplex()) { 111 | logger.log("--> END ${request.method} (duplex request body omitted)") 112 | } else if (!isTextRequestBody) { 113 | logger.log("--> END ${request.method} (non text request body omitted.)") 114 | } else { 115 | val buffer = Buffer() 116 | requestBody.writeTo(buffer) 117 | 118 | val contentType = requestBody.contentType() 119 | val charset: Charset = contentType?.charset(UTF_8) ?: UTF_8 120 | 121 | logger.log("") 122 | if (buffer.isProbablyUtf8()) { 123 | logger.log(buffer.readString(charset)) 124 | logger.log("--> END ${request.method} (${requestBody.contentLength()}-byte body)") 125 | } else { 126 | logger.log( 127 | "--> END ${request.method} (binary ${requestBody.contentLength()}-byte body omitted)" 128 | ) 129 | } 130 | } 131 | } 132 | 133 | val startNs = System.nanoTime() 134 | val response: Response 135 | try { 136 | response = chain.proceed(request) 137 | } catch (e: Exception) { 138 | logger.log("<-- HTTP FAILED: $e") 139 | throw e 140 | } 141 | 142 | val logResponseBody = responseLevel == LogLevel.BODY 143 | val logResponseHeaders = logResponseBody || responseLevel == LogLevel.HEADERS 144 | 145 | val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs) 146 | 147 | val responseBody = response.body!! 148 | val contentLength = responseBody.contentLength() 149 | val bodySize = if (contentLength != -1L) "$contentLength-byte" else "unknown-length" 150 | logger.log( 151 | "<-- ${response.code}${if (response.message.isEmpty()) "" else ' ' + response.message} ${response.request.url} (${tookMs}ms${if (!logRequestHeaders) ", $bodySize body" else ""})" 152 | ) 153 | 154 | val isTextResponseBody = responseBody.contentType().isTextContent() 155 | 156 | if (logResponseHeaders) { 157 | val headers = response.headers 158 | for (i in 0 until headers.size) { 159 | logHeader(headers, i) 160 | } 161 | 162 | if (!logResponseBody || !response.promisesBody()) { 163 | logger.log("<-- END HTTP") 164 | } else if (bodyHasUnknownEncoding(response.headers)) { 165 | logger.log("<-- END HTTP (encoded body omitted)") 166 | } else if (!isTextResponseBody) { 167 | logger.log("<-- END HTTP (non text response body omitted.)") 168 | } else { 169 | val source = responseBody.source() 170 | source.request(Long.MAX_VALUE) // Buffer the entire body. 171 | var buffer = source.buffer 172 | 173 | var gzippedLength: Long? = null 174 | if ("gzip".equals(headers["Content-Encoding"], ignoreCase = true)) { 175 | gzippedLength = buffer.size 176 | GzipSource(buffer.clone()).use { gzippedResponseBody -> 177 | buffer = Buffer() 178 | buffer.writeAll(gzippedResponseBody) 179 | } 180 | } 181 | 182 | val contentType = responseBody.contentType() 183 | val charset: Charset = contentType?.charset(UTF_8) ?: UTF_8 184 | 185 | if (!buffer.isProbablyUtf8()) { 186 | logger.log("") 187 | logger.log("<-- END HTTP (binary ${buffer.size}-byte body omitted)") 188 | return response 189 | } 190 | 191 | if (contentLength != 0L) { 192 | logger.log("") 193 | logger.log(buffer.clone().readString(charset)) 194 | } 195 | 196 | if (gzippedLength != null) { 197 | logger.log("<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)") 198 | } else { 199 | logger.log("<-- END HTTP (${buffer.size}-byte body)") 200 | } 201 | } 202 | } 203 | 204 | return response 205 | } 206 | 207 | private fun logHeader(headers: Headers, i: Int) { 208 | val value = if (headers.name(i) in headersToRedact) "██" else headers.value(i) 209 | logger.log(headers.name(i) + ": " + value) 210 | } 211 | 212 | private fun bodyHasUnknownEncoding(headers: Headers): Boolean { 213 | val contentEncoding = headers["Content-Encoding"] ?: return false 214 | return !contentEncoding.equals("identity", ignoreCase = true) && 215 | !contentEncoding.equals("gzip", ignoreCase = true) 216 | } 217 | 218 | private companion object { 219 | 220 | fun MediaType?.isTextContent(): Boolean { 221 | return this?.type == "text" || this?.subtype == "json" || this?.subtype == "x-www-form-urlencoded" 222 | } 223 | 224 | fun Buffer.isProbablyUtf8(): Boolean { 225 | try { 226 | val prefix = Buffer() 227 | val byteCount = size.coerceAtMost(64) 228 | copyTo(prefix, 0, byteCount) 229 | for (i in 0 until 16) { 230 | if (prefix.exhausted()) { 231 | break 232 | } 233 | val codePoint = prefix.readUtf8CodePoint() 234 | if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) { 235 | return false 236 | } 237 | } 238 | return true 239 | } catch (_: EOFException) { 240 | return false 241 | } 242 | } 243 | } 244 | 245 | } 246 | -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/oauth/OAuthClientInterceptor.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.oauth 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Response 5 | import java.net.HttpURLConnection 6 | 7 | class OAuthClientInterceptor(private val provider: OAuthProvider) : Interceptor { 8 | 9 | override fun intercept(chain: Interceptor.Chain): Response { 10 | var request = chain.request() 11 | val headers = request.headers 12 | val requestBuilder = request.newBuilder() 13 | // 添加授权 14 | var accessToken = provider.accessToken 15 | if (!accessToken.isNullOrEmpty()) { 16 | requestBuilder 17 | .removeHeader(AUTHORIZATION) 18 | .addHeader(AUTHORIZATION, "Bearer $accessToken") 19 | } 20 | // 添加额外的请求头,不会覆盖已存在的 21 | val headersNames = headers.names() 22 | for ((name, value) in provider.headers) { 23 | if (!headersNames.contains(name)) { 24 | requestBuilder.addHeader(name, value) 25 | } 26 | } 27 | // 构建请求,发起请求 28 | request = requestBuilder.build() 29 | var response = chain.proceed(request) 30 | 31 | if (response.code == HttpURLConnection.HTTP_UNAUTHORIZED) { 32 | //如果返回了401,则尝试通过provider获取新的token 33 | accessToken = provider.refreshToken() 34 | if (!accessToken.isNullOrEmpty()) { 35 | // 将更新的token保存起来 36 | provider.accessToken = accessToken 37 | //如果获取到了新的token,则构建一个新的请求,再次发起 38 | request = request.newBuilder() 39 | .removeHeader(AUTHORIZATION) 40 | .addHeader(AUTHORIZATION, "Bearer $accessToken") 41 | .build() 42 | response.close() 43 | response = chain.proceed(request) 44 | } 45 | } 46 | return response 47 | } 48 | 49 | private companion object { 50 | 51 | private const val AUTHORIZATION = "Authorization" 52 | 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/okhttp/oauth/OAuthProvider.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.okhttp.oauth 2 | 3 | interface OAuthProvider { 4 | 5 | /** 6 | * 这个Map里面包含的数据会添加到每一个请求头中 7 | * 可将token信息添加到此map中 8 | * 此Map中与原请求中已存在的key会被忽略 9 | */ 10 | val headers: Map 11 | 12 | /** 授权Token */ 13 | var accessToken: String? 14 | 15 | /** 16 | * 刷新token 17 | * @return String? 刷新后的新token 18 | */ 19 | fun refreshToken(): String? 20 | 21 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/retrofit/DateConverterFactory.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.retrofit 2 | 3 | import retrofit2.Converter 4 | import retrofit2.Retrofit 5 | import java.lang.reflect.Type 6 | import java.text.SimpleDateFormat 7 | import java.util.* 8 | 9 | class DateConverterFactory private constructor(private val formatter: (Date) -> String) : Converter.Factory() { 10 | 11 | override fun stringConverter(type: Type, annotations: Array, retrofit: Retrofit): Converter<*, String>? { 12 | if (type == Date::class.java) { 13 | return DateConverter(formatter) 14 | } 15 | return super.stringConverter(type, annotations, retrofit) 16 | } 17 | 18 | private class DateConverter(private val formatter: (Date) -> String) : Converter { 19 | override fun convert(value: Date): String? { 20 | return try { 21 | formatter(value) 22 | } catch (e: Exception) { 23 | null 24 | } 25 | } 26 | 27 | } 28 | 29 | companion object { 30 | 31 | @JvmStatic 32 | @JvmOverloads 33 | fun create(pattern: String = "yyyy-MM-dd HH:mm:ss", local: Locale = Locale.getDefault()): DateConverterFactory { 34 | return create(SimpleDateFormat(pattern, local)) 35 | } 36 | 37 | fun create(format: SimpleDateFormat): DateConverterFactory { 38 | return create(format::format) 39 | } 40 | 41 | fun create(formatter: (Date) -> String): DateConverterFactory { 42 | return DateConverterFactory(formatter) 43 | } 44 | 45 | } 46 | 47 | } 48 | 49 | -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/retrofit/DynamicTimeoutInterceptor.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.retrofit 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Response 5 | import retrofit2.Invocation 6 | 7 | class DynamicTimeoutInterceptor : Interceptor { 8 | 9 | override fun intercept(chain: Interceptor.Chain): Response { 10 | val request = chain.request() 11 | val invocation = request.tag(Invocation::class.java) 12 | if (invocation != null) { 13 | val method = invocation.method() 14 | val requestTimeoutAnnotation = method.getAnnotation(RequestTimeout::class.java) 15 | ?: method.declaringClass.getAnnotation(RequestTimeout::class.java) 16 | if (requestTimeoutAnnotation != null) { 17 | val value = requestTimeoutAnnotation.value 18 | val unit = requestTimeoutAnnotation.unit 19 | val readValue = getScopeValue(requestTimeoutAnnotation.read, value) 20 | val writeValue = getScopeValue(requestTimeoutAnnotation.write, value) 21 | val connectValue = getScopeValue(requestTimeoutAnnotation.connect, value) 22 | 23 | var newChain: Interceptor.Chain = chain 24 | 25 | if (readValue > -1) { 26 | // 如果有设置全局超时时间或读取超时时间,则创建新的Interceptor.Chain 27 | newChain = newChain.withReadTimeout(readValue, unit) 28 | } 29 | if (writeValue > -1) { 30 | // 如果有设置全局超时时间或写入超时时间,则创建新的Interceptor.Chain 31 | newChain = newChain.withWriteTimeout(writeValue, unit) 32 | } 33 | if (connectValue > -1) { 34 | // 如果有设置全局超时时间或连接超时时间,则创建新的Interceptor.Chain 35 | newChain = newChain.withConnectTimeout(connectValue, unit) 36 | } 37 | if (newChain !== chain) { 38 | // 如果创建了新的Interceptor.Chain,则用它发起请求 39 | return newChain.proceed(request) 40 | } 41 | } 42 | } 43 | return chain.proceed(request) 44 | } 45 | 46 | private fun getScopeValue(scopeValue: Int, globalValue: Int): Int { 47 | if (scopeValue > -1) { 48 | return scopeValue 49 | } 50 | return globalValue 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/retrofit/DynamicUrlInterceptor.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.retrofit 2 | 3 | import okhttp3.HttpUrl 4 | import okhttp3.HttpUrl.Companion.toHttpUrl 5 | import okhttp3.Interceptor 6 | import okhttp3.Response 7 | import retrofit2.Invocation 8 | 9 | class DynamicUrlInterceptor : Interceptor { 10 | 11 | override fun intercept(chain: Interceptor.Chain): Response { 12 | var request = chain.request() 13 | val invocation = request.tag(Invocation::class.java) 14 | if (invocation != null) { 15 | val httpUrl = getNewHttpUrl(invocation, request.url) 16 | if (httpUrl != null) { 17 | request = request.newBuilder().url(httpUrl).build() 18 | } 19 | } 20 | return chain.proceed(request) 21 | } 22 | 23 | private fun getNewHttpUrl(invocation: Invocation, originalHttpUrl: HttpUrl): HttpUrl? { 24 | val method = invocation.method() 25 | val isSpecUrl = method.parameterAnnotations.flatten().contains(retrofit2.http.Url()) 26 | if (isSpecUrl) { 27 | // 如果API方法通过`retrofit2.http.Url`注解指定了访问地址,则不处理 28 | return null 29 | } 30 | var urlAnnotation = method.getAnnotation(Url::class.java) 31 | var portAnnotation = method.getAnnotation(Port::class.java) 32 | if (urlAnnotation == null && portAnnotation == null) { 33 | val klass = method.declaringClass 34 | urlAnnotation = klass.getAnnotation(Url::class.java) 35 | portAnnotation = klass.getAnnotation(Port::class.java) 36 | } 37 | if (urlAnnotation == null && portAnnotation == null) { 38 | // 方法和类上均没有注解,则不处理 39 | return null 40 | } 41 | val newBuilder = originalHttpUrl.newBuilder() 42 | if (urlAnnotation != null) { 43 | val httpUrl = urlAnnotation.value.toHttpUrl() 44 | newBuilder.host(httpUrl.host).port(httpUrl.port).scheme(httpUrl.scheme) 45 | } 46 | if (portAnnotation != null) { 47 | // Port和Url可同时存在,但是Url中的端口将不生效。 48 | newBuilder.port(portAnnotation.value) 49 | } 50 | return newBuilder.build() 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/retrofit/FileConverter.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.retrofit 2 | 3 | import cn.numeron.okhttp.file.FileResponseBody 4 | import okhttp3.ResponseBody 5 | import retrofit2.Converter 6 | import retrofit2.Retrofit 7 | import java.io.File 8 | import java.lang.reflect.Type 9 | 10 | /** 11 | * 当使用Retrofit下载文件时,使用此转换器后,可以在Api接口中声明File类型的返回值。 12 | */ 13 | 14 | class FileConverter : Converter { 15 | 16 | override fun convert(value: ResponseBody): File { 17 | return getFile(value) 18 | } 19 | 20 | private fun getFile(responseBody: ResponseBody): File { 21 | return if (responseBody is FileResponseBody) { 22 | responseBody.file 23 | } else try { 24 | val field = responseBody.javaClass.getDeclaredField("delegate") 25 | field.isAccessible = true 26 | val delegate = field.get(responseBody) as ResponseBody 27 | getFile(delegate) 28 | } catch (throwable: Throwable) { 29 | throw RuntimeException("响应体中没有记录文件信息!或者没有使用Tag标记File类型的参数!", throwable) 30 | } 31 | } 32 | 33 | class Factory : Converter.Factory() { 34 | 35 | override fun responseBodyConverter( 36 | type: Type, 37 | annotations: Array, 38 | retrofit: Retrofit 39 | ): Converter? { 40 | if (type == File::class.java) { 41 | return FileConverter() 42 | } 43 | return null 44 | } 45 | 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/retrofit/Port.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.retrofit 2 | 3 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class Port(val value: Int) 6 | -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/retrofit/RequestTimeout.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.retrofit 2 | 3 | import java.util.concurrent.TimeUnit 4 | 5 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) 6 | annotation class RequestTimeout( 7 | 8 | /** 9 | * 超时时间 10 | * 若[connect]、[read]、[write]属性没有值,则也适用于该属性 11 | */ 12 | val value: Int = -1, 13 | 14 | /** 连接超时时间 */ 15 | val connect: Int = -1, 16 | 17 | /** 读取超时时间 */ 18 | val read: Int = -1, 19 | 20 | /** 写入超时时间 */ 21 | val write: Int = -1, 22 | 23 | /** 超时时间单位 */ 24 | val unit: TimeUnit = TimeUnit.SECONDS, 25 | ) 26 | -------------------------------------------------------------------------------- /http/src/main/kotlin/cn/numeron/retrofit/Url.kt: -------------------------------------------------------------------------------- 1 | package cn.numeron.retrofit 2 | 3 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class Url(val value: String) 6 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "http" 2 | include(":http") --------------------------------------------------------------------------------