Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-29 13:06:43 +03:00
parent d5cd5f1951
commit 2ee3bce46a
11 changed files with 22 additions and 357 deletions

View file

@ -19,7 +19,7 @@ import com.tangem.domain.card.common.TapWorkarounds.isNotSupportedInThatRelease
import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.card.common.TapWorkarounds.isVisa
import com.tangem.domain.common.TwinsHelper
import com.tangem.domain.card.common.TwinsHelper
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.card.configs.CardConfig

View file

@ -5,7 +5,7 @@ import com.tangem.common.card.EllipticCurve
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.CompletionCallback
import com.tangem.domain.common.TwinsHelper
import com.tangem.domain.card.common.TwinsHelper
import com.tangem.operations.attestation.AttestationTask
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.operations.wallet.CreateWalletTask

View file

@ -8,7 +8,7 @@ import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.CompletionCallback
import com.tangem.common.extensions.hexToBytes
import com.tangem.domain.common.TwinsHelper
import com.tangem.domain.card.common.TwinsHelper
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.operations.wallet.CreateWalletTask
import com.tangem.operations.wallet.PurgeWalletCommand

View file

@ -1,78 +0,0 @@
package com.tangem.tap.domain.twins
import com.tangem.Message
import com.tangem.common.CompletionResult
import com.tangem.common.KeyPair
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.operations.wallet.CreateWalletResponse
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
class TwinCardsManager(card: CardDTO) {
private val issuersConfigStorage by lazy(mode = LazyThreadSafetyMode.NONE) {
store.inject(DaggerGraphState::issuersConfigStorage)
}
private val firstCardId: String = card.cardId
private val publicKey: String = card.issuer.publicKey.toHexString()
private var currentCardPublicKey: String? = null
private var secondCardPublicKey: String? = null
suspend fun createFirstWallet(message: Message): CompletionResult<CreateWalletResponse> {
val response = tangemSdkManager.createFirstTwinWallet(cardId = firstCardId, initialMessage = message)
if (response is CompletionResult.Success) {
currentCardPublicKey = response.data.wallet.publicKey.toHexString()
}
return response
}
suspend fun createSecondWallet(
initialMessage: Message,
preparingMessage: Message,
creatingWalletMessage: Message,
): CompletionResult<CreateWalletResponse> {
val response = tangemSdkManager.createSecondTwinWallet(
firstPublicKey = requireNotNull(currentCardPublicKey),
firstCardId = firstCardId,
issuerKeys = getIssuerKeys(),
preparingMessage = preparingMessage,
creatingWalletMessage = creatingWalletMessage,
initialMessage = initialMessage,
)
if (response is CompletionResult.Success) {
secondCardPublicKey = response.data.wallet.publicKey.toHexString()
}
return response
}
suspend fun complete(message: Message): CompletionResult<ScanResponse> {
val response = tangemSdkManager.finalizeTwin(
secondCardPublicKey = requireNotNull(secondCardPublicKey).hexToBytes(),
issuerKeyPair = getIssuerKeys(),
cardId = firstCardId,
initialMessage = message,
)
return response
}
private suspend fun getIssuerKeys(): KeyPair {
val issuer = issuersConfigStorage.getConfig().first { it.publicKey == publicKey }
return KeyPair(
publicKey = issuer.publicKey.hexToBytes(),
privateKey = issuer.privateKey.hexToBytes(),
)
}
}

View file

@ -1,174 +0,0 @@
package com.tangem.tap.domain.twins
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.view.View
import androidx.core.animation.doOnEnd
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.common.TwinCardNumber
import com.tangem.sdk.ui.widget.leapfrogWidget.LeapView
import com.tangem.sdk.ui.widget.leapfrogWidget.LeapViewState
import com.tangem.sdk.ui.widget.leapfrogWidget.LeapfrogWidget
/**
[REDACTED_AUTHOR]
*/
class TwinsCardWidget(
val leapfrogWidget: LeapfrogWidget,
private val deviceScaleFactor: Float = 1f,
val getTopOfAnchorViewForActivateState: () -> Float,
) {
var state: TwinCardsWidgetState? = null
private set
private val animationDuration = 300L
init {
if (leapfrogWidget.getViewsCount() != 2) throw UnsupportedOperationException()
}
fun toWelcome(animate: Boolean = true, onEnd: VoidCallback = {}) {
if (state == TwinCardsWidgetState.Welcome) return
state = TwinCardsWidgetState.Welcome
val animator = createAnimator(animate)
animator.playTogether(
createAnimator(TwinCardNumber.First, createWelcomeProperties(TwinCardNumber.First)),
createAnimator(TwinCardNumber.Second, createWelcomeProperties(TwinCardNumber.Second)),
)
animator.doOnEnd { onEnd() }
leapfrogWidget.fold(animate) { animator.start() }
}
fun toLeapfrog(animate: Boolean = true, onEnd: VoidCallback = {}) {
if (state == TwinCardsWidgetState.Leapfrog) return
state = TwinCardsWidgetState.Leapfrog
val animator = createAnimator(animate)
animator.playTogether(
createAnimator(TwinCardNumber.First, createLeapfrogProperties(TwinCardNumber.First)),
createAnimator(TwinCardNumber.Second, createLeapfrogProperties(TwinCardNumber.Second)),
)
animator.doOnEnd {
leapfrogWidget.initViews()
leapfrogWidget.unfold(animate, onEnd)
}
animator.start()
}
fun toActivate(animate: Boolean = true, onEnd: VoidCallback = {}) {
if (state == TwinCardsWidgetState.Activate) return
state = TwinCardsWidgetState.Activate
val animator = createAnimator(animate)
animator.playTogether(
createAnimator(TwinCardNumber.First, createActivateProperties(TwinCardNumber.First)),
createAnimator(TwinCardNumber.Second, createActivateProperties(TwinCardNumber.Second)),
)
animator.doOnEnd { onEnd() }
animator.start()
}
private fun createAnimator(animate: Boolean): AnimatorSet {
return AnimatorSet().apply {
duration = if (animate) animationDuration else 0
}
}
private fun createAnimator(cardNumber: TwinCardNumber, properties: TwinsCardProperties): ObjectAnimator {
val view = getLeapViewByCardNumber(cardNumber).view
val animator = ObjectAnimator.ofPropertyValuesHolder(view, *properties.createValuesHolders().toTypedArray())
view.elevation = properties.elevation
return animator
}
@Suppress("MagicNumber")
private fun createWelcomeProperties(cardNumber: TwinCardNumber): TwinsCardProperties {
return when (cardNumber) {
TwinCardNumber.First -> {
TwinsCardProperties(
xTranslation = -120f * deviceScaleFactor,
yTranslation = -220f * deviceScaleFactor,
rotation = -3f,
elevation = 1f,
scale = deviceScaleFactor,
)
}
TwinCardNumber.Second -> {
TwinsCardProperties(
xTranslation = 170f * deviceScaleFactor,
yTranslation = 170f * deviceScaleFactor,
rotation = -3f,
elevation = 0f,
scale = deviceScaleFactor,
)
}
}
}
private fun createLeapfrogProperties(cardNumber: TwinCardNumber): TwinsCardProperties {
val twinProperties = TwinsCardProperties.from(getLeapViewByCardNumber(cardNumber).state)
return twinProperties.copy(
xTranslation = 0f,
yTranslation = 0f,
rotation = 0f,
scale = 1f,
)
}
@Suppress("MagicNumber")
private fun createActivateProperties(cardNumber: TwinCardNumber): TwinsCardProperties {
val topOfAnchorView = getTopOfAnchorViewForActivateState()
val twinProperties = TwinsCardProperties.from(getLeapViewByCardNumber(cardNumber).state)
return twinProperties.copy(
xTranslation = twinProperties.xTranslation,
yTranslation = twinProperties.yTranslation - topOfAnchorView,
rotation = 0f,
scale = (twinProperties.scale - 0.5f) * deviceScaleFactor,
)
}
private fun getLeapViewByCardNumber(cardNumber: TwinCardNumber): LeapView {
return when (cardNumber) {
TwinCardNumber.First -> leapfrogWidget.getViewByPosition(0)
TwinCardNumber.Second -> leapfrogWidget.getViewByPosition(1)
}
}
}
enum class TwinCardsWidgetState {
Welcome, Leapfrog, Activate
}
private data class TwinsCardProperties(
val xTranslation: Float = 0f,
val yTranslation: Float = 0f,
val rotation: Float = 0f,
val elevation: Float = 0f,
val scale: Float = 1.1f,
) {
fun createValuesHolders(): List<PropertyValuesHolder> {
return listOf(
PropertyValuesHolder.ofFloat(View.TRANSLATION_X, xTranslation),
PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, yTranslation),
PropertyValuesHolder.ofFloat(View.ROTATION, rotation),
PropertyValuesHolder.ofFloat(View.SCALE_X, scale),
PropertyValuesHolder.ofFloat(View.SCALE_Y, scale),
)
}
companion object {
fun from(leapViewState: LeapViewState): TwinsCardProperties {
val leapViewProperties = leapViewState.properties
return TwinsCardProperties(
yTranslation = leapViewProperties.yTranslation,
elevation = leapViewProperties.elevationEnd,
scale = leapViewProperties.scale,
)
}
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.tap.domain.twins
import com.tangem.common.core.TangemError
import com.tangem.domain.common.TwinCardNumber
import com.tangem.domain.card.common.TwinCardNumber
import com.tangem.tap.tangemSdkManager
import com.tangem.wallet.R

View file

@ -1,9 +1,8 @@
package com.tangem.domain.card
package com.tangem.domain.card.common
import com.tangem.domain.common.TwinsHelper
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert
import org.junit.Test
import org.junit.jupiter.api.Assertions.*
class TwinsHelperTest {
@ -19,7 +18,7 @@ class TwinsHelperTest {
@Test
fun `twins compatibility pack 1 success`() {
assertTrue(TwinsHelper.isTwinsCompatible(pack1Twins[0], pack1Twins[1]))
Assert.assertTrue(TwinsHelper.isTwinsCompatible(pack1Twins[0], pack1Twins[1]))
assertTrue(TwinsHelper.isTwinsCompatible(pack1Twins[1], pack1Twins[0]))
}

View file

@ -1,90 +0,0 @@
package com.tangem.domain.common
import com.tangem.crypto.CryptoUtils
import com.tangem.domain.models.scan.CardDTO
object TwinsHelper {
/**
* Card compatibility
* cb61 <-> cb62
* cb64 <-> cb65
*
*/
private const val FIRST_CARD_FIRST_SERIES = "CB61"
private const val SECOND_CARD_FIRST_SERIES = "CB62"
private const val FIRST_CARD_SECOND_SERIES = "CB64"
private const val SECOND_CARD_SECOND_SERIES = "CB65"
@Suppress("MagicNumber")
fun verifyTwinPublicKey(issuerData: ByteArray, cardWalletPublicKey: ByteArray?): Boolean {
if (issuerData.size < 65 || cardWalletPublicKey == null) return false
val publicKey = issuerData.sliceArray(0 until 65)
val signedKey = issuerData.sliceArray(65 until issuerData.size)
return CryptoUtils.verify(cardWalletPublicKey, publicKey, signedKey)
}
fun getTwinCardNumber(cardId: String): TwinCardNumber? {
val isFirstCard = cardId.startsWith(FIRST_CARD_FIRST_SERIES) ||
cardId.startsWith(FIRST_CARD_SECOND_SERIES)
if (isFirstCard) return TwinCardNumber.First
val isSecondCard = cardId.startsWith(SECOND_CARD_FIRST_SERIES) ||
cardId.startsWith(SECOND_CARD_SECOND_SERIES)
if (isSecondCard) return TwinCardNumber.Second
return null
}
@Suppress("MagicNumber")
fun getTwinCardIdForUser(cardId: String): String {
if (cardId.length < 16) return cardId
val twinCardId = cardId.substring(11..14)
val twinCardNumber = getTwinCardNumber(cardId)?.number ?: 1
return "$twinCardId #$twinCardNumber"
}
/**
* Twins compatibility
* cb61 <-> cb62
* cb64 <-> cb65
*/
fun isTwinsCompatible(firstCardId: String, secondCardId: String): Boolean {
return secondCardId.startsWith(getCompatibleTwinCardId(firstCardId))
}
private fun getCompatibleTwinCardId(cardId: String): String {
return when {
cardId.startsWith(FIRST_CARD_FIRST_SERIES) -> SECOND_CARD_FIRST_SERIES
cardId.startsWith(FIRST_CARD_SECOND_SERIES) -> SECOND_CARD_SECOND_SERIES
cardId.startsWith(SECOND_CARD_FIRST_SERIES) -> FIRST_CARD_FIRST_SERIES
cardId.startsWith(SECOND_CARD_SECOND_SERIES) -> FIRST_CARD_SECOND_SERIES
else -> {
"unknown"
}
}
}
}
enum class TwinCardNumber(val number: Int) {
First(1), Second(2);
fun pairNumber(): TwinCardNumber = when (this) {
First -> Second
Second -> First
}
}
@Deprecated("Use ScanResponse.isTangemTwin")
fun CardDTO.isTangemTwin(): Boolean {
return TwinsHelper.getTwinCardNumber(cardId) != null
}
fun CardDTO.getTwinCardNumber(): TwinCardNumber? {
return TwinsHelper.getTwinCardNumber(this.cardId)
}
fun CardDTO.getTwinCardIdForUser(): String {
return TwinsHelper.getTwinCardIdForUser(this.cardId)
}

View file

@ -2,8 +2,8 @@ package com.tangem.domain.wallets.usecase
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.services.Result
import com.tangem.domain.common.TwinCardNumber
import com.tangem.domain.common.TwinsHelper
import com.tangem.domain.card.common.TwinCardNumber
import com.tangem.domain.card.common.TwinsHelper
import com.tangem.domain.models.ArtworkModel
import com.tangem.domain.wallets.models.Artwork
import com.tangem.operations.attestation.ArtworkSize

View file

@ -16,9 +16,9 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.toWrappedList
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
import com.tangem.domain.card.common.TwinCardNumber
import com.tangem.domain.card.common.getTwinCardNumber
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.TwinCardNumber
import com.tangem.domain.common.getTwinCardNumber
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ScanResponse
@ -68,6 +68,7 @@ internal class OnboardingTwinModel @Inject constructor(
private val params = paramsContainer.require<OnboardingTwinComponent.Params>()
private val firstCardTwinNumber = params.scanResponse.card.getTwinCardNumber() ?: error("Not twin")
private val cryptoCurrencyStatusJobHolder = JobHolder()
private var twinCardsIds = Pair<String?, String?>(null, null)
private val _uiState = MutableStateFlow(
when (params.mode) {
@ -159,6 +160,7 @@ internal class OnboardingTwinModel @Inject constructor(
}
is CompletionResult.Success -> {
if (params.mode == Mode.CreateWallet) {
twinCardsIds = twinCardsIds.copy(first = result.data.cardId)
cardRepository.startCardActivation(result.data.cardId)
}
@ -216,6 +218,7 @@ internal class OnboardingTwinModel @Inject constructor(
}
is CompletionResult.Success -> {
if (params.mode == Mode.CreateWallet) {
twinCardsIds = twinCardsIds.copy(second = result.data.cardId)
cardRepository.startCardActivation(result.data.cardId)
}
@ -299,7 +302,12 @@ internal class OnboardingTwinModel @Inject constructor(
return@coroutineScope
}
cardRepository.finishCardActivation(params.scanResponse.card.cardId)
twinCardsIds.first?.let {
cardRepository.finishCardActivation(it)
}
twinCardsIds.second?.let {
cardRepository.finishCardActivation(it)
}
params.modelCallbacks.onDone()
}

@ -1 +1 @@
Subproject commit b64f8659fce65e7749d50923b32bfad13e6b8cac
Subproject commit 193db8a0247a1f33574dcdd5f9ec4dab2c28ea73