Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-22 09:21:30 +04:00
parent 3531c95908
commit 52cb5d4adf
12 changed files with 1029 additions and 0 deletions

View file

@ -0,0 +1,72 @@
package com.tangem.datasource.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import androidx.room.Room
import com.tangem.datasource.local.txhistory.db.TxHistoryDatabase
import com.tangem.datasource.local.txhistory.store.CommonSyncState
import com.tangem.datasource.local.txhistory.store.CommonSyncStateKey
import com.tangem.datasource.local.txhistory.store.DefaultTxHistoryStore
import com.tangem.datasource.local.txhistory.store.TxHistoryStore
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
import com.tangem.datasource.utils.KotlinxDataStoreSerializer.Companion.jsonBuilder
import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.builtins.MapSerializer
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface TxHistoryModule {
companion object {
private const val TX_HISTORY_DATABASE_NAME = "tx_history_database.db"
@Provides
@Singleton
fun provideTxHistoryDatabase(@ApplicationContext context: Context): TxHistoryDatabase {
return Room.databaseBuilder(
context = context,
klass = TxHistoryDatabase::class.java,
name = TX_HISTORY_DATABASE_NAME,
).build()
}
@Provides
@Singleton
fun provideTxHistoryStore(@ApplicationContext context: Context, appScope: AppCoroutineScope): TxHistoryStore {
val commonSerializer = KotlinxDataStoreSerializer(
defaultValue = emptyMap(),
serializer = MapSerializer(
CommonSyncStateKey.serializer(),
CommonSyncState.serializer(),
),
json = jsonBuilder {
allowStructuredMapKeys = true
},
)
val expressExchangeStore = DataStoreFactory.create(
serializer = commonSerializer,
produceFile = { context.dataStoreFile(fileName = "TxHistoryExpressExchangeStore") },
scope = appScope,
)
val expressOnrampStore = DataStoreFactory.create(
serializer = commonSerializer,
produceFile = { context.dataStoreFile(fileName = "TxHistoryExpressOnrampStore") },
scope = appScope,
)
return DefaultTxHistoryStore(
expressExchangeStore = expressExchangeStore,
expressOnrampStore = expressOnrampStore,
)
}
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.local.txhistory.db
import androidx.room.Database
import androidx.room.RoomDatabase
import com.tangem.datasource.local.txhistory.db.entity.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
@Database(
version = 1,
entities = [
ExpressProviderEntity::class,
ExpressExchangeEntity::class,
ExpressOnrampEntity::class,
],
)
abstract class TxHistoryDatabase : RoomDatabase() {
abstract fun expressHistoryDao(): ExpressHistoryDao
}

View file

@ -0,0 +1,87 @@
package com.tangem.datasource.local.txhistory.db.entity
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ExpressHistoryDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertProviders(items: List<ExpressProviderEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertExchanges(items: List<ExpressExchangeEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertOnramps(items: List<ExpressOnrampEntity>)
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
ORDER BY updated_at DESC
""",
)
fun observeExchanges(ownerAddress: String): Flow<List<ExpressExchangeEntity>>
@Query(
"""
SELECT *
FROM express_onramp
WHERE owner_address = :ownerAddress
ORDER BY updated_at DESC
""",
)
fun observeOnramps(ownerAddress: String): Flow<List<ExpressOnrampEntity>>
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND payin_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByPayinHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND payout_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByPayoutHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_exchange
WHERE owner_address = :ownerAddress
AND refund_hash = :hash
LIMIT 1
""",
)
suspend fun findExchangeByRefundHash(ownerAddress: String, hash: String): ExpressExchangeEntity?
@Query(
"""
SELECT *
FROM express_onramp
WHERE owner_address = :ownerAddress
AND payout_hash = :hash
LIMIT 1
""",
)
suspend fun findOnrampByPayoutHash(ownerAddress: String, hash: String): ExpressOnrampEntity?
}

View file

@ -0,0 +1,130 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.*
@Suppress("BooleanPropertyNaming")
@Entity(
tableName = "express_exchange",
foreignKeys = [
ForeignKey(
entity = ExpressProviderEntity::class,
parentColumns = ["id"],
childColumns = ["provider_id"],
onDelete = ForeignKey.RESTRICT,
),
],
indices = [
Index(value = ["owner_address", "from_network", "updated_at"]),
Index(value = ["owner_address", "payin_hash"]),
Index(value = ["owner_address", "payout_hash"]),
Index(value = ["owner_address", "refund_hash"]),
],
)
data class ExpressExchangeEntity(
@PrimaryKey
@ColumnInfo(name = "tx_id")
val txId: String,
@ColumnInfo(name = "owner_address")
val ownerAddress: String,
@ColumnInfo(name = "provider_id")
val providerId: String,
/**
* waiting
* confirming
* exchanging
* sending
* finished
* failed
* refunded
* expired
*/
@ColumnInfo(name = "status")
val status: String,
@Embedded(prefix = "from_")
val from: AssetEmbedded,
@Embedded(prefix = "to_")
val to: AssetEmbedded,
/**
* true -> actual provider-confirmed amount
* false -> estimated amount
*/
@ColumnInfo(name = "to_is_actual", defaultValue = "0")
val toIsActual: Boolean,
/**
* Match key for PAYIN leg
*/
@ColumnInfo(name = "payin_hash")
val payinHash: String?,
/**
* Match key for PAYOUT leg
*/
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/**
* fixed / float
*/
@ColumnInfo(name = "rate_type")
val rateType: String,
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
@Embedded(prefix = "refund_")
val refund: RefundEmbedded?,
) {
data class AssetEmbedded(
@ColumnInfo(name = "network")
val network: String,
@ColumnInfo(name = "token_id")
val tokenId: String?,
@ColumnInfo(name = "raw_amount")
val rawAmount: String,
@ColumnInfo(name = "decimals")
val decimals: Int,
)
data class RefundEmbedded(
@ColumnInfo(name = "network")
val network: String?,
@ColumnInfo(name = "token_id")
val tokenId: String?,
@ColumnInfo(name = "raw_amount")
val rawAmount: String?,
@ColumnInfo(name = "decimals")
val decimals: Int?,
/**
* Match key for REFUND leg
*/
@ColumnInfo(name = "hash")
val hash: String?,
)
}

View file

@ -0,0 +1,122 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.*
@Entity(
tableName = "express_onramp",
foreignKeys = [
ForeignKey(
entity = ExpressProviderEntity::class,
parentColumns = ["id"],
childColumns = ["provider_id"],
onDelete = ForeignKey.RESTRICT,
),
],
indices = [
Index(value = ["owner_address", "to_network", "updated_at"]),
Index(value = ["owner_address", "payout_hash"]),
],
)
data class ExpressOnrampEntity(
@PrimaryKey
@ColumnInfo(name = "tx_id")
val txId: String,
@ColumnInfo(name = "owner_address")
val ownerAddress: String,
@ColumnInfo(name = "provider_id")
val providerId: String,
/**
* waiting-for-payment
* payment-processing
* paused
* verifying
* sending
* finished
* failed
* expired
* refunded
*/
@ColumnInfo(name = "status")
val status: String,
/**
* ISO-4217
*/
@ColumnInfo(name = "from_currency_code")
val fromCurrencyCode: String,
/**
* Decimal string
*/
@ColumnInfo(name = "from_amount")
val fromAmount: String,
@ColumnInfo(name = "to_network")
val toNetwork: String,
@ColumnInfo(name = "to_token_id")
val toTokenId: String?,
/**
* Estimated amount at creation moment
*/
@ColumnInfo(name = "to_expected_raw_amount")
val toExpectedRawAmount: String,
/**
* Actual provider-confirmed amount
*/
@ColumnInfo(name = "to_actual_raw_amount")
val toActualRawAmount: String?,
@ColumnInfo(name = "to_decimals")
val toDecimals: Int,
/**
* Match key with gateway_tx.hash
*/
@ColumnInfo(name = "payout_hash")
val payoutHash: String?,
@ColumnInfo(name = "external_tx_id")
val externalTxId: String?,
@ColumnInfo(name = "external_tx_url")
val externalTxUrl: String?,
/**
* fixed / float
*/
@ColumnInfo(name = "rate_type")
val rateType: String,
@ColumnInfo(name = "fail_reason")
val failReason: String?,
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
@Embedded(prefix = "refund_")
val refund: RefundEmbedded?,
) {
data class RefundEmbedded(
/**
* ISO-4217
*/
@ColumnInfo(name = "currency_code")
val currencyCode: String?,
@ColumnInfo(name = "amount")
val amount: String?,
)
}

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.local.txhistory.db.entity.express
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "express_provider",
)
data class ExpressProviderEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "icon_url")
val iconUrl: String,
@ColumnInfo(name = "provider_url")
val providerUrl: String,
)

View file

@ -0,0 +1,38 @@
package com.tangem.datasource.local.txhistory.store
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class DefaultTxHistoryStore(
private val expressExchangeStore: DataStore<Map<CommonSyncStateKey, CommonSyncState>>,
private val expressOnrampStore: DataStore<Map<CommonSyncStateKey, CommonSyncState>>,
) : TxHistoryStore {
override fun expressExchangeSyncState(key: CommonSyncStateKey): Flow<CommonSyncState> {
return expressExchangeStore.data.map { map -> map.getOrDefault(key) }
}
override fun expressOnrampSyncState(key: CommonSyncStateKey): Flow<CommonSyncState> {
return expressOnrampStore.data.map { map -> map.getOrDefault(key) }
}
override suspend fun updateExpressExchangeSyncState(
key: CommonSyncStateKey,
value: CommonSyncState,
): CommonSyncState {
return expressExchangeStore.updateData { map -> map.plus(key to value) }
.getOrDefault(key)
}
override suspend fun updateExpressOnrampSyncState(
key: CommonSyncStateKey,
value: CommonSyncState,
): CommonSyncState {
return expressOnrampStore.updateData { map -> map.plus(key to value) }
.getOrDefault(key)
}
private fun Map<CommonSyncStateKey, CommonSyncState>.getOrDefault(key: CommonSyncStateKey): CommonSyncState =
this.getOrDefault(key, CommonSyncState.default(key))
}

View file

@ -0,0 +1,27 @@
package com.tangem.datasource.local.txhistory.store
import com.tangem.domain.models.account.AccountId
import kotlinx.serialization.Serializable
@Serializable
data class CommonSyncStateKey(
val accountId: AccountId,
val address: String,
)
@Serializable
data class CommonSyncState(
val accountId: AccountId,
val address: String,
val isInitialCompleted: Boolean,
val cursor: String?,
) {
companion object {
fun default(key: CommonSyncStateKey) = CommonSyncState(
accountId = key.accountId,
address = key.address,
isInitialCompleted = false,
cursor = null,
)
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.datasource.local.txhistory.store
import kotlinx.coroutines.flow.Flow
interface TxHistoryStore {
fun expressExchangeSyncState(key: CommonSyncStateKey): Flow<CommonSyncState>
fun expressOnrampSyncState(key: CommonSyncStateKey): Flow<CommonSyncState>
suspend fun updateExpressExchangeSyncState(key: CommonSyncStateKey, value: CommonSyncState): CommonSyncState
suspend fun updateExpressOnrampSyncState(key: CommonSyncStateKey, value: CommonSyncState): CommonSyncState
}

View file

@ -0,0 +1,56 @@
package com.tangem.datasource.utils
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonBuilder
import java.io.InputStream
import java.io.OutputStream
/**
* Kotlinx Serialization serializer for [androidx.datastore.core.DataStore]
*
*/
class KotlinxDataStoreSerializer<T>(
override val defaultValue: T,
private val serializer: KSerializer<T>,
private val json: Json = DefaultJson,
) : Serializer<T> {
override suspend fun readFrom(input: InputStream): T {
return try {
input.bufferedReader().use { reader ->
json.decodeFromString(
deserializer = serializer,
string = reader.readText(),
)
}
} catch (e: Exception) {
throw CorruptionException("Failed to deserialize data", e)
}
}
override suspend fun writeTo(t: T, output: OutputStream) {
output.bufferedWriter().use { writer ->
writer.write(
json.encodeToString(
serializer = serializer,
value = t,
),
)
}
}
companion object {
private val DefaultJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
fun jsonBuilder(builderAction: JsonBuilder.() -> Unit): Json {
return Json(DefaultJson, builderAction)
}
}
}