Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-02 10:43:16 +03:00
commit 4daf088b89
648 changed files with 20086 additions and 2462 deletions

View file

@ -40,10 +40,8 @@ sealed class ApiConfig {
companion object {
internal const val DEBUG_BUILD_TYPE = "debug"
internal const val DEBUG_PG_BUILD_TYPE = "debugPG"
internal const val INTERNAL_BUILD_TYPE = "internal"
internal const val MOCKED_BUILD_TYPE = "mocked"
internal const val EXTERNAL_BUILD_TYPE = "external"
internal const val RELEASE_BUILD_TYPE = "release"
}
}

View file

@ -69,12 +69,10 @@ internal class Express(
fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
DEBUG_PG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> ApiEnvironment.STAGE
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")

View file

@ -19,13 +19,13 @@ internal class TangemTech(
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem-tech.com/v1/",
baseUrl = "https://api.tangem.org/v1/",
headers = createHeaders(),
)
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://devapi.tangem-tech.com/v1/",
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)

View file

@ -1,16 +1,18 @@
package com.tangem.datasource.api.common.response
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import retrofit2.Call
import retrofit2.CallAdapter
import java.lang.reflect.Type
internal class ApiResponseCallAdapter(
private val resultType: Type,
private val analyticsErrorHandler: AnalyticsErrorHandler,
) : CallAdapter<Type, Call<ApiResponse<Type>>> {
override fun responseType(): Type = resultType
override fun adapt(call: Call<Type>): Call<ApiResponse<Type>> {
return ApiResponseCallDelegate(call)
return ApiResponseCallDelegate(call, analyticsErrorHandler)
}
}

View file

@ -1,12 +1,15 @@
package com.tangem.datasource.api.common.response
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Retrofit
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
class ApiResponseCallAdapterFactory private constructor() : CallAdapter.Factory() {
class ApiResponseCallAdapterFactory private constructor(
private val analyticsErrorHandler: AnalyticsErrorHandler,
) : CallAdapter.Factory() {
override fun get(returnType: Type, annotations: Array<out Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
if (getRawType(returnType) != Call::class.java) {
@ -19,11 +22,11 @@ class ApiResponseCallAdapterFactory private constructor() : CallAdapter.Factory(
}
val resultType = getParameterUpperBound(0, callType as ParameterizedType)
return ApiResponseCallAdapter(resultType)
return ApiResponseCallAdapter(resultType, analyticsErrorHandler)
}
companion object {
fun create() = ApiResponseCallAdapterFactory()
fun create(analyticsErrorHandler: AnalyticsErrorHandler) = ApiResponseCallAdapterFactory(analyticsErrorHandler)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.api.common.response
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import okhttp3.Request
import okio.Timeout
import retrofit2.Call
@ -9,6 +10,7 @@ import timber.log.Timber
internal class ApiResponseCallDelegate<T : Any>(
private val wrappedCall: Call<T>,
private val analyticsErrorHandler: AnalyticsErrorHandler,
) : Call<ApiResponse<T>> {
override fun enqueue(callback: Callback<ApiResponse<T>>) {
@ -16,7 +18,7 @@ internal class ApiResponseCallDelegate<T : Any>(
}
override fun execute(): Response<ApiResponse<T>> = throw NotImplementedError()
override fun clone(): Call<ApiResponse<T>> = ApiResponseCallDelegate(wrappedCall.clone())
override fun clone(): Call<ApiResponse<T>> = ApiResponseCallDelegate(wrappedCall.clone(), analyticsErrorHandler)
override fun request(): Request = wrappedCall.request()
override fun timeout(): Timeout = wrappedCall.timeout()
override fun isExecuted(): Boolean = wrappedCall.isExecuted
@ -30,7 +32,7 @@ internal class ApiResponseCallDelegate<T : Any>(
) : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
val safeResponse = response.toSafeApiResponse()
val safeResponse = response.toSafeApiResponse(analyticsErrorHandler)
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
}

View file

@ -19,51 +19,51 @@ sealed class ApiResponseError : Exception() {
) : ApiResponseError() {
// region Error Codes
enum class Code(val code: Int) {
enum class Code(val numericCode: Int) {
// 4xx Server Errors
BAD_REQUEST(code = 400),
UNAUTHORIZED(code = 401),
PAYMENT_REQUIRED(code = 402),
FORBIDDEN(code = 403),
NOT_FOUND(code = 404),
METHOD_NOT_ALLOWED(code = 405),
NOT_ACCEPTABLE(code = 406),
PROXY_AUTHENTICATION_REQUIRED(code = 407),
REQUEST_TIMEOUT(code = 408),
CONFLICT(code = 409),
GONE(code = 410),
LENGTH_REQUIRED(code = 411),
PRECONDITION_FAILED(code = 412),
PAYLOAD_TOO_LARGE(code = 413),
URI_TOO_LONG(code = 414),
UNSUPPORTED_MEDIA_TYPE(code = 415),
RANGE_NOT_SATISFIABLE(code = 416),
EXPECTATION_FAILED(code = 417),
IM_A_TEAPOT(code = 418), // Not an error, but an April Fools' joke from RFC 2324
UNPROCESSABLE_ENTITY(code = 422),
LOCKED(code = 423),
FAILED_DEPENDENCY(code = 424),
TOO_EARLY(code = 425),
UPGRADE_REQUIRED(code = 426),
PRECONDITION_REQUIRED(code = 428),
TOO_MANY_REQUESTS(code = 429),
REQUEST_HEADER_FIELDS_TOO_LARGE(code = 431),
UNAVAILABLE_FOR_LEGAL_REASONS(code = 451),
BAD_REQUEST(numericCode = 400),
UNAUTHORIZED(numericCode = 401),
PAYMENT_REQUIRED(numericCode = 402),
FORBIDDEN(numericCode = 403),
NOT_FOUND(numericCode = 404),
METHOD_NOT_ALLOWED(numericCode = 405),
NOT_ACCEPTABLE(numericCode = 406),
PROXY_AUTHENTICATION_REQUIRED(numericCode = 407),
REQUEST_TIMEOUT(numericCode = 408),
CONFLICT(numericCode = 409),
GONE(numericCode = 410),
LENGTH_REQUIRED(numericCode = 411),
PRECONDITION_FAILED(numericCode = 412),
PAYLOAD_TOO_LARGE(numericCode = 413),
URI_TOO_LONG(numericCode = 414),
UNSUPPORTED_MEDIA_TYPE(numericCode = 415),
RANGE_NOT_SATISFIABLE(numericCode = 416),
EXPECTATION_FAILED(numericCode = 417),
IM_A_TEAPOT(numericCode = 418), // Not an error, but an April Fools' joke from RFC 2324
UNPROCESSABLE_ENTITY(numericCode = 422),
LOCKED(numericCode = 423),
FAILED_DEPENDENCY(numericCode = 424),
TOO_EARLY(numericCode = 425),
UPGRADE_REQUIRED(numericCode = 426),
PRECONDITION_REQUIRED(numericCode = 428),
TOO_MANY_REQUESTS(numericCode = 429),
REQUEST_HEADER_FIELDS_TOO_LARGE(numericCode = 431),
UNAVAILABLE_FOR_LEGAL_REASONS(numericCode = 451),
// 5xx Server Errors
INTERNAL_SERVER_ERROR(code = 500),
NOT_IMPLEMENTED(code = 501),
BAD_GATEWAY(code = 502),
SERVICE_UNAVAILABLE(code = 503),
GATEWAY_TIMEOUT(code = 504),
HTTP_VERSION_NOT_SUPPORTED(code = 505),
VARIANT_ALSO_NEGOTIATES(code = 506),
INSUFFICIENT_STORAGE(code = 507),
LOOP_DETECTED(code = 508),
NOT_EXTENDED(code = 510),
NETWORK_AUTHENTICATION_REQUIRED(code = 511),
INTERNAL_SERVER_ERROR(numericCode = 500),
NOT_IMPLEMENTED(numericCode = 501),
BAD_GATEWAY(numericCode = 502),
SERVICE_UNAVAILABLE(numericCode = 503),
GATEWAY_TIMEOUT(numericCode = 504),
HTTP_VERSION_NOT_SUPPORTED(numericCode = 505),
VARIANT_ALSO_NEGOTIATES(numericCode = 506),
INSUFFICIENT_STORAGE(numericCode = 507),
LOOP_DETECTED(numericCode = 508),
NOT_EXTENDED(numericCode = 510),
NETWORK_AUTHENTICATION_REQUIRED(numericCode = 511),
;
override fun toString(): String = "$code - $name"
override fun toString(): String = "$numericCode - $name"
companion object {
val values = values()

View file

@ -1,5 +1,7 @@
package com.tangem.datasource.api.common.response
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.api.common.response.analytics.ApiErrorEvent
import kotlinx.coroutines.TimeoutCancellationException
import retrofit2.Response
import timber.log.Timber
@ -9,18 +11,20 @@ import java.net.UnknownHostException
import java.util.concurrent.TimeoutException
import javax.net.ssl.SSLHandshakeException
internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
internal fun <T : Any> Response<T>.toSafeApiResponse(analyticsErrorHandler: AnalyticsErrorHandler): ApiResponse<T> {
val body = body()
return if (isSuccessful && body != null) {
apiSuccess(body)
} else {
val code = ApiResponseError.HttpException.Code.values
.firstOrNull { it.code == code() }
.firstOrNull { it.numericCode == code() }
val e = try {
if (code == null) {
ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}"))
} else {
sendHttpError(code, analyticsErrorHandler)
ApiResponseError.HttpException(code, message(), errorBody()?.string())
}
} catch (e: Exception) {
@ -32,6 +36,21 @@ internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
}
}
private fun <T : Any> Response<T>.sendHttpError(
code: ApiResponseError.HttpException.Code,
analyticsErrorHandler: AnalyticsErrorHandler,
) {
val fullRequestUrl = raw().request.url.toUrl()
val shortUrl = fullRequestUrl.authority + fullRequestUrl.path
analyticsErrorHandler.sendErrorEvent(
ApiErrorEvent(
endpoint = shortUrl,
code = code.numericCode,
message = errorBody()?.string().orEmpty(),
),
)
}
internal fun Throwable.toApiError(): ApiResponseError = when (this) {
is ConnectException,
is UnknownHostException,

View file

@ -0,0 +1,17 @@
package com.tangem.datasource.api.common.response.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
internal data class ApiErrorEvent(
val endpoint: String,
val code: Int,
val message: String,
) : AnalyticsEvent(
category = "Tangem API Service",
event = "Exception",
params = mapOf(
"Endpoint" to endpoint,
"Code" to code.toString(),
"Message" to message,
),
)

View file

@ -20,7 +20,7 @@ data class TokenMarketListResponse(
@Json(name = "name") val name: String,
@Json(name = "symbol") val symbol: String,
@Json(name = "current_price") val currentPrice: BigDecimal,
@Json(name = "price_change_percentage") val priceChangePercentage: PriceChangePercentage,
@Json(name = "price_change_percentage") val priceChangePercentage: PriceChangePercentage?,
@Json(name = "market_rating") val marketRating: Int?,
@Json(name = "market_cap") val marketCap: BigDecimal?,
@Json(name = "is_under_market_cap_limit") val isUnderMarketCapLimit: Boolean?,
@ -28,9 +28,9 @@ data class TokenMarketListResponse(
@JsonClass(generateAdapter = true)
data class PriceChangePercentage(
@Json(name = "24h") val h24: BigDecimal,
@Json(name = "1w") val week1: BigDecimal,
@Json(name = "30d") val day30: BigDecimal,
@Json(name = "24h") val h24: BigDecimal?,
@Json(name = "1w") val week1: BigDecimal?,
@Json(name = "30d") val day30: BigDecimal?,
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.datasource.api.stakekit.models.response.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.stakekit.models.request.Address
import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO.PendingAction.PendingActionArgs.Amount
import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionTypeDTO
import org.joda.time.DateTime
import java.math.BigDecimal
@ -31,6 +32,8 @@ data class BalanceDTO(
val pricePerShare: BigDecimal,
@Json(name = "pendingActions")
val pendingActions: List<PendingAction>,
@Json(name = "pendingActionConstraints")
val pendingActionConstraints: List<PendingActionConstraints>?,
@Json(name = "token")
val tokenDTO: TokenDTO,
@Json(name = "validatorAddress")
@ -99,7 +102,7 @@ data class BalanceDTO(
@JsonClass(generateAdapter = true)
data class Amount(
@Json(name = "required")
val required: Boolean,
val required: Boolean = true,
@Json(name = "minimum")
val minimum: BigDecimal?,
@Json(name = "maximum")
@ -136,6 +139,14 @@ data class BalanceDTO(
}
}
@JsonClass(generateAdapter = true)
data class PendingActionConstraints(
@Json(name = "type")
val type: StakingActionTypeDTO,
@Json(name = "amount")
val amountArg: Amount,
)
@JsonClass(generateAdapter = true)
data class Required(
@Json(name = "required")

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.di
import android.content.Context
import com.squareup.moshi.Moshi
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiConfigs
@ -61,6 +62,7 @@ internal object NetworkModule {
fun provideExpressApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): TangemExpressApi {
@ -69,6 +71,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
@ -83,6 +86,7 @@ internal object NetworkModule {
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
apiConfigsManager: ApiConfigsManager,
analyticsErrorHandler: AnalyticsErrorHandler,
appLogsStore: AppLogsStore,
): StakeKitApi {
return createApi(
@ -90,6 +94,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
timeouts = Timeouts(
callTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS,
connectTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS,
@ -109,6 +114,7 @@ internal object NetworkModule {
fun provideOnrampApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): OnrampApi {
@ -117,6 +123,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
@ -130,6 +137,7 @@ internal object NetworkModule {
fun provideTangemTechApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
): TangemTechApi {
return createApi(
@ -137,6 +145,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = { applyTimeoutAnnotations() },
)
}
@ -147,6 +156,7 @@ internal object NetworkModule {
fun provideTangemTechApiV2(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
appVersionProvider: AppVersionProvider,
): TangemTechApiV2 {
return provideTangemTechApiInternal(
@ -154,6 +164,7 @@ internal object NetworkModule {
context = context,
appVersionProvider = appVersionProvider,
baseUrl = PROD_V2_TANGEM_TECH_BASE_URL,
analyticsErrorHandler = analyticsErrorHandler,
)
}
@ -162,6 +173,7 @@ internal object NetworkModule {
fun provideTangemTechMarketsApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
): TangemTechMarketsApi {
return createApi(
@ -169,6 +181,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
this.callTimeout(TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.connectTimeout(TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
@ -183,6 +196,7 @@ internal object NetworkModule {
fun provideTangemVisaAuthApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): TangemVisaAuthApi {
@ -191,6 +205,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
@ -204,6 +219,7 @@ internal object NetworkModule {
fun provideTangemVisaApi(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
analyticsErrorHandler: AnalyticsErrorHandler,
apiConfigsManager: ApiConfigsManager,
appLogsStore: AppLogsStore,
): TangemVisaApi {
@ -212,6 +228,7 @@ internal object NetworkModule {
moshi = moshi,
context = context,
apiConfigsManager = apiConfigsManager,
analyticsErrorHandler = analyticsErrorHandler,
clientBuilder = {
addInterceptor(
NetworkLogsSaveInterceptor(appLogsStore),
@ -226,6 +243,7 @@ internal object NetworkModule {
context: Context,
appVersionProvider: AppVersionProvider,
baseUrl: String,
analyticsErrorHandler: AnalyticsErrorHandler,
timeouts: Timeouts = Timeouts(),
requestHeaders: List<RequestHeader> = listOf(AppVersionPlatformHeaders(appVersionProvider)),
): T {
@ -257,7 +275,7 @@ internal object NetworkModule {
return Retrofit.Builder()
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create(analyticsErrorHandler))
.baseUrl(baseUrl)
.client(client)
.build()
@ -269,6 +287,7 @@ internal object NetworkModule {
moshi: Moshi,
context: Context,
apiConfigsManager: ApiConfigsManager,
analyticsErrorHandler: AnalyticsErrorHandler,
timeouts: Timeouts = Timeouts(),
clientBuilder: OkHttpClient.Builder.() -> OkHttpClient.Builder = { this },
): T {
@ -276,7 +295,7 @@ internal object NetworkModule {
return Retrofit.Builder()
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create(analyticsErrorHandler))
.baseUrl(environmentConfig.baseUrl)
.client(
OkHttpClient.Builder()

View file

@ -0,0 +1,45 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.datasource.local.walletconnect.DefaultWalletConnectStore
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.setTypes
import com.tangem.domain.walletconnect.model.WcSessionDTO
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object WalletConnectModule {
@Provides
@Singleton
fun provideWalletConnectStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): WalletConnectStore {
return DefaultWalletConnectStore(
persistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = setTypes<WcSessionDTO>(),
defaultValue = emptySet(),
),
produceFile = { context.dataStoreFile(fileName = "wallet_connect_sessions") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
)
}
}

View file

@ -4,18 +4,15 @@ import android.content.Context
import com.tangem.datasource.BuildConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.joda.time.DateTime
import org.joda.time.format.DateTimeFormatterBuilder
import timber.log.Timber
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.*
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.inject.Inject
import javax.inject.Singleton
@ -30,7 +27,7 @@ import javax.inject.Singleton
@Singleton
class AppLogsStore @Inject constructor(
@ApplicationContext private val applicationContext: Context,
dispatchers: CoroutineDispatcherProvider,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val scope = CoroutineScope(
@ -38,8 +35,10 @@ class AppLogsStore @Inject constructor(
CoroutineExceptionHandler { _, error -> Timber.e("AppLogsStore.scope is failed $error") },
)
private val mutex = Mutex()
private val zipMutex = Mutex()
private val file = File(applicationContext.filesDir, NEW_LOG_FILE_NAME)
private val file = File(applicationContext.filesDir, PERMITTED_FILE_NAME)
private val fileZip = File(applicationContext.filesDir, PERMITTED_FILE_NAME_ZIP)
private val formatter = DateTimeFormatterBuilder()
.appendDayOfMonth(2)
@ -58,6 +57,16 @@ class AppLogsStore @Inject constructor(
/** Get log file */
fun getFile(): File? = if (file.exists()) file else null
suspend fun getZipFile(): File? {
return zipMutex.withLock {
if (file.exists()) {
zip(listOf(file), fileZip)
} else {
null
}
}
}
/** Save log [message] */
fun saveLogMessage(tag: String, message: String) {
// Temporally logs are not saved in prod environment
@ -133,8 +142,41 @@ class AppLogsStore @Inject constructor(
}
}
@Suppress("NestedBlockDepth")
private suspend fun zip(filesToCompress: List<File>, outputZipFile: File): File? {
return withContext(dispatchers.io) {
if (outputZipFile.exists() && !outputZipFile.delete()) {
return@withContext null
}
val buffer = ByteArray(BUFFER_SIZE)
FileOutputStream(outputZipFile).use { fos ->
ZipOutputStream(fos).use { zos ->
filesToCompress.forEach { file ->
FileInputStream(file).use { inStream ->
val ze = ZipEntry(file.name)
zos.putNextEntry(ze)
var len: Int
while (inStream.read(buffer).also { len = it } > 0) {
zos.write(buffer, 0, len)
}
}
}
zos.finish() // Ensures the zip output is finalized
}
}
outputZipFile
}
}
private companion object {
const val BUFFER_SIZE = 1024
const val LOG_FILE_NAME = "logs.txt"
const val NEW_LOG_FILE_NAME = "app_logs.txt"
// the only name that we allow to send as email to company addresses
const val PERMITTED_FILE_NAME = "log.txt"
const val PERMITTED_FILE_NAME_ZIP = "log.zip"
}
}

View file

@ -10,7 +10,7 @@ import timber.log.Timber
*
[REDACTED_AUTHOR]
*/
internal class NetworkAddressConverter(
class NetworkAddressConverter(
private val selectedAddress: String,
) : TwoWayConverter<Set<NetworkStatusDM.Address>, NetworkAddress> {

View file

@ -14,7 +14,7 @@ private typealias AmountsDomainModel = Map<CryptoCurrency.ID, CryptoCurrencyAmou
*
[REDACTED_AUTHOR]
*/
internal object NetworkAmountsConverter : TwoWayConverter<AmountsDataModel, AmountsDomainModel> {
object NetworkAmountsConverter : TwoWayConverter<AmountsDataModel, AmountsDomainModel> {
override fun convert(value: AmountsDataModel): AmountsDomainModel {
return value

View file

@ -10,7 +10,7 @@ import com.tangem.utils.converter.TwoWayConverter
*
[REDACTED_AUTHOR]
*/
internal object NetworkDerivationPathConverter :
object NetworkDerivationPathConverter :
TwoWayConverter<NetworkStatusDM.DerivationPath, Network.DerivationPath> {
override fun convert(value: NetworkStatusDM.DerivationPath): Network.DerivationPath {

View file

@ -9,7 +9,7 @@ import com.tangem.utils.converter.Converter
*
[REDACTED_AUTHOR]
*/
internal object NetworkStatusDataModelConverter : Converter<NetworkStatus, NetworkStatusDM?> {
object NetworkStatusDataModelConverter : Converter<NetworkStatus, NetworkStatusDM?> {
override fun convert(value: NetworkStatus): NetworkStatusDM? {
return when (val status = value.value) {

View file

@ -6,7 +6,7 @@ import com.tangem.domain.tokens.model.Network
import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel
import java.math.BigDecimal
internal sealed interface NetworkStatusDM {
sealed interface NetworkStatusDM {
val networkId: Network.ID
val derivationPath: DerivationPath

View file

@ -12,7 +12,7 @@ internal class DefaultNFTPersistenceStore(
private val pricesPersistenceStore: DataStore<Map<NFTAsset.Identifier, NFTAsset.SalePrice>>,
) : NFTPersistenceStore {
override fun getCollections(): Flow<List<NFTCollection>> = collectionsPersistenceStore.data
override fun getCollections(): Flow<List<NFTCollection>?> = collectionsPersistenceStore.data
override suspend fun getCollectionsSync(): List<NFTCollection>? = collectionsPersistenceStore
.data

View file

@ -42,7 +42,7 @@ internal class DefaultNFTRuntimeStore(
)
}
override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow<NFTAsset> =
override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow<NFTAsset?> =
collectionsRuntimeStore
.get()
.combine(getSalePrice(assetId)) { collectionsData, price ->
@ -50,13 +50,17 @@ internal class DefaultNFTRuntimeStore(
.getCollection(collectionId)
?.getAsset(assetId)
?.mergeWithPrice(price)
?: NFTAsset.Error(assetId)
}
override fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice> = pricesRuntimeStore
.get()
.map { it[assetId] ?: NFTSalePrice.Empty(assetId) }
override suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice = pricesRuntimeStore
.getSyncOrNull()
?.let { it[assetId] }
?: NFTSalePrice.Empty(assetId)
override suspend fun saveCollections(collections: NFTCollections) {
collectionsRuntimeStore.store(collections)
}
@ -72,8 +76,13 @@ internal class DefaultNFTRuntimeStore(
?.collections
?.firstOrNull { it.id == collectionId }
private fun NFTCollection.getAsset(assetId: NFTAsset.Identifier): NFTAsset? =
assets.firstOrNull { it.id == assetId }
private fun NFTCollection.getAsset(assetId: NFTAsset.Identifier): NFTAsset? = when (val assets = assets) {
is NFTCollection.Assets.Empty,
is NFTCollection.Assets.Loading,
is NFTCollection.Assets.Failed,
-> null
is NFTCollection.Assets.Value -> assets.items.firstOrNull { it.id == assetId }
}
private fun NFTCollections.mergeWithPrices(prices: Map<NFTAsset.Identifier, NFTSalePrice>): NFTCollections =
when (val content = this.content) {
@ -89,18 +98,23 @@ internal class DefaultNFTRuntimeStore(
copy(
collections = this.collections?.map { data ->
data.copy(
assets = data.assets.map { asset ->
asset.mergeWithPrice(prices[asset.id] ?: NFTSalePrice.Empty(asset.id))
assets = when (val assets = data.assets) {
is NFTCollection.Assets.Empty,
is NFTCollection.Assets.Loading,
is NFTCollection.Assets.Failed,
-> assets
is NFTCollection.Assets.Value -> assets.copy(
items = assets.items.map { asset ->
asset.mergeWithPrice(prices[asset.id] ?: NFTSalePrice.Empty(asset.id))
},
)
},
)
},
source = this.source,
)
private fun NFTAsset.mergeWithPrice(price: NFTSalePrice): NFTAsset = when (this) {
is NFTAsset.Error -> this
is NFTAsset.Value -> copy(
salePrice = price,
)
}
private fun NFTAsset.mergeWithPrice(price: NFTSalePrice): NFTAsset = copy(
salePrice = price,
)
}

View file

@ -5,7 +5,7 @@ import com.tangem.blockchain.nft.models.NFTCollection
import kotlinx.coroutines.flow.Flow
interface NFTPersistenceStore {
fun getCollections(): Flow<List<NFTCollection>>
fun getCollections(): Flow<List<NFTCollection>?>
suspend fun getCollectionsSync(): List<NFTCollection>?

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.local.nft
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
@ -11,10 +12,12 @@ import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.datasource.utils.mapWithCustomKeyTypes
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.lang.reflect.ParameterizedType
import javax.inject.Inject
import javax.inject.Singleton
@ -25,41 +28,51 @@ class NFTPersistenceStoreFactory @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) {
fun provide(network: Network): NFTPersistenceStore {
val networkStringIdentifier = listOfNotNull(
network.id.value,
network.derivationPath.value,
).joinToString("_") {
// remove all non-alphanumeric characters
it
.toCharArray()
.filter(Char::isLetterOrDigit)
.joinToString("")
.lowercase()
}
fun provide(userWalletId: UserWalletId, network: Network): NFTPersistenceStore {
// simplify network identifier so that is correct for a file name
// e.g. eth_m4460000 or theopennetwork_m446070
val networkStringId =
network.id.formatted() + network.derivationPath.formatted()?.let { "_$it" }.orEmpty()
// simplify user wallet id so that is correct for a file name
// e.g. 9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a
val userWalletStringId = userWalletId.formatted()
return DefaultNFTPersistenceStore(
collectionsPersistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = listTypes<NFTCollection>(),
defaultValue = emptyList(),
),
produceFile = {
context.dataStoreFile(fileName = "nft_${networkStringIdentifier}_collections")
},
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
collectionsPersistenceStore = createPersistenceStore(
// result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_eth_m4460000_collections
// result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_theopennetwork_m446070_collections
fileName = "nft_${userWalletStringId}_${networkStringId}_collections",
types = listTypes<NFTCollection>(),
defaultValue = emptyList(),
),
pricesPersistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithCustomKeyTypes<NFTAsset.Identifier, NFTAsset.SalePrice>(),
defaultValue = emptyMap(),
),
produceFile = {
context.dataStoreFile(fileName = "nft_${networkStringIdentifier}_prices")
},
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
pricesPersistenceStore = createPersistenceStore(
// result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_eth_m4460000_prices
// result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_theopennetwork_m446070_prices
fileName = "nft_${userWalletStringId}_${networkStringId}_prices",
types = mapWithCustomKeyTypes<NFTAsset.Identifier, NFTAsset.SalePrice>(),
defaultValue = emptyMap(),
),
)
}
private fun <T> createPersistenceStore(fileName: String, types: ParameterizedType, defaultValue: T): DataStore<T> =
DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = types,
defaultValue = defaultValue,
),
produceFile = { context.dataStoreFile(fileName = fileName) },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
private fun Network.ID.formatted(): String = value
.filter(Char::isLetterOrDigit)
.lowercase()
private fun Network.DerivationPath.formatted(): String? = value
?.filter(Char::isLetterOrDigit)
?.lowercase()
private fun UserWalletId.formatted(): String = stringValue
.lowercase()
}

View file

@ -16,7 +16,9 @@ interface NFTRuntimeStore {
fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow<NFTAsset?>
fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice?>
fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice>
suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice
suspend fun saveCollections(collections: NFTCollections)

View file

@ -7,15 +7,12 @@ import com.tangem.domain.tokens.model.Network
import com.tangem.utils.converter.Converter
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
class NFTSdkAssetConverter(
private val nftSdkAssetIdentifierConverter: NFTSdkAssetIdentifierConverter,
private val nftSdkCollectionIdentifierConverter: NFTSdkCollectionIdentifierConverter,
) : Converter<Pair<Network, SdkNFTAsset>, NFTAsset> {
object NFTSdkAssetConverter : Converter<Pair<Network, SdkNFTAsset>, NFTAsset> {
override fun convert(value: Pair<Network, SdkNFTAsset>): NFTAsset {
val (network, asset) = value
val assetId = nftSdkAssetIdentifierConverter.convert(asset.identifier)
val collectionId = nftSdkCollectionIdentifierConverter.convert(asset.collectionIdentifier)
return NFTAsset.Value(
val assetId = NFTSdkAssetIdentifierConverter.convert(asset.identifier)
val collectionId = NFTSdkCollectionIdentifierConverter.convert(asset.collectionIdentifier)
return NFTAsset(
id = assetId,
collectionId = collectionId,
network = network,
@ -28,23 +25,22 @@ class NFTSdkAssetConverter(
assetId = assetId,
value = it.value,
symbol = it.symbol,
source = StatusSource.CACHE,
)
} ?: NFTSalePrice.Empty(assetId = assetId),
rarity = asset.rarity?.let {
NFTAsset.Value.Rarity(
NFTAsset.Rarity(
rank = it.rank,
label = it.label,
)
},
media = asset.media?.let {
NFTAsset.Value.Media(
NFTAsset.Media(
url = it.url,
mimetype = it.mimetype,
)
},
traits = asset.traits.map {
NFTAsset.Value.Trait(
NFTAsset.Trait(
name = it.name,
value = it.value,
)

View file

@ -1,18 +1,16 @@
package com.tangem.datasource.local.nft.converter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.tokens.model.Network
import com.tangem.utils.converter.Converter
import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection
class NFTSdkCollectionConverter(
private val nftSdkCollectionIdentifierConverter: NFTSdkCollectionIdentifierConverter,
private val nftSdkAssetConverter: NFTSdkAssetConverter,
) : Converter<Pair<Network, SdkNFTCollection>, NFTCollection> {
object NFTSdkCollectionConverter : Converter<Pair<Network, SdkNFTCollection>, NFTCollection> {
override fun convert(value: Pair<Network, SdkNFTCollection>): NFTCollection {
val (network, collection) = value
val collectionId = nftSdkCollectionIdentifierConverter.convert(collection.identifier)
val collectionId = NFTSdkCollectionIdentifierConverter.convert(collection.identifier)
return NFTCollection(
id = collectionId,
network = network,
@ -22,10 +20,20 @@ class NFTSdkCollectionConverter(
count = collection.count,
assets = collection.assets
.map { asset ->
nftSdkAssetConverter.convert(network to asset)
NFTSdkAssetConverter.convert(network to asset)
}
.filter {
it.id !is NFTAsset.Identifier.Unknown
}
.let {
if (it.isEmpty()) {
NFTCollection.Assets.Empty
} else {
NFTCollection.Assets.Value(
items = it,
source = StatusSource.CACHE,
)
}
},
)
}

View file

@ -0,0 +1,20 @@
package com.tangem.datasource.local.token.converter
import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO
import com.tangem.domain.staking.model.stakekit.PendingAction
import com.tangem.domain.staking.model.stakekit.PendingActionConstraints
import com.tangem.utils.converter.Converter
internal object PendingActionConstraintsConverter :
Converter<BalanceDTO.PendingActionConstraints, PendingActionConstraints> {
override fun convert(value: BalanceDTO.PendingActionConstraints): PendingActionConstraints {
return PendingActionConstraints(
type = StakingActionTypeConverter.convert(value.type),
amountArg = PendingAction.PendingActionArgs.Amount(
required = value.amountArg.required,
minimum = value.amountArg.minimum,
maximum = value.amountArg.maximum,
),
)
}
}

View file

@ -38,6 +38,8 @@ internal class YieldBalanceConverter(
pendingActions = PendingActionConverter
.convertList(item.pendingActions)
.sortedBy { it.passthrough },
pendingActionsConstraints = PendingActionConstraintsConverter
.convertList(item.pendingActionConstraints.orEmpty()),
isPending = false,
)
}

View file

@ -11,7 +11,9 @@ interface UserWalletsStore {
val userWallets: Flow<List<UserWallet>>
suspend fun getSyncOrNull(key: UserWalletId): UserWallet?
fun getSyncOrNull(key: UserWalletId): UserWallet?
suspend fun getSyncStrict(key: UserWalletId): UserWallet
suspend fun getAllSyncOrNull(): List<UserWallet>?

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.local.walletconnect
import androidx.datastore.core.DataStore
import com.tangem.domain.walletconnect.model.WcSessionDTO
import kotlinx.coroutines.flow.Flow
internal typealias WcSessionCollection = Set<WcSessionDTO>
class DefaultWalletConnectStore(
private val persistenceStore: DataStore<WcSessionCollection>,
) : WalletConnectStore {
override val sessions: Flow<WcSessionCollection>
get() = persistenceStore.data
override suspend fun saveSessions(sessions: WcSessionCollection) {
persistenceStore.updateData { data -> data.plus(sessions) }
}
override suspend fun removeSessions(sessions: WcSessionCollection) {
persistenceStore.updateData { data -> data.minus(sessions) }
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.datasource.local.walletconnect
import com.tangem.domain.walletconnect.model.WcSessionDTO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
interface WalletConnectStore {
val sessions: Flow<WcSessionCollection>
suspend fun findSessionByTopic(topic: String) = sessions.first().find { it.topic == topic }
suspend fun saveSessions(sessions: WcSessionCollection)
suspend fun saveSession(session: WcSessionDTO) = saveSessions(setOf(session))
suspend fun removeSessions(sessions: WcSessionCollection)
suspend fun removeSession(session: WcSessionDTO) = removeSessions(setOf(session))
}

View file

@ -6,8 +6,6 @@ import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.*
import com.tangem.datasource.api.common.config.ApiConfig.Companion.DEBUG_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.DEBUG_PG_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.EXTERNAL_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.INTERNAL_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE
import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE
@ -97,12 +95,10 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
private fun createExpressModel(): Model {
val environment = when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
DEBUG_PG_BUILD_TYPE,
-> ApiEnvironment.DEV
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> ApiEnvironment.STAGE
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
@ -114,12 +110,10 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
environment = environment,
baseUrl = when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,
DEBUG_PG_BUILD_TYPE,
-> "[REDACTED_ENV_URL]"
INTERNAL_BUILD_TYPE,
MOCKED_BUILD_TYPE,
-> "[REDACTED_ENV_URL]"
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> "https://express.tangem.com/v1/"
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
@ -152,7 +146,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) {
id = ApiConfig.ID.TangemTech,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.tangem-tech.com/v1/",
baseUrl = "https://api.tangem.org/v1/",
headers = mapOf(
"card_id" to ProviderSuspend { APP_CARD_ID },
"card_public_key" to ProviderSuspend { APP_CARD_PUBLIC_KEY },