Updated on 2026-08-14

This commit is contained in:
Tangem 2022-11-28 18:26:31 +03:00
parent e72bf276da
commit 41dea25009
17 changed files with 348 additions and 23 deletions

View file

@ -0,0 +1,8 @@
package com.tangem.tap
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
interface ActivityResultCaller {
val activityResultLauncher: ActivityResultLauncher<Intent>?
}

View file

@ -2,11 +2,18 @@ package com.tangem.tap
import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.content.Intent
import android.os.Bundle
import java.util.WeakHashMap
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import java.util.*
import kotlin.reflect.KClass
class ForegroundActivityObserver {
class ForegroundActivityObserver : ActivityResultCaller {
override var activityResultLauncher: ActivityResultLauncher<Intent>? = null
private set
private val activities = WeakHashMap<KClass<out Activity>, Activity>()
val foregroundActivity: Activity?
@ -15,25 +22,38 @@ class ForegroundActivityObserver {
.firstOrNull()
?.value
val callbacks get() = Callbacks()
internal val callbacks: ActivityLifecycleCallbacks
get() = Callbacks()
internal inner class Callbacks : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
activityResultLauncher = (activity as? AppCompatActivity)?.registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {
/* no-op */
}
}
inner class Callbacks : ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) {
activities[activity::class] = activity
}
override fun onActivityDestroyed(activity: Activity) {
activities.remove(activity::class)
if (activities.isEmpty()) {
activityResultLauncher = null
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
}
override fun onActivityStarted(activity: Activity) {
}
override fun onActivityPaused(activity: Activity) {
}
override fun onActivityStopped(activity: Activity) {
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
}
}

View file

@ -53,6 +53,7 @@ import javax.inject.Inject
lateinit var store: Store<AppState>
lateinit var foregroundActivityObserver: ForegroundActivityObserver
lateinit var activityResultCaller: ActivityResultCaller
lateinit var preferencesStorage: PreferencesStorage
lateinit var walletConnectRepository: WalletConnectRepository
lateinit var shopService: TangemShopService
@ -82,6 +83,7 @@ class TapApplication : Application(), ImageLoaderFactory {
}
foregroundActivityObserver = ForegroundActivityObserver()
activityResultCaller = foregroundActivityObserver
registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks)
initMoshiConverter()

View file

@ -10,6 +10,7 @@ import com.tangem.tap.features.onboarding.products.note.redux.OnboardingNoteRedu
import com.tangem.tap.features.onboarding.products.otherCards.redux.OnboardingOtherCardsReducer
import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsReducer
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletReducer
import com.tangem.tap.features.saveWallet.redux.SaveWalletReducer
import com.tangem.tap.features.send.redux.reducers.SendScreenReducer
import com.tangem.tap.features.shop.redux.ShopReducer
import com.tangem.tap.features.tokens.redux.TokensReducer
@ -38,6 +39,7 @@ fun appReducer(action: Action, state: AppState?, appStateHolder: AppStateHolder)
walletConnectState = WalletConnectReducer.reduce(action, state.walletConnectState),
shopState = ShopReducer.reduce(action, state.shopState),
welcomeState = WelcomeReducer.reduce(action, state),
saveWalletState = SaveWalletReducer.reduce(action, state),
)
}

View file

@ -25,6 +25,7 @@ import com.tangem.tap.features.onboarding.products.wallet.redux.BackupMiddleware
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletMiddleware
import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState
import com.tangem.tap.features.onboarding.products.wallet.saltPay.redux.OnboardingSaltPayMiddleware
import com.tangem.tap.features.saveWallet.redux.SaveWalletMiddleware
import com.tangem.tap.features.saveWallet.redux.SaveWalletState
import com.tangem.tap.features.send.redux.middlewares.SendMiddleware
import com.tangem.tap.features.send.redux.states.SendState
@ -86,6 +87,7 @@ data class AppState(
BackupMiddleware().backupMiddleware,
ShopMiddleware().shopMiddleware,
WelcomeMiddleware().middleware,
SaveWalletMiddleware().middleware,
)
}
}

View file

@ -18,6 +18,8 @@ sealed class NavigationAction : Action {
data class OpenDocument(val url: Uri) : NavigationAction()
object OpenBiometricsSettings : NavigationAction()
data class Share(val data: String) : NavigationAction()
data class ActivityCreated(val activity: WeakReference<AppCompatActivity>) : NavigationAction()

View file

@ -1,6 +1,10 @@
package com.tangem.tap.common.redux.navigation
import android.content.Intent
import android.hardware.biometrics.BiometricManager
import android.os.Build
import android.provider.Settings
import com.tangem.tap.activityResultCaller
import com.tangem.tap.common.CustomTabsManager
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.openFragment
@ -48,6 +52,28 @@ val navigationMiddleware: Middleware<AppState> = { _, state ->
intent.data = action.url
navState?.activity?.get()?.startActivity(intent)
}
is NavigationAction.OpenBiometricsSettings -> {
val settingsAction = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
Settings.ACTION_BIOMETRIC_ENROLL
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> {
Settings.ACTION_FINGERPRINT_ENROLL
}
else -> {
Settings.ACTION_SETTINGS
}
}
val intent = Intent(settingsAction).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
putExtra(
Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED,
BiometricManager.Authenticators.BIOMETRIC_STRONG,
)
}
}
activityResultCaller.activityResultLauncher?.launch(intent)
}
is NavigationAction.Share -> {
navState?.activity?.get()?.shareText(action.data)
}

View file

@ -5,19 +5,12 @@ import androidx.annotation.StringRes
import com.tangem.Message
import com.tangem.TangemSdk
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.CardFilter
import com.tangem.common.CompletionResult
import com.tangem.common.SuccessResponse
import com.tangem.common.UserCodeType
import com.tangem.common.*
import com.tangem.common.card.FirmwareVersion
import com.tangem.common.core.CardIdDisplayFormat
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.Config
import com.tangem.common.core.TangemSdkError
import com.tangem.common.core.UserCodeRequestPolicy
import com.tangem.common.core.*
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.hdWallet.DerivationPath
import com.tangem.common.map
import com.tangem.common.usersCode.UserCodeRepository
import com.tangem.domain.common.CardDTO
import com.tangem.domain.common.ScanResponse
import com.tangem.operations.CommandResponse
@ -43,6 +36,11 @@ import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Context) {
val canUseBiometry: Boolean
get() = tangemSdk.biometricManager.canAuthenticate || canEnrollBiometrics
val canEnrollBiometrics: Boolean
get() = tangemSdk.biometricManager.canEnrollBiometrics
suspend fun scanProduct(
userTokensRepository: UserTokensRepository,
@ -104,6 +102,16 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
.map { CardDTO(it) }
}
suspend fun saveAccessCode(accessCode: String, cardsIds: Set<String>): CompletionResult<Unit> {
return createUserCodeRepository().save(
cardIds = cardsIds,
userCode = UserCode(
type = UserCodeType.AccessCode,
stringValue = accessCode,
),
)
}
suspend fun setPasscode(cardId: String?): CompletionResult<SuccessResponse> {
return runTaskAsyncReturnOnMain(
SetUserCodeCommand.changePasscode(null),
@ -136,9 +144,14 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
)
}
suspend fun scanCard(): CompletionResult<CardDTO> {
suspend fun scanCard(
cardId: String? = null,
useBiometricsForAccessCode: Boolean = false,
): CompletionResult<CardDTO> {
setAccessCodeRequestPolicy(useBiometricsForAccessCode)
return runTaskAsyncReturnOnMain(
runnable = ScanTask(),
cardId = cardId,
initialMessage = Message(context.getString(R.string.initial_message_tap_header)),
)
.map { CardDTO(it) }
@ -190,6 +203,13 @@ class TangemSdkManager(private val tangemSdk: TangemSdk, private val context: Co
}
}
private fun createUserCodeRepository() = with(tangemSdk) {
UserCodeRepository(
biometricManager = biometricManager,
secureStorage = secureStorage,
)
}
companion object {
val config = Config(
linkedTerminal = true,

View file

@ -15,6 +15,9 @@ data class UserWallet(
val cardId: String
get() = scanResponse.card.cardId
val hasAccessCode: Boolean
get() = scanResponse.card.isAccessCodeSet
companion object {
suspend operator fun invoke(
scanResponse: ScanResponse,

View file

@ -1,9 +1,16 @@
package com.tangem.tap.features.saveWallet.redux
import com.tangem.common.core.TangemError
import com.tangem.domain.common.ScanResponse
import org.rekotlin.Action
internal sealed interface SaveWalletAction : Action {
data class ProvideAdditionalInfo(
val scanResponse: ScanResponse,
val accessCode: String?,
val backupCardsIds: Set<String>?,
) : SaveWalletAction
object Save : SaveWalletAction {
object Success : SaveWalletAction
data class Error(val error: TangemError) : SaveWalletAction
@ -12,6 +19,10 @@ internal sealed interface SaveWalletAction : Action {
object Dismiss : SaveWalletAction
object CloseError : SaveWalletAction
object EnrollBiometrics : SaveWalletAction {
object Enroll : SaveWalletAction
object Cancel : SaveWalletAction
}
object SaveWalletWasShown : SaveWalletAction
}

View file

@ -0,0 +1,124 @@
package com.tangem.tap.features.saveWallet.redux
import com.tangem.common.CompletionResult
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.flatMap
import com.tangem.common.map
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.common.extensions.onUserWalletSelected
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.domain.model.UserWallet
import com.tangem.tap.preferencesStorage
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.tap.tangemSdkManager
import com.tangem.tap.userWalletsListManager
import kotlinx.coroutines.launch
import org.rekotlin.Middleware
internal class SaveWalletMiddleware {
val middleware: Middleware<AppState> = { _, stateProvider ->
{ next ->
{ action ->
val state = stateProvider()
if (action is SaveWalletAction && state != null) {
handleAction(action, state.saveWalletState)
}
next(action)
}
}
}
private fun handleAction(action: SaveWalletAction, state: SaveWalletState) {
when (action) {
is SaveWalletAction.Save -> saveWalletIfBiometricsEnrolled(state)
is SaveWalletAction.Save.Success -> popBack()
is SaveWalletAction.EnrollBiometrics.Enroll -> enrollBiometrics()
is SaveWalletAction.SaveWalletWasShown -> saveWalletWasShown()
is SaveWalletAction.ProvideAdditionalInfo,
is SaveWalletAction.Dismiss,
is SaveWalletAction.CloseError,
is SaveWalletAction.Save.Error,
is SaveWalletAction.EnrollBiometrics,
is SaveWalletAction.EnrollBiometrics.Cancel,
-> Unit
}
}
private fun enrollBiometrics() {
store.dispatchOnMain(NavigationAction.OpenBiometricsSettings)
}
private fun saveWalletIfBiometricsEnrolled(state: SaveWalletState) {
if (tangemSdkManager.canEnrollBiometrics) {
store.dispatchOnMain(SaveWalletAction.EnrollBiometrics)
} else {
saveWallet(state)
}
}
private fun saveWallet(state: SaveWalletState) {
val scanResponse = state.additionalInfo?.scanResponse
?: store.state.globalState.scanResponse
?: return
scope.launch {
val userWallet = UserWallet(
scanResponse = scanResponse,
backupCardsIds = state.additionalInfo?.backupCardsIds,
)
userWalletsListManager.save(userWallet)
.flatMap {
trySaveAccessCode(
userWallet = userWallet,
additionalInfo = state.additionalInfo,
)
}
.doOnFailure { error ->
store.dispatchOnMain(SaveWalletAction.Save.Error(error))
}
.doOnSuccess {
preferencesStorage.shouldSaveUserWallets = true
preferencesStorage.shouldSaveAccessCodes = true
store.dispatchOnMain(SaveWalletAction.Save.Success)
store.onUserWalletSelected(userWallet)
}
}
}
private fun popBack() {
store.dispatchOnMain(NavigationAction.PopBackTo())
}
private fun saveWalletWasShown() {
preferencesStorage.shouldShowSaveWallet = false
}
private suspend fun trySaveAccessCode(
userWallet: UserWallet,
additionalInfo: SaveWalletState.WalletAdditionalInfo?,
): CompletionResult<Unit> {
return when {
additionalInfo?.accessCode != null -> {
tangemSdkManager.saveAccessCode(
accessCode = additionalInfo.accessCode,
cardsIds = userWallet.cardsInWallet,
)
}
userWallet.hasAccessCode -> {
tangemSdkManager
.scanCard(
cardId = userWallet.cardId,
useBiometricsForAccessCode = true,
)
.map { /* no-op */ }
}
else -> {
CompletionResult.Success(Unit)
}
}
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.tap.features.saveWallet.redux
import com.tangem.tap.common.redux.AppState
import org.rekotlin.Action
internal object SaveWalletReducer {
fun reduce(action: Action, state: AppState): SaveWalletState {
return if (action is SaveWalletAction) {
internalReduce(action, state.saveWalletState)
} else state.saveWalletState
}
private fun internalReduce(action: SaveWalletAction, state: SaveWalletState): SaveWalletState {
return when (action) {
is SaveWalletAction.ProvideAdditionalInfo -> state.copy(
additionalInfo = SaveWalletState.WalletAdditionalInfo(
scanResponse = action.scanResponse,
accessCode = action.accessCode,
backupCardsIds = action.backupCardsIds,
),
)
is SaveWalletAction.Save -> state.copy(
isSaveInProgress = true,
)
is SaveWalletAction.Save.Error -> state.copy(
error = action.error,
isSaveInProgress = false,
)
is SaveWalletAction.Save.Success -> state.copy(
additionalInfo = null,
isSaveInProgress = false,
)
is SaveWalletAction.Dismiss -> state.copy(
additionalInfo = null,
)
is SaveWalletAction.CloseError -> state.copy(
error = null,
)
is SaveWalletAction.EnrollBiometrics -> state.copy(
needEnrollBiometrics = true,
)
is SaveWalletAction.EnrollBiometrics.Enroll,
is SaveWalletAction.EnrollBiometrics.Cancel,
-> state.copy(
needEnrollBiometrics = false,
isSaveInProgress = false,
)
is SaveWalletAction.SaveWalletWasShown -> state
}
}
}

View file

@ -1,5 +1,18 @@
package com.tangem.tap.features.saveWallet.redux
import com.tangem.common.core.TangemError
import com.tangem.domain.common.ScanResponse
import org.rekotlin.StateType
class SaveWalletState : StateType
data class SaveWalletState(
val additionalInfo: WalletAdditionalInfo? = null,
val isSaveInProgress: Boolean = false,
val needEnrollBiometrics: Boolean = false,
val error: TangemError? = null,
) : StateType {
data class WalletAdditionalInfo(
val scanResponse: ScanResponse,
val accessCode: String?,
val backupCardsIds: Set<String>?,
)
}

View file

@ -20,6 +20,7 @@ import com.tangem.core.ui.components.dialogs.EnrollBiometricsDialogContent
import com.tangem.core.ui.fragments.ComposeBottomSheetFragment
import com.tangem.core.ui.models.EnrollBiometricsDialog
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.details.ui.cardsettings.resolveReference
import com.tangem.tap.features.saveWallet.ui.components.SaveWalletScreenContent
internal class SaveWalletBottomSheetFragment : ComposeBottomSheetFragment<SaveWalletScreenState>() {
@ -43,7 +44,7 @@ internal class SaveWalletBottomSheetFragment : ComposeBottomSheetFragment<SaveWa
state: SaveWalletScreenState,
) {
val snackbarHostState = remember { SnackbarHostState() }
val errorMessage by rememberUpdatedState(newValue = state.error?.customMessage)
val errorMessage by rememberUpdatedState(newValue = state.error?.resolveReference())
val enrollBiometricsDialog by rememberUpdatedState(newValue = state.enrollBiometricsDialog)
Box(modifier = modifier) {

View file

@ -1,12 +1,12 @@
package com.tangem.tap.features.saveWallet.ui
import androidx.compose.runtime.Immutable
import com.tangem.common.core.TangemError
import com.tangem.core.ui.models.EnrollBiometricsDialog
import com.tangem.tap.features.details.ui.cardsettings.TextReference
@Immutable
internal data class SaveWalletScreenState(
val showProgress: Boolean = false,
val enrollBiometricsDialog: EnrollBiometricsDialog? = null,
val error: TangemError? = null,
val error: TextReference? = null,
)

View file

@ -1,7 +1,9 @@
package com.tangem.tap.features.saveWallet.ui
import androidx.lifecycle.ViewModel
import com.tangem.core.ui.models.EnrollBiometricsDialog
import com.tangem.tap.common.extensions.dispatchOnMain
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.tap.features.saveWallet.redux.SaveWalletAction
import com.tangem.tap.features.saveWallet.redux.SaveWalletState
import com.tangem.tap.store
@ -33,8 +35,16 @@ internal class SaveWalletViewModel : ViewModel(), StoreSubscriber<SaveWalletStat
override fun newState(state: SaveWalletState) {
stateInternal.update { prevState ->
// TODO: Update screen state
prevState
prevState.copy(
showProgress = state.isSaveInProgress,
enrollBiometricsDialog = if (state.needEnrollBiometrics) createEnrollBiometricsDialog() else null,
error = state.error
?.takeUnless { it.silent }
?.let { error ->
error.messageResId?.let { TextReference.Res(it) }
?: TextReference.Str(error.customMessage)
},
)
}
}
@ -48,4 +58,13 @@ internal class SaveWalletViewModel : ViewModel(), StoreSubscriber<SaveWalletStat
.select { it.saveWalletState }
}
}
private fun createEnrollBiometricsDialog() = EnrollBiometricsDialog(
onEnroll = {
store.dispatchOnMain(SaveWalletAction.EnrollBiometrics.Enroll)
},
onCancel = {
store.dispatchOnMain(SaveWalletAction.EnrollBiometrics.Cancel)
},
)
}

View file

@ -33,6 +33,24 @@ class PreferencesStorage(applicationContext: Application) {
get() = preferences.getLong(CHAT_FIRST_LAUNCH_KEY, 0).takeIf { it != 0L }
set(value) = preferences.edit { putLong(CHAT_FIRST_LAUNCH_KEY, value ?: 0) }
var shouldShowSaveWallet: Boolean
get() = preferences.getBoolean(SAVE_WALLET_DIALOG_SHOWN_KEY, true)
set(value) = preferences.edit {
putBoolean(SAVE_WALLET_DIALOG_SHOWN_KEY, value)
}
var shouldSaveUserWallets: Boolean
get() = preferences.getBoolean(SAVE_USER_WALLETS_KEY, false)
set(value) = preferences.edit {
putBoolean(SAVE_USER_WALLETS_KEY, value)
}
var shouldSaveAccessCodes: Boolean
get() = preferences.getBoolean(SAVE_ACCESS_CODES_KEY, false)
set(value) = preferences.edit {
putBoolean(SAVE_ACCESS_CODES_KEY, value)
}
fun getCountOfLaunches(): Int = preferences.getInt(APP_LAUNCH_COUNT_KEY, 1)
fun saveTwinsOnboardingShown() {
@ -53,6 +71,9 @@ class PreferencesStorage(applicationContext: Application) {
private const val TWINS_ONBOARDING_SHOWN_KEY = "twinsOnboardingShown"
private const val APP_LAUNCH_COUNT_KEY = "launchCount"
private const val CHAT_FIRST_LAUNCH_KEY = "chatFirstLaunchKey"
private const val SAVE_WALLET_DIALOG_SHOWN_KEY = "saveUserWalletShown"
private const val SAVE_USER_WALLETS_KEY = "saveUserWallets"
private const val SAVE_ACCESS_CODES_KEY = "saveAccessCodes"
}
}