Updated on 2026-08-14
This commit is contained in:
parent
70e40cb2a7
commit
bbb9280413
8 changed files with 294 additions and 49 deletions
|
|
@ -14,6 +14,7 @@ import com.tangem.datasource.api.markets.TangemTechMarketsApi
|
|||
import com.tangem.datasource.api.stakekit.StakeKitApi
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApiV2
|
||||
import com.tangem.datasource.local.logs.AppLogsStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.utils.*
|
||||
import com.tangem.datasource.utils.RequestHeader.AppVersionPlatformHeaders
|
||||
|
|
@ -58,12 +59,18 @@ internal object NetworkModule {
|
|||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
apiConfigsManager: ApiConfigsManager,
|
||||
appLogsStore: AppLogsStore,
|
||||
): TangemExpressApi {
|
||||
return createApi(
|
||||
id = ApiConfig.ID.Express,
|
||||
moshi = moshi,
|
||||
context = context,
|
||||
apiConfigsManager = apiConfigsManager,
|
||||
clientBuilder = {
|
||||
addInterceptor(
|
||||
NetworkLogsSaveInterceptor(appLogsStore),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -73,12 +80,18 @@ internal object NetworkModule {
|
|||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
apiConfigsManager: ApiConfigsManager,
|
||||
appLogsStore: AppLogsStore,
|
||||
): StakeKitApi {
|
||||
return createApi(
|
||||
id = ApiConfig.ID.StakeKit,
|
||||
moshi = moshi,
|
||||
context = context,
|
||||
apiConfigsManager = apiConfigsManager,
|
||||
clientBuilder = {
|
||||
addInterceptor(
|
||||
NetworkLogsSaveInterceptor(appLogsStore),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +107,7 @@ internal object NetworkModule {
|
|||
moshi = moshi,
|
||||
context = context,
|
||||
apiConfigsManager = apiConfigsManager,
|
||||
clientBuilder = { applyTimeoutAnnotations() },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +214,7 @@ internal object NetworkModule {
|
|||
moshi: Moshi,
|
||||
context: Context,
|
||||
apiConfigsManager: ApiConfigsManager,
|
||||
clientBuilder: OkHttpClient.Builder.() -> OkHttpClient.Builder = { this },
|
||||
): T {
|
||||
val environmentConfig = apiConfigsManager.getEnvironmentConfig(id)
|
||||
|
||||
|
|
@ -210,8 +225,8 @@ internal object NetworkModule {
|
|||
.client(
|
||||
OkHttpClient.Builder()
|
||||
.applyApiConfig(id, apiConfigsManager)
|
||||
.applyTimeoutAnnotations()
|
||||
.addLoggers(context)
|
||||
.clientBuilder()
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
package com.tangem.datasource.local.logs
|
||||
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.joda.time.DateTime
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Store for saving app logs
|
||||
*
|
||||
* @property appPreferencesStore app preferences store
|
||||
* @param dispatchers coroutine dispatcher provider
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
class AppLogsStore @Inject constructor(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(dispatchers.io)
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Save log [message] */
|
||||
fun saveLogMessage(message: String) {
|
||||
val newLogs = DateTime.now().millis.toString() to message
|
||||
|
||||
appPreferencesStore.editDataWithLock { preferences ->
|
||||
val savedLogs = preferences.getObjectMap<String>(PreferencesKeys.APP_LOGS_KEY)
|
||||
|
||||
preferences.setObjectMap(key = PreferencesKeys.APP_LOGS_KEY, value = savedLogs + newLogs)
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete deprecated logs if file size exceeds [maxSize] */
|
||||
fun deleteDeprecatedLogs(maxSize: Int) {
|
||||
appPreferencesStore.editDataWithLock { preferences ->
|
||||
val savedLogs = preferences.getObjectMap<String>(PreferencesKeys.APP_LOGS_KEY)
|
||||
|
||||
var sum = 0
|
||||
preferences.setObjectMap(
|
||||
key = PreferencesKeys.APP_LOGS_KEY,
|
||||
value = savedLogs.entries
|
||||
.sortedBy(Map.Entry<String, String>::key)
|
||||
.takeLastWhile {
|
||||
sum += it.value.length
|
||||
sum < maxSize
|
||||
}
|
||||
.associate { it.key to it.value },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AppPreferencesStore.editDataWithLock(
|
||||
transform: suspend AppPreferencesStore.(MutablePreferences) -> Unit,
|
||||
) {
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
editData(transform)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
package com.tangem.datasource.utils
|
||||
|
||||
import com.tangem.datasource.local.logs.AppLogsStore
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.http.promisesBody
|
||||
import okio.Buffer
|
||||
import okio.EOFException
|
||||
import okio.GzipSource
|
||||
import okio.IOException
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val JSON_INDENT_SPACES = 4
|
||||
|
||||
/**
|
||||
* Interceptor for save network requests and responses logs
|
||||
*
|
||||
* @property appLogsStore app logs store
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class NetworkLogsSaveInterceptor(
|
||||
private val appLogsStore: AppLogsStore,
|
||||
) : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
logRequestMessage(chain, request)
|
||||
|
||||
val startNs = System.nanoTime()
|
||||
val response: Response
|
||||
try {
|
||||
response = chain.proceed(request)
|
||||
} catch (e: Exception) {
|
||||
appLogsStore.saveLogMessage("<-- HTTP FAILED: $e")
|
||||
throw e
|
||||
}
|
||||
|
||||
logResponseMessage(response, startNs)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun logRequestMessage(chain: Interceptor.Chain, request: Request) {
|
||||
val connection = chain.connection()
|
||||
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
|
||||
|
||||
appLogsStore.saveLogMessage(
|
||||
"--> ${request.method} ${request.url}$connectionProtocol\n" +
|
||||
createRequestEndMessage(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createRequestEndMessage(request: Request): String {
|
||||
val requestBody = request.body
|
||||
val method = request.method
|
||||
|
||||
return if (requestBody == null) {
|
||||
"--> END $method"
|
||||
} else if (bodyHasUnknownEncoding(request.headers)) {
|
||||
"--> END $method (encoded body omitted)"
|
||||
} else if (requestBody.isDuplex()) {
|
||||
"--> END $method (duplex request body omitted)"
|
||||
} else if (requestBody.isOneShot()) {
|
||||
"--> END $method (one-shot body omitted)"
|
||||
} else {
|
||||
val buffer = Buffer()
|
||||
requestBody.writeTo(buffer)
|
||||
|
||||
val contentType = requestBody.contentType()
|
||||
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||
|
||||
if (buffer.isProbablyUtf8()) {
|
||||
val json = buffer.readString(charset).beautifyJson()
|
||||
"$json\n--> END $method (${requestBody.contentLength()}-byte body)"
|
||||
} else {
|
||||
"--> END $method (binary ${requestBody.contentLength()}-byte body omitted)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logResponseMessage(response: Response, startNs: Long) {
|
||||
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
|
||||
|
||||
val responseMessage = if (response.message.isEmpty()) "" else ' ' + response.message
|
||||
val startMessage = "<-- ${response.code}$responseMessage ${response.request.url} " +
|
||||
"(${tookMs}ms)"
|
||||
|
||||
val responseHeaders = response.headers
|
||||
val responseBody = response.body!!
|
||||
val contentLength = responseBody.contentLength()
|
||||
|
||||
val message = if (!response.promisesBody()) {
|
||||
"<-- END HTTP"
|
||||
} else if (bodyHasUnknownEncoding(response.headers)) {
|
||||
"<-- END HTTP (encoded body omitted)"
|
||||
} else {
|
||||
val source = responseBody.source()
|
||||
source.request(Long.MAX_VALUE)
|
||||
var buffer = source.buffer
|
||||
|
||||
var gzippedLength: Long? = null
|
||||
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
|
||||
gzippedLength = buffer.size
|
||||
GzipSource(buffer.clone()).use { gzippedResponseBody ->
|
||||
buffer = Buffer()
|
||||
buffer.writeAll(gzippedResponseBody)
|
||||
}
|
||||
}
|
||||
|
||||
val contentType = responseBody.contentType()
|
||||
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||
|
||||
if (!buffer.isProbablyUtf8()) {
|
||||
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
|
||||
} else {
|
||||
val json = if (contentLength != 0L) {
|
||||
buffer.clone().readString(charset).beautifyJson()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val end = if (gzippedLength != null) {
|
||||
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
|
||||
} else {
|
||||
"<-- END HTTP (${buffer.size}-byte body)"
|
||||
}
|
||||
|
||||
"$json\n$end"
|
||||
}
|
||||
}
|
||||
|
||||
appLogsStore.saveLogMessage(startMessage + "\n" + message)
|
||||
}
|
||||
|
||||
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
|
||||
val contentEncoding = headers["Content-Encoding"] ?: return false
|
||||
return !contentEncoding.equals("identity", ignoreCase = true) &&
|
||||
!contentEncoding.equals("gzip", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun Buffer.isProbablyUtf8(): Boolean {
|
||||
try {
|
||||
val prefix = Buffer()
|
||||
val byteCount = size.coerceAtMost(maximumValue = 64)
|
||||
copyTo(out = prefix, offset = 0, byteCount = byteCount)
|
||||
|
||||
@Suppress("MagicNumber", "UnusedPrivateMember")
|
||||
for (i in 0 until 16) {
|
||||
if (prefix.exhausted()) break
|
||||
|
||||
val codePoint = prefix.readUtf8CodePoint()
|
||||
if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (_: EOFException) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.beautifyJson(): String {
|
||||
beautifyIfObject(json = this)?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
beautifyIfArray(json = this)?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun beautifyIfObject(json: String): String? {
|
||||
return try {
|
||||
JSONObject(json).toString(JSON_INDENT_SPACES)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun beautifyIfArray(json: String): String? {
|
||||
return try {
|
||||
JSONArray(json).toString(JSON_INDENT_SPACES)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue