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

@ -37,7 +37,7 @@ internal class MainViewModel @Inject constructor(
private val listenToFlipsUseCase: ListenToFlipsUseCase, private val listenToFlipsUseCase: ListenToFlipsUseCase,
private val router: AppRouter, private val router: AppRouter,
private val fetchAppCurrenciesUseCase: FetchAppCurrenciesUseCase, private val fetchAppCurrenciesUseCase: FetchAppCurrenciesUseCase,
private val deleteDeprecatedLogsUseCase: DeleteDeprecatedLogsUseCase, deleteDeprecatedLogsUseCase: DeleteDeprecatedLogsUseCase,
private val incrementAppLaunchCounterUseCase: IncrementAppLaunchCounterUseCase, private val incrementAppLaunchCounterUseCase: IncrementAppLaunchCounterUseCase,
private val blockchainSDKFactory: BlockchainSDKFactory, private val blockchainSDKFactory: BlockchainSDKFactory,
private val userWalletsListManager: UserWalletsListManager, private val userWalletsListManager: UserWalletsListManager,
@ -76,10 +76,8 @@ internal class MainViewModel @Inject constructor(
fetchStakingTokens() fetchStakingTokens()
} }
viewModelScope.launch(dispatchers.main) {
deleteDeprecatedLogsUseCase() deleteDeprecatedLogsUseCase()
} }
}
/** Loading the resources needed to run the application */ /** Loading the resources needed to run the application */
private fun loadApplicationResources() { private fun loadApplicationResources() {

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.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.TangemTechApiV2 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.local.preferences.AppPreferencesStore
import com.tangem.datasource.utils.* import com.tangem.datasource.utils.*
import com.tangem.datasource.utils.RequestHeader.AppVersionPlatformHeaders import com.tangem.datasource.utils.RequestHeader.AppVersionPlatformHeaders
@ -58,12 +59,18 @@ internal object NetworkModule {
@NetworkMoshi moshi: Moshi, @NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context, @ApplicationContext context: Context,
apiConfigsManager: ApiConfigsManager, apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): TangemExpressApi { ): TangemExpressApi {
return createApi( return createApi(
id = ApiConfig.ID.Express, id = ApiConfig.ID.Express,
moshi = moshi, moshi = moshi,
context = context, context = context,
apiConfigsManager = apiConfigsManager, apiConfigsManager = apiConfigsManager,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
)
},
) )
} }
@ -73,12 +80,18 @@ internal object NetworkModule {
@NetworkMoshi moshi: Moshi, @NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context, @ApplicationContext context: Context,
apiConfigsManager: ApiConfigsManager, apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): StakeKitApi { ): StakeKitApi {
return createApi( return createApi(
id = ApiConfig.ID.StakeKit, id = ApiConfig.ID.StakeKit,
moshi = moshi, moshi = moshi,
context = context, context = context,
apiConfigsManager = apiConfigsManager, apiConfigsManager = apiConfigsManager,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
)
},
) )
} }
@ -94,6 +107,7 @@ internal object NetworkModule {
moshi = moshi, moshi = moshi,
context = context, context = context,
apiConfigsManager = apiConfigsManager, apiConfigsManager = apiConfigsManager,
clientBuilder = { applyTimeoutAnnotations() },
) )
} }
@ -200,6 +214,7 @@ internal object NetworkModule {
moshi: Moshi, moshi: Moshi,
context: Context, context: Context,
apiConfigsManager: ApiConfigsManager, apiConfigsManager: ApiConfigsManager,
clientBuilder: OkHttpClient.Builder.() -> OkHttpClient.Builder = { this },
): T { ): T {
val environmentConfig = apiConfigsManager.getEnvironmentConfig(id) val environmentConfig = apiConfigsManager.getEnvironmentConfig(id)
@ -210,8 +225,8 @@ internal object NetworkModule {
.client( .client(
OkHttpClient.Builder() OkHttpClient.Builder()
.applyApiConfig(id, apiConfigsManager) .applyApiConfig(id, apiConfigsManager)
.applyTimeoutAnnotations()
.addLoggers(context) .addLoggers(context)
.clientBuilder()
.build(), .build(),
) )
.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
}
}
}

View file

@ -1,25 +1,17 @@
package com.tangem.data.settings 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.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.store import com.tangem.datasource.local.preferences.utils.store
import com.tangem.domain.settings.repositories.SettingsRepository 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( internal class DefaultSettingsRepository(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider, private val appLogsStore: AppLogsStore,
) : SettingsRepository { ) : SettingsRepository {
private val scope = CoroutineScope(dispatchers.io)
private val mutex = Mutex()
override suspend fun shouldShowSaveUserWalletScreen(): Boolean { override suspend fun shouldShowSaveUserWalletScreen(): Boolean {
return appPreferencesStore.getSyncOrDefault( return appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY, key = PreferencesKeys.SHOULD_SHOW_SAVE_USER_WALLET_SCREEN_KEY,
@ -46,35 +38,11 @@ internal class DefaultSettingsRepository(
} }
override fun saveLogMessage(message: String) { override fun saveLogMessage(message: String) {
val newLogs = DateTime.now().millis.toString() to message appLogsStore.saveLogMessage(message)
scope.launch {
mutex.withLock {
appPreferencesStore.editData { preferences ->
val savedLogs = preferences.getObjectMap<String>(PreferencesKeys.APP_LOGS_KEY)
preferences.setObjectMap(key = PreferencesKeys.APP_LOGS_KEY, value = savedLogs + newLogs)
}
}
}
} }
override suspend fun deleteDeprecatedLogs(maxSize: Int) { override fun deleteDeprecatedLogs(maxSize: Int) {
appPreferencesStore.editData { preferences -> appLogsStore.deleteDeprecatedLogs(maxSize)
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 },
)
}
} }
override suspend fun isSendTapHelpPreviewEnabled(): Boolean { override suspend fun isSendTapHelpPreviewEnabled(): Boolean {

View file

@ -4,12 +4,12 @@ import com.tangem.data.settings.DefaultAppRatingRepository
import com.tangem.data.settings.DefaultPermissionRepository import com.tangem.data.settings.DefaultPermissionRepository
import com.tangem.data.settings.DefaultPromoSettingsRepository import com.tangem.data.settings.DefaultPromoSettingsRepository
import com.tangem.data.settings.DefaultSettingsRepository import com.tangem.data.settings.DefaultSettingsRepository
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.settings.repositories.AppRatingRepository import com.tangem.domain.settings.repositories.AppRatingRepository
import com.tangem.domain.settings.repositories.PermissionRepository import com.tangem.domain.settings.repositories.PermissionRepository
import com.tangem.domain.settings.repositories.PromoSettingsRepository import com.tangem.domain.settings.repositories.PromoSettingsRepository
import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -24,9 +24,9 @@ internal object SettingsDataModule {
@Singleton @Singleton
fun provideSettingsRepository( fun provideSettingsRepository(
appPreferencesStore: AppPreferencesStore, appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider, appLogsStore: AppLogsStore,
): SettingsRepository { ): SettingsRepository {
return DefaultSettingsRepository(appPreferencesStore = appPreferencesStore, dispatchers = dispatchers) return DefaultSettingsRepository(appPreferencesStore = appPreferencesStore, appLogsStore = appLogsStore)
} }
@Provides @Provides

View file

@ -6,7 +6,7 @@ class DeleteDeprecatedLogsUseCase(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
) { ) {
suspend operator fun invoke() { operator fun invoke() {
settingsRepository.deleteDeprecatedLogs(maxSize = MAX_LOGS_SIZE) settingsRepository.deleteDeprecatedLogs(maxSize = MAX_LOGS_SIZE)
} }

View file

@ -10,11 +10,9 @@ interface SettingsRepository {
suspend fun setWalletScrollPreviewAvailability(isEnabled: Boolean) suspend fun setWalletScrollPreviewAvailability(isEnabled: Boolean)
@Throws
fun saveLogMessage(message: String) fun saveLogMessage(message: String)
@Throws fun deleteDeprecatedLogs(maxSize: Int)
suspend fun deleteDeprecatedLogs(maxSize: Int)
suspend fun isSendTapHelpPreviewEnabled(): Boolean suspend fun isSendTapHelpPreviewEnabled(): Boolean