From bbb92804136d4fcd6014e80c771f67c8ff859774 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Aug 2024 12:42:11 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/tap/features/main/MainViewModel.kt | 6 +- .../com/tangem/datasource/di/NetworkModule.kt | 17 +- .../datasource/local/logs/AppLogsStore.kt | 69 ++++++ .../utils/NetworkLogsSaveInterceptor.kt | 197 ++++++++++++++++++ .../settings/DefaultSettingsRepository.kt | 42 +--- .../data/settings/di/SettingsDataModule.kt | 6 +- .../settings/DeleteDeprecatedLogsUseCase.kt | 2 +- .../repositories/SettingsRepository.kt | 4 +- 8 files changed, 294 insertions(+), 49 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index c108d54aab..375a326b2b 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -37,7 +37,7 @@ internal class MainViewModel @Inject constructor( private val listenToFlipsUseCase: ListenToFlipsUseCase, private val router: AppRouter, private val fetchAppCurrenciesUseCase: FetchAppCurrenciesUseCase, - private val deleteDeprecatedLogsUseCase: DeleteDeprecatedLogsUseCase, + deleteDeprecatedLogsUseCase: DeleteDeprecatedLogsUseCase, private val incrementAppLaunchCounterUseCase: IncrementAppLaunchCounterUseCase, private val blockchainSDKFactory: BlockchainSDKFactory, private val userWalletsListManager: UserWalletsListManager, @@ -76,9 +76,7 @@ internal class MainViewModel @Inject constructor( fetchStakingTokens() } - viewModelScope.launch(dispatchers.main) { - deleteDeprecatedLogsUseCase() - } + deleteDeprecatedLogsUseCase() } /** Loading the resources needed to run the application */ diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index b43900233f..c692a451c2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -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() diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt new file mode 100644 index 0000000000..38a05e8d79 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt @@ -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(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(PreferencesKeys.APP_LOGS_KEY) + + var sum = 0 + preferences.setObjectMap( + key = PreferencesKeys.APP_LOGS_KEY, + value = savedLogs.entries + .sortedBy(Map.Entry::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) + } + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt new file mode 100644 index 0000000000..ee74b94ab5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt index fd7a139ab2..6ff9910d3e 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt @@ -1,25 +1,17 @@ package com.tangem.data.settings +import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getSyncOrDefault import com.tangem.datasource.local.preferences.utils.store import com.tangem.domain.settings.repositories.SettingsRepository -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 internal class DefaultSettingsRepository( private val appPreferencesStore: AppPreferencesStore, - dispatchers: CoroutineDispatcherProvider, + private val appLogsStore: AppLogsStore, ) : SettingsRepository { - private val scope = CoroutineScope(dispatchers.io) - private val mutex = Mutex() - override suspend fun shouldShowSaveUserWalletScreen(): Boolean { return appPreferencesStore.getSyncOrDefault( key = PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY, @@ -46,35 +38,11 @@ internal class DefaultSettingsRepository( } override fun saveLogMessage(message: String) { - val newLogs = DateTime.now().millis.toString() to message - - scope.launch { - mutex.withLock { - appPreferencesStore.editData { preferences -> - val savedLogs = preferences.getObjectMap(PreferencesKeys.APP_LOGS_KEY) - - preferences.setObjectMap(key = PreferencesKeys.APP_LOGS_KEY, value = savedLogs + newLogs) - } - } - } + appLogsStore.saveLogMessage(message) } - override suspend fun deleteDeprecatedLogs(maxSize: Int) { - appPreferencesStore.editData { preferences -> - val savedLogs = preferences.getObjectMap(PreferencesKeys.APP_LOGS_KEY) - - var sum = 0 - preferences.setObjectMap( - key = PreferencesKeys.APP_LOGS_KEY, - value = savedLogs.entries - .sortedBy(Map.Entry::key) - .takeLastWhile { - sum += it.value.length - sum < maxSize - } - .associate { it.key to it.value }, - ) - } + override fun deleteDeprecatedLogs(maxSize: Int) { + appLogsStore.deleteDeprecatedLogs(maxSize) } override suspend fun isSendTapHelpPreviewEnabled(): Boolean { diff --git a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt index 3efdd2e7eb..bddc8ef6e1 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt @@ -4,12 +4,12 @@ import com.tangem.data.settings.DefaultAppRatingRepository import com.tangem.data.settings.DefaultPermissionRepository import com.tangem.data.settings.DefaultPromoSettingsRepository import com.tangem.data.settings.DefaultSettingsRepository +import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.settings.repositories.AppRatingRepository import com.tangem.domain.settings.repositories.PermissionRepository import com.tangem.domain.settings.repositories.PromoSettingsRepository import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,9 +24,9 @@ internal object SettingsDataModule { @Singleton fun provideSettingsRepository( appPreferencesStore: AppPreferencesStore, - dispatchers: CoroutineDispatcherProvider, + appLogsStore: AppLogsStore, ): SettingsRepository { - return DefaultSettingsRepository(appPreferencesStore = appPreferencesStore, dispatchers = dispatchers) + return DefaultSettingsRepository(appPreferencesStore = appPreferencesStore, appLogsStore = appLogsStore) } @Provides diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/DeleteDeprecatedLogsUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/DeleteDeprecatedLogsUseCase.kt index 627d3f1124..bff7acf23a 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/DeleteDeprecatedLogsUseCase.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/DeleteDeprecatedLogsUseCase.kt @@ -6,7 +6,7 @@ class DeleteDeprecatedLogsUseCase( private val settingsRepository: SettingsRepository, ) { - suspend operator fun invoke() { + operator fun invoke() { settingsRepository.deleteDeprecatedLogs(maxSize = MAX_LOGS_SIZE) } diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt index 992990fe85..06fd81fde6 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt @@ -10,11 +10,9 @@ interface SettingsRepository { suspend fun setWalletScrollPreviewAvailability(isEnabled: Boolean) - @Throws fun saveLogMessage(message: String) - @Throws - suspend fun deleteDeprecatedLogs(maxSize: Int) + fun deleteDeprecatedLogs(maxSize: Int) suspend fun isSendTapHelpPreviewEnabled(): Boolean