feat: add debug network logging and project guide
This commit is contained in:
@@ -3,6 +3,7 @@ package com.yzx.kiosk.network.di
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.yzx.kiosk.BuildConfig
|
||||
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
|
||||
import com.yzx.kiosk.network.interceptor.RequestInterceptor
|
||||
import com.yzx.kiosk.network.interceptor.ResponseInterceptor
|
||||
import dagger.Module
|
||||
@@ -10,7 +11,6 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -41,18 +41,11 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun provideGson(): Gson = GsonBuilder().create()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level =
|
||||
if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
|
||||
private fun buildOkHttpClient(
|
||||
timeout: Long,
|
||||
requestInterceptor: RequestInterceptor,
|
||||
responseInterceptor: ResponseInterceptor,
|
||||
loggingInterceptor: HttpLoggingInterceptor?
|
||||
clientName: String,
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(timeout, TimeUnit.SECONDS)
|
||||
.writeTimeout(timeout, TimeUnit.SECONDS)
|
||||
@@ -61,7 +54,9 @@ object NetworkModule {
|
||||
.addInterceptor(requestInterceptor)
|
||||
.addInterceptor(responseInterceptor)
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) loggingInterceptor?.let { addInterceptor(it) }
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor(clientName))
|
||||
}
|
||||
}.build()
|
||||
|
||||
@Provides
|
||||
@@ -70,12 +65,11 @@ object NetworkModule {
|
||||
fun provideDefaultOkHttpClient(
|
||||
requestInterceptor: RequestInterceptor,
|
||||
responseInterceptor: ResponseInterceptor,
|
||||
loggingInterceptor: HttpLoggingInterceptor,
|
||||
): OkHttpClient = buildOkHttpClient(
|
||||
TIMEOUT_SHORT,
|
||||
requestInterceptor,
|
||||
responseInterceptor,
|
||||
loggingInterceptor,
|
||||
"api",
|
||||
)
|
||||
|
||||
@Provides
|
||||
@@ -84,7 +78,12 @@ object NetworkModule {
|
||||
fun provideUploadOkHttpClient(
|
||||
requestInterceptor: RequestInterceptor,
|
||||
responseInterceptor: ResponseInterceptor,
|
||||
): OkHttpClient = buildOkHttpClient(TIMEOUT_LONG, requestInterceptor, responseInterceptor, null)
|
||||
): OkHttpClient = buildOkHttpClient(
|
||||
TIMEOUT_LONG,
|
||||
requestInterceptor,
|
||||
responseInterceptor,
|
||||
"upload",
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@@ -98,6 +97,11 @@ object NetworkModule {
|
||||
.callTimeout(TIMEOUT_LONG, TimeUnit.SECONDS)
|
||||
.addInterceptor(requestInterceptor)
|
||||
// 注意:不添加 ResponseInterceptor,避免将大文件加载到内存
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor("download"))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
private fun buildRetrofit(
|
||||
@@ -127,4 +131,4 @@ object NetworkModule {
|
||||
gson: Gson,
|
||||
@Named(BASE_URL) baseUrl: String
|
||||
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
|
||||
}
|
||||
}
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package com.yzx.kiosk.network.interceptor
|
||||
|
||||
import android.util.Log
|
||||
import com.yzx.kiosk.BuildConfig
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Debug-only HTTP logger used by every application-owned OkHttpClient.
|
||||
*
|
||||
* Text bodies are capped to avoid flooding Logcat or buffering large payloads. Binary,
|
||||
* multipart, one-shot and duplex bodies are represented by metadata only. Response bodies are
|
||||
* inspected with [Response.peekBody], so logging never consumes the body used by business code.
|
||||
*/
|
||||
class DebugNetworkLoggingInterceptor(
|
||||
private val clientName: String = "api",
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
if (!BuildConfig.DEBUG) return chain.proceed(chain.request())
|
||||
|
||||
val request = chain.request()
|
||||
val requestId = REQUEST_SEQUENCE.incrementAndGet()
|
||||
val startedAt = System.nanoTime()
|
||||
|
||||
log("┌─ #$requestId [$clientName] --> ${request.method} ${redactedUrl(request)}")
|
||||
logHeaders(requestId, "request", request.headers)
|
||||
logRequestBody(requestId, request)
|
||||
|
||||
val response = try {
|
||||
chain.proceed(request)
|
||||
} catch (error: Throwable) {
|
||||
val durationMs = elapsedMillis(startedAt)
|
||||
log("└─ #$requestId [$clientName] <-- HTTP FAILED (${durationMs}ms): ${error.javaClass.simpleName}: ${error.message}")
|
||||
throw error
|
||||
}
|
||||
|
||||
val durationMs = elapsedMillis(startedAt)
|
||||
log("├─ #$requestId [$clientName] <-- ${response.code} ${response.message} (${durationMs}ms) ${redactedUrl(request)}")
|
||||
logHeaders(requestId, "response", response.headers)
|
||||
logResponseBody(requestId, response)
|
||||
log("└─ #$requestId [$clientName] END HTTP")
|
||||
return response
|
||||
}
|
||||
|
||||
private fun logRequestBody(requestId: Long, request: Request) {
|
||||
val body = request.body ?: run {
|
||||
log("#$requestId request body: <none>")
|
||||
return
|
||||
}
|
||||
val contentType = body.contentType()
|
||||
val contentLength = runCatching { body.contentLength() }.getOrDefault(-1L)
|
||||
val description = describeBody(contentType, contentLength)
|
||||
|
||||
if (body.isDuplex() || body.isOneShot()) {
|
||||
log("#$requestId request body: $description <one-shot/duplex body omitted>")
|
||||
return
|
||||
}
|
||||
if (!isText(contentType) || isEncoded(request.headers) || contentLength > MAX_BODY_BYTES) {
|
||||
log("#$requestId request body: $description <binary/encoded/large body omitted>")
|
||||
return
|
||||
}
|
||||
|
||||
val bodyText = runCatching {
|
||||
Buffer().use { buffer ->
|
||||
body.writeTo(buffer)
|
||||
if (buffer.size > MAX_BODY_BYTES) {
|
||||
"$description <body exceeded ${MAX_BODY_BYTES / 1024}KB while buffering; omitted>"
|
||||
} else {
|
||||
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||
"$description\n${buffer.readString(charset)}"
|
||||
}
|
||||
}
|
||||
}.getOrElse { "$description <unable to read: ${it.message}>" }
|
||||
log("#$requestId request body: $bodyText")
|
||||
}
|
||||
|
||||
private fun logResponseBody(requestId: Long, response: Response) {
|
||||
val body = response.body ?: run {
|
||||
log("#$requestId response body: <none>")
|
||||
return
|
||||
}
|
||||
val contentType = body.contentType()
|
||||
val contentLength = body.contentLength()
|
||||
val description = describeBody(contentType, contentLength)
|
||||
|
||||
if (!isText(contentType) || isEncoded(response.headers)) {
|
||||
log("#$requestId response body: $description <binary/encoded body omitted>")
|
||||
return
|
||||
}
|
||||
|
||||
val bodyText = runCatching {
|
||||
val preview = response.peekBody(MAX_BODY_BYTES)
|
||||
val text = preview.string()
|
||||
val truncated = contentLength > MAX_BODY_BYTES ||
|
||||
(contentLength == -1L && preview.contentLength() >= MAX_BODY_BYTES)
|
||||
buildString {
|
||||
append(description).append('\n').append(text)
|
||||
if (truncated) append("\n<response truncated at ${MAX_BODY_BYTES / 1024}KB>")
|
||||
}
|
||||
}.getOrElse { "$description <unable to preview: ${it.message}>" }
|
||||
log("#$requestId response body: $bodyText")
|
||||
}
|
||||
|
||||
private fun logHeaders(requestId: Long, direction: String, headers: Headers) {
|
||||
if (headers.size == 0) {
|
||||
log("#$requestId $direction headers: <none>")
|
||||
return
|
||||
}
|
||||
val text = buildString {
|
||||
append("#$requestId $direction headers:")
|
||||
for (index in 0 until headers.size) {
|
||||
val name = headers.name(index)
|
||||
val value = if (isSensitiveHeader(name)) REDACTED else headers.value(index)
|
||||
append('\n').append(name).append(": ").append(value)
|
||||
}
|
||||
}
|
||||
log(text)
|
||||
}
|
||||
|
||||
private fun redactedUrl(request: Request): String {
|
||||
val url = request.url
|
||||
val builder = url.newBuilder()
|
||||
url.queryParameterNames.forEach { name ->
|
||||
if (isSensitiveName(name)) builder.setQueryParameter(name, REDACTED)
|
||||
}
|
||||
return builder.build().toString()
|
||||
}
|
||||
|
||||
private fun isEncoded(headers: Headers): Boolean {
|
||||
val encoding = headers["Content-Encoding"] ?: return false
|
||||
return !encoding.equals("identity", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun isText(contentType: MediaType?): Boolean {
|
||||
if (contentType == null) return false
|
||||
val type = contentType.type.lowercase(Locale.US)
|
||||
val subtype = contentType.subtype.lowercase(Locale.US)
|
||||
if (type == "text") return true
|
||||
return subtype.contains("json") ||
|
||||
subtype.contains("xml") ||
|
||||
subtype.contains("html") ||
|
||||
subtype.contains("x-www-form-urlencoded") ||
|
||||
subtype.contains("javascript")
|
||||
}
|
||||
|
||||
private fun describeBody(contentType: MediaType?, contentLength: Long): String {
|
||||
val length = if (contentLength >= 0) "$contentLength bytes" else "unknown length"
|
||||
return "${contentType ?: "unknown content-type"}, $length"
|
||||
}
|
||||
|
||||
private fun isSensitiveHeader(name: String): Boolean = SENSITIVE_NAMES.contains(name.lowercase(Locale.US))
|
||||
|
||||
private fun isSensitiveName(name: String): Boolean = SENSITIVE_NAMES.contains(name.lowercase(Locale.US))
|
||||
|
||||
private fun log(message: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
if (message.isEmpty()) {
|
||||
Log.d(TAG, "")
|
||||
return
|
||||
}
|
||||
var start = 0
|
||||
while (start < message.length) {
|
||||
val end = minOf(start + LOGCAT_CHUNK_SIZE, message.length)
|
||||
Log.d(TAG, message.substring(start, end))
|
||||
start = end
|
||||
}
|
||||
}
|
||||
|
||||
private fun elapsedMillis(startedAt: Long): Long =
|
||||
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)
|
||||
|
||||
companion object {
|
||||
const val TAG = "KIOSK-NET"
|
||||
|
||||
private const val MAX_BODY_BYTES = 64L * 1024L
|
||||
private const val LOGCAT_CHUNK_SIZE = 3_500
|
||||
private const val REDACTED = "██"
|
||||
private val REQUEST_SEQUENCE = AtomicLong(0)
|
||||
private val SENSITIVE_NAMES = setOf(
|
||||
"authorization",
|
||||
"token",
|
||||
"access_token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"sn",
|
||||
"device_sn",
|
||||
"password",
|
||||
"secret",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import com.yzx.kiosk.network.model.response.FaceSearchResponse
|
||||
import com.yzx.kiosk.network.service.FaceSearchService
|
||||
import com.yzx.kiosk.App
|
||||
import com.yzx.kiosk.BuildConfig
|
||||
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
|
||||
import com.yzx.kiosk.navigation.AppNavigator
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes
|
||||
@@ -38,7 +39,6 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.io.File
|
||||
@@ -260,23 +260,20 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
|
||||
LogUtils.d(TAG, "URL: $requestUrl")
|
||||
LogUtils.d(TAG, "Headers:")
|
||||
LogUtils.d(TAG, "Authorization: $authorization")
|
||||
LogUtils.d(TAG, "Authorization: <redacted>")
|
||||
LogUtils.d(TAG, "Parameters:")
|
||||
LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)")
|
||||
LogUtils.d(TAG, "threshold: $thresholdValue")
|
||||
LogUtils.d(TAG, "index_date: $indexDate")
|
||||
LogUtils.d(TAG, "==========================================")
|
||||
|
||||
// 创建日志拦截器
|
||||
val loggingInterceptor = HttpLoggingInterceptor { message ->
|
||||
LogUtils.d(TAG, message)
|
||||
}.apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
// 创建OkHttpClient,添加日志拦截器
|
||||
// Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容
|
||||
val client = OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
// 创建Retrofit实例
|
||||
|
||||
@@ -7,7 +7,9 @@ import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import com.google.gson.Gson
|
||||
import com.yzx.kiosk.App
|
||||
import com.yzx.kiosk.BuildConfig
|
||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
|
||||
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||
import com.yzx.kiosk.network.model.response.BatchQueryResponse
|
||||
@@ -65,6 +67,11 @@ class PrivacyPrintService @Inject constructor(
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor("privacy-print"))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
// 缓存 AppState 实例,避免频繁调用 Lazy.get()
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import com.yzx.kiosk.BuildConfig
|
||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
|
||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||
import com.yzx.kiosk.ui.poster.ScenicLivePosterController
|
||||
import com.yzx.kiosk.priter.PrintStatusManager
|
||||
@@ -100,6 +101,11 @@ class WebSocketService @Inject constructor(
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.pingInterval(30, TimeUnit.SECONDS) // 自动 ping
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor("websocket"))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -971,4 +977,3 @@ sealed class UploadPhotoEvent {
|
||||
val imageIds: List<Int>
|
||||
) : UploadPhotoEvent()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user