Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-16 12:42:11 +03:00
parent 70e40cb2a7
commit bbb9280413
8 changed files with 294 additions and 49 deletions

View file

@ -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()

View file

@ -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)
}
}
}
}

View file

@ -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
}
}
}