添加几个拦截器

This commit is contained in:
czp3009 2019-02-18 19:08:16 +08:00
parent ac9d86b401
commit 6acaad0445
13 changed files with 416 additions and 18 deletions

View File

@ -25,11 +25,37 @@ repositories {
//kotlin
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-stdlib-jdk8
compile group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8', version: '1.3.21'
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: '1.1.1'
}
compileKotlin {
kotlinOptions.jvmTarget = jvm_target
kotlinOptions {
jvmTarget = jvm_target
freeCompilerArgs = ["-Xjvm-default=enable"]
}
}
compileTestKotlin {
kotlinOptions.jvmTarget = jvm_target
}
//logging
dependencies {
// https://mvnrepository.com/artifact/io.github.microutils/kotlin-logging
compile group: 'io.github.microutils', name: 'kotlin-logging', version: '1.6.25'
// https://mvnrepository.com/artifact/org.slf4j/slf4j-log4j12
testCompile group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.25'
}
//http
dependencies {
// https://mvnrepository.com/artifact/com.squareup.retrofit2/retrofit
compile group: 'com.squareup.retrofit2', name: 'retrofit', version: '2.5.0'
// https://mvnrepository.com/artifact/com.squareup.retrofit2/converter-gson
compile group: 'com.squareup.retrofit2', name: 'converter-gson', version: '2.5.0'
// https://mvnrepository.com/artifact/com.jakewharton.retrofit/retrofit2-kotlin-coroutines-adapter
compile group: 'com.jakewharton.retrofit', name: 'retrofit2-kotlin-coroutines-adapter', version: '0.9.2'
// https://mvnrepository.com/artifact/com.squareup.okhttp3/logging-interceptor
compile group: 'com.squareup.okhttp3', name: 'logging-interceptor', version: '3.13.1'
}

View File

@ -0,0 +1,16 @@
package com.hiczp.bilibili.api
/**
* 各个站点的域名
*/
object BaseUrl {
/**
* passport , 用于登录
*/
val passport = "https://passport.bilibili.com"
/**
* 直播站
*/
val live = "https://api.live.bilibili.com"
}

View File

@ -1,22 +1,85 @@
package com.hiczp.bilibili.api
import com.hiczp.bilibili.api.passport.PassportAPI
import com.hiczp.bilibili.api.retrofit.interceptor.CommonHeaderInterceptor
import com.hiczp.bilibili.api.retrofit.interceptor.CommonParamInterceptor
import com.hiczp.bilibili.api.retrofit.interceptor.ParamType
import com.hiczp.bilibili.api.retrofit.interceptor.SortAndSignInterceptor
import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
import mu.KotlinLogging
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.time.Instant
private val logger = KotlinLogging.logger {}
/**
* 此类表示一个模拟的 Bilibili 客户端(Android), 所有调用由此开始.
* 多个 BilibiliClient 实例之间不共享登陆状态.
* 不能严格保证线程安全.
*
* @param billingClientProperties 客户端的固有属性, 是一种常量
* @param autoRefreshToken Token 过期时是否自动重新登录
* @param debug 是否打印请求日志
*/
class BilibiliClient(
@Suppress("MemberVisibilityCanBePrivate")
var billingClientProperties: BilibiliClientProperties = BilibiliClientProperties()
val billingClientProperties: BilibiliClientProperties = BilibiliClientProperties(),
private val autoRefreshToken: Boolean = true,
private val debug: Boolean = false
) {
/**
* 客户端被打开的时间(BilibiliClient 被实例化的时间)
*/
val clientInitTime = Instant.now().epochSecond
private val initTime = Instant.now().epochSecond
/**
* 是否已登录
*/
@Suppress("MemberVisibilityCanBePrivate")
var isLogin = false
@Suppress("SpellCheckingInspection")
val passportAPI: PassportAPI by lazy {
val okHttpClient = OkHttpClient.Builder().apply {
addInterceptor(CommonHeaderInterceptor(
"Display-ID" to {
if (isLogin) {
"${billingClientProperties.buildVersionId}-$initTime"
} else {
billingClientProperties.buildVersionId
}
},
"Buvid" to { billingClientProperties.buildVersionId },
"User-Agent" to { "Mozilla/5.0 BiliDroid/5.37.0 (bbcallen@gmail.com)" },
"Device-ID" to { billingClientProperties.hardwareId }
))
addInterceptor(CommonParamInterceptor(ParamType.FORM_URL_ENCODED,
"appkey" to { billingClientProperties.appKey },
"build" to { billingClientProperties.build },
"channel" to { billingClientProperties.channel },
"mobi_app" to { billingClientProperties.platform },
"platform" to { billingClientProperties.platform },
"ts" to { Instant.now().epochSecond.toString() }
))
addInterceptor(SortAndSignInterceptor(ParamType.FORM_URL_ENCODED, billingClientProperties.appSecret))
//debug
if (debug) {
addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
}
}.build()
Retrofit.Builder()
.baseUrl(BaseUrl.passport)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.client(okHttpClient)
.build()
.create(PassportAPI::class.java)
}
/**
* 登陆

View File

@ -2,6 +2,7 @@ package com.hiczp.bilibili.api
/**
* 客户端固有属性. 包括版本号, 密钥以及硬件编码.
* 默认值对应 5.37.0(release-b220051) 版本.
*/
class BilibiliClientProperties {
/**
@ -16,19 +17,34 @@ class BilibiliClientProperties {
var appSecret = "560c52ccd288fed045859ed18bffd973"
/**
* 硬件 ID, 尚不明确生成算法. 在每台手机上固定
* 客户端平台
*/
var platform = "android"
/**
* 客户端类型
* 此属性在旧版客户端不存在
*/
var channel = "html5_app_bili"
/**
* 硬件 ID, 尚不明确生成算法
*/
@Suppress("SpellCheckingInspection")
var hardwareId = "JxdyESFAJkcjEicQbBBsCTlbal5uX2Y"
var hardwareId = "aBRoDWAVeRhsA3FDewMzS3lLMwM"
/**
* 屏幕尺寸, 大屏手机(已经没有小屏手机了)统一为 xxhdpi
*/
var scale = "xxhdpi"
/**
* 版本号
*/
var version = "5.37.0.5370000"
/**
* 构建版本号
* 默认值对应 5.37.0(release-b220051) 版本
*/
var build = "5370000"
@ -36,15 +52,4 @@ class BilibiliClientProperties {
* 构建版本 ID, 可能是某种 Hash
*/
var buildVersionId = "XXD9E43D7A1EBB6669597650E3EE417D9E7F5"
/**
* 客户端类型(似乎只有 H5 一种类型)
* 此属性在旧版客户端不存在
*/
var channel = "html5_app_bili"
/**
* 客户端平台
*/
var platform = "android"
}

View File

@ -0,0 +1,18 @@
package com.hiczp.bilibili.api.passport
import com.hiczp.bilibili.api.passport.model.GetKeyResponse
import com.hiczp.bilibili.api.passport.model.LoginResponse
import kotlinx.coroutines.Deferred
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
@Suppress("DeferredIsResult")
interface PassportAPI {
@POST("/api/oauth2/getKey")
fun getKey(): Deferred<GetKeyResponse>
@POST("/api/v3/oauth2/login")
@FormUrlEncoded
fun login(@Field("username") username: String, @Field("password") password: String): Deferred<LoginResponse>
}

View File

@ -0,0 +1,21 @@
package com.hiczp.bilibili.api.passport.model
import com.google.gson.annotations.SerializedName
data class GetKeyResponse(
@SerializedName("code")
var code: Int, // 0
@SerializedName("message")
var message: String,
@SerializedName("data")
var `data`: Data,
@SerializedName("ts")
var ts: Int // 1550219688
) {
data class Data(
@SerializedName("hash")
var hash: String, // 93ac6f60b4789952
@SerializedName("key")
var key: String // -----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCdScM09sZJqFPX7bvmB2y6i08JbHsa0v4THafPbJN9NoaZ9Djz1LmeLkVlmWx1DwgHVW+K7LVWT5FV3johacVRuV9837+RNntEK6SE82MPcl7fA++dmW2cLlAjsIIkrX+aIvvSGCuUfcWpWFy3YVDqhuHrNDjdNcaefJIQHMW+sQIDAQAB-----END PUBLIC KEY-----
)
}

View File

@ -0,0 +1,54 @@
package com.hiczp.bilibili.api.passport.model
import com.google.gson.annotations.SerializedName
data class LoginResponse(
@SerializedName("code")
var code: Int, // 0
@SerializedName("data")
var `data`: Data,
@SerializedName("message")
var message: String,
@SerializedName("ts")
var ts: Int // 1550219689
) {
data class Data(
@SerializedName("cookie_info")
var cookieInfo: CookieInfo,
@SerializedName("sso")
var sso: List<String>,
@SerializedName("status")
var status: Int, // 0
@SerializedName("token_info")
var tokenInfo: TokenInfo
) {
data class CookieInfo(
@SerializedName("cookies")
var cookies: List<Cookie>,
@SerializedName("domains")
var domains: List<String>
) {
data class Cookie(
@SerializedName("expires")
var expires: Int, // 1552811689
@SerializedName("http_only")
var httpOnly: Boolean, // 1
@SerializedName("name")
var name: String, // SESSDATA
@SerializedName("value")
var value: String // 5ff9ba24%2C1552811689%2C04ae9421
)
}
data class TokenInfo(
@SerializedName("access_token")
var accessToken: String, // fd0303ff75a6ec6b452c28f4d8621021
@SerializedName("expires_in")
var expiresIn: Int, // 2592000
@SerializedName("mid")
var mid: Int, // 20293030
@SerializedName("refresh_token")
var refreshToken: String // 6a333ebded3c3dbdde65d136b3190d21
)
}
}

View File

@ -0,0 +1,10 @@
package com.hiczp.bilibili.api.retrofit
enum class HttpMethod {
GET,
POST,
PATCH,
PUT,
DELETE,
OPTION
}

View File

@ -0,0 +1,34 @@
package com.hiczp.bilibili.api.retrofit
import okhttp3.FormBody
inline fun FormBody.forEach(block: (Pair<String, String>) -> Unit) {
for (i in 0..size()) {
block(encodedName(i) to encodedValue(i))
}
}
fun FormBody.raw() =
StringBuilder().apply {
repeat(size()) {
if (it != 0) append('&')
append(encodedName(it))
append('=')
append(encodedValue(it))
}
}.toString()
fun FormBody.sortedRaw(): String {
val nameAndValue = ArrayList<String>()
repeat(size()) {
nameAndValue.add("${encodedName(it)}=${encodedValue(it)}")
}
return nameAndValue.sorted().joinToString(separator = "&")
}
fun FormBody.Builder.addAll(formBody: FormBody): FormBody.Builder {
formBody.forEach { (encodedName, encodedValue) ->
addEncoded(encodedName, encodedValue)
}
return this
}

View File

@ -0,0 +1,20 @@
package com.hiczp.bilibili.api.retrofit.interceptor
import okhttp3.Interceptor
import okhttp3.Response
/**
* 为请求添加公共 Header
*
* @param additionHeaders HeaderName to HeaderValueExpression
*/
class CommonHeaderInterceptor(private vararg val additionHeaders: Pair<String, () -> String>) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder().apply {
additionHeaders.forEach { (headerName, headerValueExpression) ->
addHeader(headerName, headerValueExpression())
}
}.build()
return chain.proceed(request)
}
}

View File

@ -0,0 +1,59 @@
package com.hiczp.bilibili.api.retrofit.interceptor
import com.hiczp.bilibili.api.retrofit.HttpMethod
import com.hiczp.bilibili.api.retrofit.addAll
import mu.KotlinLogging
import okhttp3.FormBody
import okhttp3.Interceptor
import okhttp3.Response
private val logger = KotlinLogging.logger {}
/**
* 为请求添加公共参数
*
* @param paramType 参数类型, Query FormUrlEncoded
* @param additionParams ParamName to ParamValueExpression
*/
class CommonParamInterceptor(
private val paramType: ParamType,
private vararg val additionParams: Pair<String, () -> String>
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (paramType == ParamType.QUERY) {
val httpUrl = request.url().newBuilder().apply {
additionParams.forEach { (paramName, paramValueExpression) ->
addQueryParameter(paramName, paramValueExpression())
}
}.build()
return chain.proceed(
request.newBuilder().url(httpUrl).build()
)
} else if (paramType == ParamType.FORM_URL_ENCODED && request.body() is FormBody ||
paramType == ParamType.FORM_URL_ENCODED && request.method() == HttpMethod.POST.toString() && request.body()?.contentType() == null
) {
//TODO 原有的 Body 为空的情况
val formBody = FormBody.Builder().apply {
//添加原有的参数
addAll(request.body() as FormBody)
//添加公共参数
additionParams.forEach { (paramName, paramValueExpression) ->
add(paramName, paramValueExpression())
}
}.build()
return chain.proceed(
request.newBuilder().post(formBody).build()
)
} else {
//如果 body 不为 FormBody 将无法添加 FormUrlEncoded 参数
logger.error {
"Impossible add FormUrlEncoded params to ${request.body()?.javaClass}. Request: " +
"${request.method()} ${request.url()}"
}
}
return chain.proceed(request)
}
}

View File

@ -0,0 +1,6 @@
package com.hiczp.bilibili.api.retrofit.interceptor
enum class ParamType {
QUERY,
FORM_URL_ENCODED
}

View File

@ -0,0 +1,66 @@
package com.hiczp.bilibili.api.retrofit.interceptor
import com.hiczp.bilibili.api.retrofit.addAll
import com.hiczp.bilibili.api.retrofit.sortedRaw
import mu.KotlinLogging
import okhttp3.FormBody
import okhttp3.Interceptor
import okhttp3.Response
import java.security.MessageDigest
private val logger = KotlinLogging.logger {}
/**
* 排序参数并添加签名
*/
class SortAndSignInterceptor(private val paramType: ParamType, private val appSecret: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (paramType == ParamType.QUERY) {
//这里认为 Query 一定不为 null
val sortedEncodedQuery = request.url().encodedQuery()!!
.split('&')
.sorted()
.joinToString(separator = "&")
val sign = calculateSign(sortedEncodedQuery, appSecret)
return chain.proceed(
request.newBuilder()
.url(request.url().newBuilder()
.encodedQuery("$sortedEncodedQuery&sign=$sign")
.build()
)
.build()
)
} else if (paramType == ParamType.FORM_URL_ENCODED && request.body() is FormBody) {
val sign = calculateSign((request.body() as FormBody).sortedRaw(), appSecret)
val formBody = FormBody.Builder().apply {
//添加原有的参数
addAll(request.body() as FormBody)
//添加 sign
addEncoded("sign", sign)
}.build()
return chain.proceed(
request.newBuilder().post(formBody).build()
)
} else {
//如果 body 不为 FormBody 将无法添加签名
logger.error {
"Impossible add sign to ${request.body()?.javaClass}. Request: " +
"${request.method()} ${request.url()}"
}
}
return chain.proceed(request)
}
/**
* 签名算法为 "$排序后的参数字符串$appSecret".md5()
*/
private fun calculateSign(string: String, appSecret: String) =
MessageDigest.getInstance("MD5")
.digest((string + appSecret).toByteArray())
.joinToString(separator = "") {
String.format("%02x", it)
}
}