feat: add debug network logging and project guide

This commit is contained in:
2026-08-04 17:29:44 +08:00
parent de7122611d
commit 92dbd59c23
6 changed files with 734 additions and 26 deletions
@@ -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)
}
}
@@ -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()
}