Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-10 11:49:44 +03:00
commit 6519ad5568
341 changed files with 6153 additions and 6250 deletions

View file

@ -167,6 +167,7 @@ dependencies {
implementation(projects.domain.blockaid)
implementation(projects.domain.hotWallet)
implementation(projects.domain.news)
implementation(projects.domain.earn)
implementation(projects.common)
implementation(projects.common.routing)
@ -220,6 +221,7 @@ dependencies {
implementation(projects.data.yieldSupply)
implementation(projects.data.hotWallet)
implementation(projects.data.news)
implementation(projects.data.earn)
/** Features */
implementation(projects.features.referral.impl)

View file

@ -84,6 +84,7 @@ abstract class BaseTestCase : TestCase(
* and only after that the activity should be launched.
*/
protected fun setupHooks(
additionalBeforeAppLaunchSection: () -> Unit = {},
additionalBeforeSection: () -> Unit = {},
additionalAfterSection: () -> Unit = {},
) = before {
@ -91,6 +92,7 @@ abstract class BaseTestCase : TestCase(
// Setup WireMock redirect for CI with local WireMock instances
val wiremockUrl = InstrumentationRegistry.getArguments().getString(WIREMOCK_BASE_URL_ARG)
WireMockRedirectInterceptor.overriddenBaseUrl = wiremockUrl
additionalBeforeAppLaunchSection()
hiltRule.inject()
runBlocking {
appPreferencesStore.editData { mutablePreferences ->
@ -147,7 +149,9 @@ abstract class BaseTestCase : TestCase(
"NEW_ONRAMP_MAIN_ENABLED" to true,
"HOT_WALLET_ENABLED" to true,
"YIELD_SUPPLY_FEATURE_ENABLED" to true,
"ACCOUNTS_FEATURE_ENABLED" to true
"ACCOUNTS_FEATURE_ENABLED" to true,
"FEED_ENABLED" to true,
"GASLESS_TRANSACTIONS_ENABLED" to true,
)
)
}

View file

@ -1,5 +1,9 @@
package com.tangem.common.extensions
import androidx.compose.ui.test.SemanticsMatcher
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.components.buttons.actions.HasBadgeKey
import com.tangem.core.ui.components.buttons.actions.IsDimmedKey
import io.github.kakaocup.compose.node.element.KNode
fun assertElementDoesNotExist(
@ -22,4 +26,20 @@ fun assertElementDoesNotExist(
throw e
}
}
}
fun Any.assertIsDimmed(expectedValue: Boolean = true) {
val matcher = SemanticsMatcher.expectValue(IsDimmedKey, expectedValue)
when (this) {
is KNode, is LazyListItemNode -> this.assert(matcher)
else -> throw IllegalArgumentException("Unsupported type: ${this::class}")
}
}
fun Any.assertHasBadge(expectedValue: Boolean = true) {
val matcher = SemanticsMatcher.expectValue(HasBadgeKey, expectedValue)
when (this) {
is KNode, is LazyListItemNode -> this.assert(matcher)
else -> throw IllegalArgumentException("Unsupported type: ${this::class}")
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.common.extensions
import androidx.test.uiautomator.By
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.wallet.R
import io.github.kakaocup.kakao.common.utilities.getResourceString
@ -74,8 +75,17 @@ fun BaseTestCase.stopApp(packageName: String) {
}
fun BaseTestCase.launchApp(packageName: String) {
device.apps.waitForAppLaunchAndReady(packageName = packageName)
device.apps.launch(packageName)
device.apps.waitForAppLaunchAndReady(packageName = packageName)
}
fun BaseTestCase.restartApp(packageName: String) {
device.uiDevice.pressHome()
device.apps.kill(packageName)
waitForIdle()
device.apps.launch(packageName)
device.apps.waitForAppLaunchAndReady(timeout = WAIT_UNTIL_TIMEOUT_LONG, packageName = packageName)
waitForIdle()
}
enum class SwipeDirection {

View file

@ -65,8 +65,8 @@ fun BaseTestCase.openMainScreen(
step("Assert 'Main' screen is displayed") {
onMainScreen { screenContainer.assertIsDisplayed() }
}
step("Click on 'Market Tooltip' screen") {
onMarketsTooltipScreen { contentContainer.clickWithAssertion() }
step("Dismiss Market Tooltip by clicking close button") {
onMarketsTooltipScreen { closeButton.clickWithAssertion() }
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.screens.onMainScreen
import com.tangem.screens.onMarketsScreen
import com.tangem.screens.onMarketsTokenDetailsScreen
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName: String) {
step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() }
waitForIdle()
}
step("Click on 'Search' placeholder") {
onMarketsScreen { searchThroughMarketPlaceholder.performClick() }
}
step("Click on $blockchainName blockchain") {
waitForIdle()
onMarketsScreen { tokenWithTitle(blockchainName).clickWithAssertion() }
}
step("Click on $tokenName token") {
waitForIdle()
onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
}
}

View file

@ -0,0 +1,180 @@
package com.tangem.scenarios
import androidx.compose.ui.test.click
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.screens.*
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step
import com.tangem.common.ui.R as CommonUiR
private val firstStoryIndex = 0
private val firstStoryTitle = getResourceString(CommonUiR.string.swap_story_first_title)
private val firstStorySubtitle = getResourceString(CommonUiR.string.swap_story_first_subtitle)
private val secondStoryIndex = 1
private val secondStoryTitle = getResourceString(CommonUiR.string.swap_story_second_title)
private val secondStorySubtitle = getResourceString(CommonUiR.string.swap_story_second_subtitle)
private val thirdStoryIndex = 2
private val thirdStoryTitle = getResourceString(CommonUiR.string.swap_story_third_title)
private val thirdStorySubtitle = getResourceString(CommonUiR.string.swap_story_third_subtitle)
private val forthStoryIndex = 3
private val forthStoryTitle = getResourceString(CommonUiR.string.swap_story_forth_title)
private val forthStorySubtitle = getResourceString(CommonUiR.string.swap_story_forth_subtitle)
private val fifthStoryIndex = 4
private val fifthStoryTitle = getResourceString(CommonUiR.string.swap_story_fifth_title)
private val fifthStorySubtitle = getResourceString(CommonUiR.string.swap_story_fifth_subtitle)
fun BaseTestCase.openSwapScreen(
from: SwapEntryPoint,
storiesExist: Boolean = true,
) {
when (from) {
SwapEntryPoint.MainScreen -> step("Click on 'Swap' button on 'Main' screen") {
onMainScreen { swapButton.performClick() }
}
SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton().performClick() }
}
SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() }
}
SwapEntryPoint.TokenActionsBottomSheet -> step("Click on 'Swap' button on token actions bottom sheet") {
onTokenActionsBottomSheet { swapButton.performClick() }
}
}
if (storiesExist) {
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
}
} else {
step("Assert 'Stories' screen is not displayed") {
onSwapStoriesScreen { container.assertDoesNotExist() }
}
}
step("Assert 'Swap' screen title is displayed") {
onSwapTokenScreen { title.assertIsDisplayed() }
}
}
fun BaseTestCase.checkStoriesContent(
storyIndex: Int,
storyTitle: String,
storySubtitle: String,
) {
step("Assert 'Close' button is displayed") {
onSwapStoriesScreen { closeButton.assertIsDisplayed() }
}
step("Assert progress bar item №${storyIndex + 1} is displayed") {
onSwapStoriesScreen { progressBarItem(storyIndex).assertIsDisplayed() }
}
step("Assert story title is $storyTitle") {
onSwapStoriesScreen { title.assertTextContains(storyTitle) }
}
step("Assert story subtitle is $storySubtitle") {
onSwapStoriesScreen { subtitle.assertTextContains(storySubtitle) }
}
}
fun BaseTestCase.checkStoriesChanges() {
step("Check title and subtitle for story №${firstStoryIndex + 1}") {
checkStoriesContent(
storyIndex = firstStoryIndex,
storyTitle = firstStoryTitle,
storySubtitle = firstStorySubtitle
)
}
step("Click on right side") {
onSwapStoriesScreen { container.performTouchInput { click(centerRight) } }
}
step("Check title and subtitle for story №${secondStoryIndex + 1}") {
checkStoriesContent(
storyIndex = secondStoryIndex,
storyTitle = secondStoryTitle,
storySubtitle = secondStorySubtitle
)
}
step("Click on right side") {
onSwapStoriesScreen { container.performTouchInput { click(centerRight) } }
}
step("Check title and subtitle for story №${thirdStoryIndex + 1}") {
checkStoriesContent(
storyIndex = thirdStoryIndex,
storyTitle = thirdStoryTitle,
storySubtitle = thirdStorySubtitle
)
}
step("Click on right side") {
onSwapStoriesScreen { container.performTouchInput { click(centerRight) } }
}
step("Check title and subtitle for story №${forthStoryIndex + 1}") {
checkStoriesContent(
storyIndex = forthStoryIndex,
storyTitle = forthStoryTitle,
storySubtitle = forthStorySubtitle
)
}
step("Click on right side") {
onSwapStoriesScreen { container.performTouchInput { click(centerRight) } }
}
step("Check title and subtitle for story №${fifthStoryIndex + 1}") {
checkStoriesContent(
storyIndex = fifthStoryIndex,
storyTitle = fifthStoryTitle,
storySubtitle = fifthStorySubtitle
)
}
step("Click on left side") {
onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } }
}
step("Check title and subtitle for story №${forthStoryIndex + 1}") {
checkStoriesContent(
storyIndex = forthStoryIndex,
storyTitle = forthStoryTitle,
storySubtitle = forthStorySubtitle
)
}
step("Click on left side") {
onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } }
}
step("Check title and subtitle for story №${thirdStoryIndex + 1}") {
checkStoriesContent(
storyIndex = thirdStoryIndex,
storyTitle = thirdStoryTitle,
storySubtitle = thirdStorySubtitle
)
}
step("Click on left side") {
onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } }
}
step("Check title and subtitle for story №${secondStoryIndex + 1}") {
checkStoriesContent(
storyIndex = secondStoryIndex,
storyTitle = secondStoryTitle,
storySubtitle = secondStorySubtitle
)
}
step("Click on left side") {
onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } }
}
step("Check title and subtitle for story №${firstStoryIndex + 1}") {
checkStoriesContent(
storyIndex = firstStoryIndex,
storyTitle = firstStoryTitle,
storySubtitle = firstStorySubtitle
)
}
}
sealed class SwapEntryPoint {
object MainScreen : SwapEntryPoint()
object TokenDetails : SwapEntryPoint()
object MarketsTokenDetails : SwapEntryPoint()
object TokenActionsBottomSheet : SwapEntryPoint()
}

View file

@ -45,6 +45,16 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(getResourceString(R.string.common_continue))
}
val addButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_add))
}
val laterButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_later))
}
val okButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_ok))

View file

@ -235,6 +235,11 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true
}
val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title))
useUnmergedTree = true
}
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
return lazyList.child {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)

View file

@ -1,57 +1,44 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.core.ui.utils.LazyListItemPositionSemantics
import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsPageObject>(semanticsProvider = semanticsProvider) {
private val lazyList = KLazyListNode(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(MarketsTestTags.TOKENS_LIST) },
itemTypeBuilder = { itemType(::LazyListItemNode) },
positionMatcher = { position ->
SemanticsMatcher.expectValue(
LazyListItemPositionSemantics,
position
)
}
)
val addToPortfolioButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_add_to_portfolio))
useUnmergedTree = true
}
val mainNetworkSwitch: KNode = child<KNode> {
hasAnyDescendant(withText(MARKETS_MAIN_NETWORK_SUFFIX))
useUnmergedTree = true
}.child { hasTestTag(MarketsTestTags.ADD_TO_PORTFOLIO_SWITCH) }
val mainNetworkSuffix: KNode = child {
hasText(MARKETS_MAIN_NETWORK_SUFFIX)
}
val topBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
useUnmergedTree = true
}
val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title))
useUnmergedTree = true
}
fun tokenWithTitle(title: String): KNode {
return lazyList.child<KNode> {
return child {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)
hasText(title)
useUnmergedTree = true
}
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsTokenDetailsPageObject>(semanticsProvider = semanticsProvider) {
val swapPortfolioQuickActionButton: KNode = child {
hasTestTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_QUICK_ACTION_BUTTON)
hasText(getResourceString(R.string.common_swap), substring = true)
}
fun tokenWithTitle(title: String): KNode = child {
hasAnyAncestor(withTestTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_TOKEN_ITEM))
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnySibling(withTestTag(TokenElementsTestTags.TOKEN_ICON))
hasAnyChild(withText(title))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onMarketsTokenDetailsScreen(function: MarketsTokenDetailsPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -13,6 +13,10 @@ class MarketsTooltipPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
val contentContainer: KNode = child {
hasTestTag(MarketTooltipTestTags.CONTAINER)
}
val closeButton: KNode = child {
hasTestTag(MarketTooltipTestTags.CLOSE_BUTTON)
}
}
internal fun BaseTestCase.onMarketsTooltipScreen(function: MarketsTooltipPageObject.() -> Unit) =

View file

@ -0,0 +1,70 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.SearchBarTestTags
import com.tangem.core.ui.test.SwapSelectTokenScreenTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SwapSelectTokenPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.common_swap))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
val youSwapTitle: KNode = child {
hasText(getResourceString(R.string.swapping_from_title))
useUnmergedTree = true
}
val youSwapBlock: KNode = child {
hasTestTag(SwapSelectTokenScreenTestTags.YOU_SWAP_BLOCK)
hasAnyDescendant(withText(getResourceString(R.string.action_buttons_you_want_to_swap)))
useUnmergedTree = true
}
val youReceiveTitle: KNode = child {
hasText(getResourceString(R.string.swapping_to_title))
useUnmergedTree = true
}
val youReceiveBlock: KNode = child {
hasTestTag(SwapSelectTokenScreenTestTags.YOU_SWAP_BLOCK)
hasAnyDescendant(withText(getResourceString(R.string.action_buttons_you_want_to_receive)))
useUnmergedTree = true
}
val searchBarIcon: KNode = child {
hasTestTag(SearchBarTestTags.ICON)
useUnmergedTree = true
}
val searchBarPlaceholderText: KNode = child {
hasTestTag(SearchBarTestTags.PLACEHOLDER_TEXT)
useUnmergedTree = true
}
fun tokenWithName(tokenName: String): KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnyChild(withText(tokenName))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSwapSelectTokenScreen(function: SwapSelectTokenPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -10,10 +10,31 @@ import io.github.kakaocup.compose.node.element.KNode
class SwapStoriesPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SwapStoriesPageObject>(semanticsProvider = semanticsProvider) {
val container: KNode = child {
hasTestTag(SwapStoriesScreenTestTags.SCREEN_CONTAINER)
useUnmergedTree = true
}
val closeButton: KNode = child {
hasTestTag(SwapStoriesScreenTestTags.CLOSE_BUTTON)
useUnmergedTree = true
}
fun progressBarItem(index: Int): KNode = child {
hasTestTag(SwapStoriesScreenTestTags.PROGRESS_BAR_ITEM)
hasPosition(index)
useUnmergedTree = true
}
val title: KNode = child {
hasTestTag(SwapStoriesScreenTestTags.TITLE)
useUnmergedTree = true
}
val subtitle: KNode = child {
hasTestTag(SwapStoriesScreenTestTags.SUBTITLE)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSwapStoriesScreen(function: SwapStoriesPageObject.() -> Unit) =

View file

@ -13,6 +13,10 @@ import androidx.compose.ui.test.hasTestTag as withTestTag
class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SwapTokenPageObject>(semanticsProvider = semanticsProvider) {
val container: KNode = child {
hasTestTag(SwapTokenScreenTestTags.CONTAINER)
}
val title: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.common_swap))
@ -78,6 +82,7 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
fun tokenSymbol(symbol: String): KNode = child {
hasTestTag(SwapTokenScreenTestTags.TOKEN_SYMBOL)
hasText(symbol)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.tests.actionButtons
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.assertIsDimmed
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.utils.resetWireMockScenarioState
@ -60,8 +61,6 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
@Test
fun checkActionButtonsStateTest() {
val tokenTitle = "Bitcoin"
val actionButtonIsNotDimmed = "Action button is not dimmed"
val actionButtonIsDimmed = "Action button is dimmed"
setupHooks().run {
step("Open 'Main Screen'") {
@ -75,19 +74,19 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Assert 'Receive' button is not dimmed") {
onTokenDetailsScreen { receiveButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) }
onTokenDetailsScreen { receiveButton().assertIsDimmed(false) }
}
step("Assert 'Buy' button is not dimmed") {
onTokenDetailsScreen { buyButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) }
onTokenDetailsScreen { buyButton().assertIsDimmed(false) }
}
step("Assert 'Send' button is not dimmed") {
onTokenDetailsScreen { sendButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) }
onTokenDetailsScreen { sendButton().assertIsDimmed(false) }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) }
onTokenDetailsScreen { swapButton().assertIsDimmed() }
}
step("Assert 'Sell' button is dimmed") {
onTokenDetailsScreen { sellButton().assertContentDescriptionEquals(actionButtonIsDimmed) }
onTokenDetailsScreen { sellButton().assertIsDimmed() }
}
}
}
@ -130,7 +129,6 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
@Test
fun checkSwapButtonProviderErrorTest() {
val tokenTitle = "POL (ex-MATIC)"
val actionButtonIsDimmed = "Action button is dimmed"
setupHooks().run {
step("Open 'Main Screen'") {
@ -147,7 +145,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) }
onTokenDetailsScreen { swapButton().assertIsDimmed() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
@ -162,7 +160,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
onSwapIsNotSupportedDialog { okButton.performClick() }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) }
onTokenDetailsScreen { swapButton().assertIsDimmed() }
}
}
}
@ -172,7 +170,6 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
@Test
fun checkSwapButtonExpressErrorTest() {
val tokenTitle = "Polygon"
val actionButtonIsDimmed = "Action button is dimmed"
val scenarioName = "express_api_assets"
val scenarioState = "Error"
@ -198,7 +195,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) }
onTokenDetailsScreen { swapButton().assertIsDimmed() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
@ -213,7 +210,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
onOperationIsUnavailableDialog { okButton.performClick() }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) }
onTokenDetailsScreen { swapButton().assertIsDimmed() }
}
}
}

View file

@ -57,7 +57,7 @@ class TotalBalanceUpdateTest : BaseTestCase() {
val tokenTitle = "XRP"
val scenarioName = "quotes_api"
val scenarioState = "Ripple"
val updatedBalance = "$3,307.18"
val updatedBalance = "$3,320.47"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
@ -85,14 +85,18 @@ class TotalBalanceUpdateTest : BaseTestCase() {
step("Click on 'Add to portfolio' button") {
onMarketsScreen { addToPortfolioButton.clickWithAssertion() }
}
step("Toggle the main network switch") {
onMarketsScreen { mainNetworkSwitch.performClick() }
step("Click on main network") {
onMarketsScreen { mainNetworkSuffix.performClick() }
}
step("Click on 'Continue' button") {
onDialog { continueButton.clickWithAssertion() }
step("Click on 'Add' button") {
onDialog { addButton.clickWithAssertion() }
onDialog { addButton.clickWithAssertion() } //TODO: [REDACTED_JIRA]
}
step("Assert 'Continue' is not displayed") {
onDialog { continueButton.assertIsNotDisplayed() }
onDialog { addButton.assertIsNotDisplayed() }
}
step("Click on 'Later' button") {
onDialog { laterButton.clickWithAssertion() }
}
step("Go back to 'Markets: tokens list'") {
waitForIdle()

View file

@ -0,0 +1,72 @@
package com.tangem.tests.swap
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import com.tangem.screens.onSwapSelectTokenScreen
import com.tangem.screens.onSwapStoriesScreen
import com.tangem.screens.onSwapTokenScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class SwapSelectTokenScreenTest : BaseTestCase() {
@AllureId("2829")
@DisplayName("Open 'Swap select token' screen from 'Main' screen")
@Test
fun openSwapSelectTokenScreenFromMainScreenTest() {
val swapTokenName = "Ethereum"
val receiveTokenName = "Polygon"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Swap' button") {
onMainScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
}
step("Assert 'Swap select token' screen title is displayed") {
onSwapSelectTokenScreen { title.assertIsDisplayed() }
}
step("Assert 'You swap' title is displayed") {
onSwapSelectTokenScreen { youSwapTitle.assertIsDisplayed() }
}
step("Assert 'You swap' block is displayed") {
onSwapSelectTokenScreen { youSwapBlock.assertIsDisplayed() }
}
step("Assert search icon is displayed") {
onSwapSelectTokenScreen { searchBarIcon.assertIsDisplayed() }
}
step("Assert search placeholder is displayed") {
onSwapSelectTokenScreen { searchBarPlaceholderText.assertIsDisplayed() }
}
step("Click on token with name '$swapTokenName'") {
onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() }
}
step("Assert 'You receive' title is displayed") {
onSwapSelectTokenScreen { youReceiveTitle.assertIsDisplayed() }
}
step("Assert 'You receive' block is displayed") {
onSwapSelectTokenScreen { youReceiveBlock.assertIsDisplayed() }
}
step("Click on token with name '$receiveTokenName'") {
onSwapSelectTokenScreen { tokenWithName(receiveTokenName).performClick() }
}
step("Assert 'Swap token' screen is opened") {
onSwapTokenScreen { container.assertIsDisplayed() }
}
}
}
}

View file

@ -0,0 +1,437 @@
package com.tangem.tests.swap
import androidx.compose.ui.test.longClick
import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.assertHasBadge
import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.*
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class SwapStoriesTest : BaseTestCase() {
@AllureId("5453")
@DisplayName("Check 'Swap' button badge on 'Main' screen")
@Test
fun checkMainScreenSwapButtonBadgeTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onMainScreen { swapButton.assertHasBadge(false) }
}
}
}
@AllureId("5454")
@DisplayName("Check 'Swap' button badge on token details screen")
@Test
fun checkTokenDetailsScreenSwapButtonTest() {
val tokenName = "Ethereum"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
}
}
}
@AllureId("5455")
@DisplayName("Check 'Swap' button badge on token details in 'Market' screen")
@Test
fun checkMarketTokenDetailsScreenSwapButtonTest() {
val tokenName = "Ethereum"
val badgeShown = "Badge shown"
val badgeHidden = "Badge hidden"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
}
}
}
@AllureId("5469")
@DisplayName("Check unavailable swap stories on 'Main' screen")
@Test
fun checkUnavailableSwapStoriesOnMainScreen() {
val scenarioName = "stories_first_time_swap"
val scenarioErrorState = "Error"
val packageName = getTargetContext().packageName
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioErrorState)
},
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
}
).run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge(false) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false)
}
step("Reset WireMock scenario state") {
resetWireMockScenarioState(scenarioName)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Restart app") {
restartApp(packageName)
}
step("Assert 'Swap' button is displayed") {
waitForIdle()
onMainScreen { swapButton.assertIsDisplayed() }
}
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = true)
}
}
}
@AllureId("5471")
@DisplayName("Check unavailable swap stories on 'Token details' screen")
@Test
fun checkUnavailableSwapStoriesOnTokenDetailsScreen() {
val scenarioName = "stories_first_time_swap"
val scenarioErrorState = "Error"
val packageName = getTargetContext().packageName
val tokenName = "Ethereum"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioErrorState)
},
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
}
).run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Reset WireMock scenario state") {
resetWireMockScenarioState(scenarioName)
}
step("Restart app") {
restartApp(packageName)
}
step("Assert 'Swap' button has badge") {
waitForIdle()
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
}
}
}
@AllureId("5471")
@DisplayName("Check unavailable swap stories on 'Markets' token details screen")
@Test
fun checkUnavailableSwapStoriesOnMarketsTokenDetailsScreen() {
val scenarioName = "stories_first_time_swap"
val scenarioErrorState = "Error"
val packageName = getTargetContext().packageName
val tokenName = "Ethereum"
val badgeShown = "Badge shown"
val badgeHidden = "Badge hidden"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioErrorState)
},
additionalAfterSection = {
resetWireMockScenarioState(scenarioName)
}
).run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
waitForIdle()
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Reset WireMock scenario state") {
resetWireMockScenarioState(scenarioName)
}
step("Restart app") {
restartApp(packageName)
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
waitForIdle()
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = true)
}
}
}
@AllureId("5474")
@DisplayName("Check 'Swap' stories on 'Main' screen")
@Test
fun checkSwapStoriesOnMainScreenTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Swap' button on 'Main' screen") {
onMainScreen { swapButton.performClick() }
}
step("Check stories changes") {
checkStoriesChanges()
}
step("Click on 'Close' button") {
onSwapStoriesScreen { closeButton.performClick() }
}
step("Assert 'Swap' screen title is displayed") {
onSwapTokenScreen { title.assertIsDisplayed() }
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false)
}
}
}
@AllureId("5475")
@DisplayName("Check 'Swap' stories on 'Token details' screen")
@Test
fun checkSwapStoriesOnTokenDetailsScreenTest() {
val tokenName = "Ethereum"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge() }
}
step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton().performClick() }
}
step("Check stories changes") {
checkStoriesChanges()
}
step("Click on 'Close' button") {
onSwapStoriesScreen { closeButton.performClick() }
}
step("Assert 'Swap' screen title is displayed") {
onSwapTokenScreen { title.assertIsDisplayed() }
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
}
}
@AllureId("5476")
@DisplayName("Check 'Swap' stories on 'Markets' screen")
@Test
fun checkSwapStoriesOnMarketTokenDetailsScreenTest() {
val tokenName = "Ethereum"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Click on 'Swap' button on 'Markets' token details screen") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() }
}
step("Check stories changes") {
checkStoriesChanges()
}
step("Click on 'Close' button") {
onSwapStoriesScreen { closeButton.performClick() }
}
step("Assert 'Swap' screen title is displayed") {
onSwapTokenScreen { title.assertIsDisplayed() }
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false)
}
}
}
@AllureId("5477")
@DisplayName("Check 'Swap' stories on token actions bottom sheet")
@Test
fun checkSwapStoriesOnTokenActionsBottomSheetTest() {
val tokenName = "Ethereum"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Long click on token with name: '$tokenName'") {
waitForIdle()
onMainScreen {
tokenWithTitleAndAddress(tokenName).performTouchInput {
longClick(
position = center,
durationMillis = 1000L
)
}
}
}
step("Click on 'Swap' button") {
onTokenActionsBottomSheet { swapButton.performClick() }
}
step("Check stories changes") {
checkStoriesChanges()
}
step("Click on 'Close' button") {
onSwapStoriesScreen { closeButton.performClick() }
}
step("Assert 'Swap' screen title is displayed") {
onSwapTokenScreen { title.assertIsDisplayed() }
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.TokenActionsBottomSheet, storiesExist = false)
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.tests
package com.tangem.tests.swap
import androidx.compose.ui.test.hasText
import com.tangem.common.BaseTestCase
@ -19,7 +19,7 @@ import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class SwapTokenTest : BaseTestCase() {
class SwapTokenScreenTest : BaseTestCase() {
@ApiEnv(
ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD)
@ -43,7 +43,7 @@ class SwapTokenTest : BaseTestCase() {
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
step("Assert title: '$tokenTitle' is displayed") {
onTokenDetailsScreen { title.assertIsDisplayed() }
}
step("Click on 'Swap' button") {
@ -123,7 +123,7 @@ class SwapTokenTest : BaseTestCase() {
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
step("Assert title: '$tokenTitle' is displayed") {
onTokenDetailsScreen { title.assertIsDisplayed() }
}
step("Turn off Wi-Fi and Mobile Data") {
@ -171,7 +171,7 @@ class SwapTokenTest : BaseTestCase() {
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
step("Assert title: '$tokenTitle' is displayed") {
onTokenDetailsScreen { title.assertIsDisplayed() }
}
step("Click on 'Swap' button") {
@ -239,4 +239,55 @@ class SwapTokenTest : BaseTestCase() {
}
}
}
@AllureId("2828")
@DisplayName("Swap: network fee")
@Test
fun goToTokenSwapTest() {
val swapTokenSymbol = "POL"
val receiveTokenSymbol = "ETH"
val tokenTitle = "Polygon"
setupHooks().run {
resetWireMockScenarios()
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).clickWithAssertion() }
}
step("Assert title: '$tokenTitle' is displayed") {
onTokenDetailsScreen { title.assertIsDisplayed() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
}
step("Assert 'Swap' screen title is displayed") {
onSwapTokenScreen { title.assertIsDisplayed() }
}
step("Assert 'Close' button is displayed") {
onSwapTokenScreen { closeButton.assertIsDisplayed() }
}
step("Assert 'Swap tokens on screen' button is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT) {
swapTokensOnscreenButton.assertIsDisplayed()
}
}
}
step("Assert token symbol: '$swapTokenSymbol' is displayed") {
onSwapTokenScreen { tokenSymbol(swapTokenSymbol).assertIsDisplayed() }
}
step("Assert token symbol: '$receiveTokenSymbol' is displayed") {
onSwapTokenScreen { tokenSymbol(receiveTokenSymbol).assertIsDisplayed() }
}
}
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.tap
import android.app.Activity
import com.google.android.gms.tasks.Task
import com.google.android.play.core.review.ReviewInfo
import com.google.android.play.core.review.ReviewManagerFactory
import com.tangem.core.navigation.review.ReviewManager
import timber.log.Timber
import com.google.android.play.core.review.ReviewManager as GReviewManager
/**
* Implementation of [ReviewManager] for Google Play Store.
*/
internal class GoogleReviewManager : ReviewManager {
override fun request(onDismissClick: () -> Unit) {
foregroundActivityObserver.withForegroundActivity { activity ->
val reviewManager = ReviewManagerFactory.create(activity)
val requestTask = reviewManager.requestReviewFlow()
requestTask
.addOnCompleteListener { task ->
handleOnCompleteRequestTask(
reviewManager = reviewManager,
activity = activity,
task = task,
onDismissClick = onDismissClick,
)
}
.addOnFailureListener(Timber::e)
}
}
private fun handleOnCompleteRequestTask(
reviewManager: GReviewManager,
activity: Activity,
task: Task<ReviewInfo>,
onDismissClick: () -> Unit,
) {
if (task.isSuccessful) {
val reviewFlow = reviewManager.launchReviewFlow(activity, task.result)
reviewFlow
.addOnCompleteListener { resultReviewTask ->
if (!resultReviewTask.isSuccessful) onDismissClick()
}
.addOnFailureListener(Timber::e)
} else {
Timber.e(task.exception)
}
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.tap.di
import com.tangem.core.navigation.review.ReviewManager
import com.tangem.tap.GoogleReviewManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal class GoogleReviewManagerModule {
@Singleton
@Provides
fun provideGoogleReviewManager(): ReviewManager {
return GoogleReviewManager()
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.tap.di
import com.tangem.core.navigation.review.DummyReviewManager
import com.tangem.core.navigation.review.ReviewManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal class HuaweiReviewManagerModule {
@Provides
@Singleton
fun provideHuaweiReviewManager(): ReviewManager {
return DummyReviewManager()
}
}

View file

@ -40,9 +40,7 @@ import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
@ -92,8 +90,6 @@ interface ApplicationEntryPoint {
fun getOneTimeEventFilter(): OneTimeEventFilter
fun getGeneralUserWalletsListManager(): UserWalletsListManager
fun getWasTwinsOnboardingShownUseCase(): WasTwinsOnboardingShownUseCase
fun getSaveTwinsOnboardingShownUseCase(): SaveTwinsOnboardingShownUseCase
@ -151,8 +147,6 @@ interface ApplicationEntryPoint {
fun getTangemHotSdk(): TangemHotSdk
fun getHotWalletFeatureToggles(): HotWalletFeatureToggles
fun getWcInitializeUseCase(): WcInitializeUseCase
fun getTrackingContextProxy(): TrackingContextProxy

View file

@ -6,9 +6,6 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import timber.log.Timber
@ -18,21 +15,12 @@ class LockTimerWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val settingsRepository: SettingsRepository,
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
Timber.i("onStart job")
if (hotWalletFeatureToggles.isHotWalletEnabled) {
userWalletsListRepository.lockAllWallets()
.onRight {
settingsRepository.setShouldOpenWelcomeScreenOnResume(value = true)
}
} else {
val userWalletsListManagerLockable = userWalletsListManager.asLockable() ?: return Result.failure()
userWalletsListManagerLockable.lock()
userWalletsListRepository.lockAllWallets().onRight {
settingsRepository.setShouldOpenWelcomeScreenOnResume(value = true)
}
Timber.i("onStart job complete")

View file

@ -9,10 +9,7 @@ import androidx.work.WorkManager
import com.tangem.common.routing.AppRoute
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.tap.LockTimerWorker.Companion.TAG
import com.tangem.tap.common.extensions.dispatchNavigationAction
import kotlinx.coroutines.CoroutineScope
@ -22,16 +19,13 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
@Suppress("LongParameterList")
internal class LockUserWalletsTimer(
private val context: Context,
private val settingsRepository: SettingsRepository,
private val duration: Duration = with(Duration) { 5.minutes },
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val coroutineScope: CoroutineScope,
private val clearAllHotWalletContextualUnlockUseCase: ClearAllHotWalletContextualUnlockUseCase,
) : LifecycleOwner by context as LifecycleOwner,
@ -59,9 +53,7 @@ internal class LockUserWalletsTimer(
)
if (shouldOpenWelcomeScreenOnResume) {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
clearAllHotWalletContextualUnlockUseCase.invoke()
}
clearAllHotWalletContextualUnlockUseCase.invoke()
store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) }
settingsRepository.setShouldOpenWelcomeScreenOnResume(value = false)
}
@ -115,38 +107,18 @@ internal class LockUserWalletsTimer(
* This job used only when app is foreground when background use [JobScheduler]
*/
private fun createDelayJob(): Job = coroutineScope.launch {
val startTime = System.currentTimeMillis()
delay(duration)
if (hotWalletFeatureToggles.isHotWalletEnabled) {
val userWallets = userWalletsListRepository.userWalletsSync()
if (userWallets.isNotEmpty()) {
userWalletsListRepository.lockAllWallets()
.onLeft {
start()
}
.onRight {
clearAllHotWalletContextualUnlockUseCase.invoke()
store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) }
}
}
} else {
val userWalletsListManager = userWalletsListManager.asLockable() ?: return@launch
if (userWalletsListManager.hasUserWallets) {
val currentTime = System.currentTimeMillis()
Timber.i(
"""
Finished
|- Millis passed: ${currentTime - startTime}
""".trimIndent(),
)
userWalletsListManager.lock()
store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) }
}
val userWallets = userWalletsListRepository.userWalletsSync()
if (userWallets.isNotEmpty()) {
userWalletsListRepository.lockAllWallets()
.onLeft {
start()
}
.onRight {
clearAllHotWalletContextualUnlockUseCase.invoke()
store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) }
}
}
}
}

View file

@ -43,9 +43,7 @@ import com.tangem.domain.settings.SetGoogleServicesAvailabilityUseCase
import com.tangem.domain.settings.ShouldInitiallyAskPermissionUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.staking.SendUnsubmittedHashesUseCase
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tester.api.TesterMenuLauncher
import com.tangem.google.GoogleServicesHelper
@ -114,9 +112,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
@Inject
lateinit var analyticsEventsHandler: AnalyticsEventHandler
@Inject
lateinit var userWalletsListManager: UserWalletsListManager
@Inject
@RootAppComponentContext
internal lateinit var rootComponentContext: AppComponentContext
@ -163,9 +158,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
@Inject
internal lateinit var userWalletsListRepository: UserWalletsListRepository
@Inject
internal lateinit var hotWalletFeatureToggles: HotWalletFeatureToggles
@Inject
internal lateinit var clearAllHotWalletContextualUnlockUseCase: ClearAllHotWalletContextualUnlockUseCase
@ -258,10 +250,8 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
lockUserWalletsTimer = LockUserWalletsTimer(
context = this,
settingsRepository = settingsRepository,
userWalletsListManager = userWalletsListManager,
coroutineScope = mainScope,
userWalletsListRepository = userWalletsListRepository,
hotWalletFeatureToggles = hotWalletFeatureToggles,
clearAllHotWalletContextualUnlockUseCase = clearAllHotWalletContextualUnlockUseCase,
)

View file

@ -58,7 +58,6 @@ import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.operations.attestation.api.TangemApiServiceSettings
@ -146,9 +145,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val oneTimeEventFilter: OneTimeEventFilter
get() = entryPoint.getOneTimeEventFilter()
private val generalUserWalletsListManager: UserWalletsListManager
get() = entryPoint.getGeneralUserWalletsListManager()
private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase
get() = entryPoint.getWasTwinsOnboardingShownUseCase()
@ -238,9 +234,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val tangemHotSdk
get() = entryPoint.getTangemHotSdk()
private val hotWalletFeatureToggles
get() = entryPoint.getHotWalletFeatureToggles()
private val wcInitializeUseCase
get() = entryPoint.getWcInitializeUseCase()
@ -382,7 +375,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
appThemeModeRepository = appThemeModeRepository,
balanceHidingRepository = balanceHidingRepository,
walletsRepository = walletsRepository,
generalUserWalletsListManager = generalUserWalletsListManager,
wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase,
saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase,
cardRepository = cardRepository,
@ -407,7 +399,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
userTokensResponseStore = userTokensResponseStore,
userWalletsListRepository = userWalletsListRepository,
tangemHotSdk = tangemHotSdk,
hotWalletFeatureToggles = hotWalletFeatureToggles,
trackingContextProxy = trackingContextProxy,
),
),

View file

@ -2,7 +2,6 @@ package com.tangem.tap.common.redux
import com.tangem.tap.common.redux.global.globalReducer
import com.tangem.tap.features.details.redux.DetailsReducer
import com.tangem.tap.features.welcome.redux.WelcomeReducer
import com.tangem.tap.proxy.redux.DaggerGraphReducer
import org.rekotlin.Action
@ -12,7 +11,6 @@ fun appReducer(action: Action, state: AppState): AppState {
return AppState(
globalState = globalReducer(action, state),
detailsState = DetailsReducer.reduce(action, state),
welcomeState = WelcomeReducer.reduce(action, state),
daggerGraphState = DaggerGraphReducer.reduce(action, state),
)
}

View file

@ -7,8 +7,6 @@ import com.tangem.tap.features.details.redux.DetailsMiddleware
import com.tangem.tap.features.details.redux.DetailsState
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupMiddleware
import com.tangem.tap.features.wallet.redux.middlewares.TradeCryptoMiddleware
import com.tangem.tap.features.welcome.redux.WelcomeMiddleware
import com.tangem.tap.features.welcome.redux.WelcomeState
import com.tangem.tap.proxy.redux.DaggerGraphMiddleware
import com.tangem.tap.proxy.redux.DaggerGraphState
import org.rekotlin.Middleware
@ -17,7 +15,6 @@ import org.rekotlin.StateType
data class AppState(
val globalState: GlobalState = GlobalState(),
val detailsState: DetailsState = DetailsState(),
val welcomeState: WelcomeState = WelcomeState(),
val daggerGraphState: DaggerGraphState = DaggerGraphState(),
) : StateType {
@ -28,7 +25,6 @@ data class AppState(
GlobalMiddleware.handler,
DetailsMiddleware().detailsMiddleware,
BackupMiddleware().backupMiddleware,
WelcomeMiddleware().middleware,
LockUserWalletsTimerMiddleware().middleware,
AccessCodeRequestPolicyMiddleware().middleware,
DaggerGraphMiddleware.daggerGraphMiddleware,

View file

@ -27,8 +27,6 @@ internal object LegacyMiddleware {
{ action ->
when (action) {
is LegacyAction.PrepareDetailsScreen -> {
val walletsRepository = store.inject(DaggerGraphState::walletsRepository)
selectedUserWallet()
.distinctUntilChanged { old, new ->
if (old is UserWallet.Cold && new is UserWallet.Cold) {
@ -39,9 +37,7 @@ internal object LegacyMiddleware {
}
}
.onEach { selectedUserWallet ->
val initializedAppSettingsStateContent = initializeAppSettingsState(
shouldSaveUserWallets = walletsRepository.shouldSaveUserWalletsSync(),
)
val initializedAppSettingsStateContent = initializeAppSettingsState()
store.dispatchWithMain(
DetailsAction.PrepareScreen(
scanResponse = (selectedUserWallet as? UserWallet.Cold)?.scanResponse,
@ -60,24 +56,15 @@ internal object LegacyMiddleware {
}
private fun selectedUserWallet(): Flow<UserWallet> {
val hotWalletFeatureToggles = store.inject(DaggerGraphState::hotWalletFeatureToggles)
return if (hotWalletFeatureToggles.isHotWalletEnabled) {
store.inject(DaggerGraphState::userWalletsListRepository).selectedUserWallet.filterNotNull()
} else {
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
userWalletsListManager.selectedUserWallet
}
return store.inject(DaggerGraphState::userWalletsListRepository).selectedUserWallet.filterNotNull()
}
/**
* LEGACY: We need to initialize [AppSettingsState] async to avoid drawing blocking
* previously it was initialized in runBlocking and blocked details screen
*/
private suspend fun initializeAppSettingsState(shouldSaveUserWallets: Boolean): AppSettingsState {
private suspend fun initializeAppSettingsState(): AppSettingsState {
return AppSettingsState(
isBiometricsAvailable = tangemSdkManager.checkCanUseBiometry(),
saveWallets = shouldSaveUserWallets,
saveAccessCodes = store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes(),
selectedAppCurrency = store.state.globalState.appCurrency,
selectedThemeMode = store.inject(DaggerGraphState::appThemeModeRepository).getAppThemeMode().firstOrNull()
?: AppThemeMode.DEFAULT,

View file

@ -1,39 +0,0 @@
package com.tangem.tap.data
import com.tangem.common.CompletionResult
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import kotlinx.coroutines.flow.Flow
// FIXME: Workaround, remove it once the normal UserWalletsStore has been implemented
// [REDACTED_JIRA]
internal class RuntimeUserWalletsStore(
private val userWalletsListManager: UserWalletsListManager,
) : UserWalletsStore {
override val selectedUserWalletOrNull: UserWallet?
get() = userWalletsListManager.selectedUserWalletSync
override val userWallets: Flow<List<UserWallet>>
get() = userWalletsListManager.userWallets
override val userWalletsSync: List<UserWallet>
get() = userWalletsListManager.userWalletsSync
override fun getSyncOrNull(key: UserWalletId): UserWallet? {
return userWalletsListManager.userWalletsSync.firstOrNull { it.walletId == key }
}
override fun getSyncStrict(key: UserWalletId): UserWallet {
return requireNotNull(getSyncOrNull(key)) { "Unable to find user wallet with provided ID: $key" }
}
override suspend fun update(
userWalletId: UserWalletId,
update: suspend (UserWallet) -> UserWallet,
): CompletionResult<UserWallet> {
return userWalletsListManager.update(userWalletId, update)
}
}

View file

@ -2,9 +2,6 @@ package com.tangem.tap.di.data
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.tap.data.RuntimeUserWalletsStore
import com.tangem.tap.data.UserWalletsStoreRepositoryProxy
import dagger.Module
import dagger.Provides
@ -18,15 +15,7 @@ internal object UserWalletsStoreModule {
@Provides
@Singleton
fun provideUserWalletsStore(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): UserWalletsStore {
return if (hotWalletFeatureToggles.isHotWalletEnabled) {
UserWalletsStoreRepositoryProxy(userWalletsListRepository)
} else {
RuntimeUserWalletsStore(userWalletsListManager = userWalletsListManager)
}
fun provideUserWalletsStore(userWalletsListRepository: UserWalletsListRepository): UserWalletsStore {
return UserWalletsStoreRepositoryProxy(userWalletsListRepository)
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.account.usecase.*
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.feature.referral.data.ExternalReferralRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -94,10 +95,12 @@ internal object AccountDomainModule {
@Provides
@Singleton
fun provideIsAccountsModeEnabledUseCase(
userWalletsListRepository: UserWalletsListRepository,
accountsCRUDRepository: AccountsCRUDRepository,
accountsFeatureToggles: AccountsFeatureToggles,
): IsAccountsModeEnabledUseCase {
return IsAccountsModeEnabledUseCase(
userWalletsListRepository = userWalletsListRepository,
crudRepository = accountsCRUDRepository,
accountsFeatureToggles = accountsFeatureToggles,
)

View file

@ -9,9 +9,7 @@ import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.*
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.card.DefaultDeleteSavedAccessCodesUseCase
import com.tangem.tap.domain.card.DefaultResetCardUseCase
@ -42,16 +40,8 @@ internal object CardDomainModule {
}
@Provides
fun provideIsNeedToBackupUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): IsNeedToBackupUseCase {
return IsNeedToBackupUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
fun provideIsNeedToBackupUseCase(userWalletsListRepository: UserWalletsListRepository): IsNeedToBackupUseCase {
return IsNeedToBackupUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides

View file

@ -3,9 +3,7 @@ package com.tangem.tap.di.domain
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.domain.scanCard.DefaultScanCardProcessor
import com.tangem.tap.domain.scanCard.LegacyScanProcessor
@ -34,14 +32,8 @@ internal object CardLegacyDomainModule {
@Provides
@Singleton
fun providesWalletNameGenerateUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): GenerateWalletNameUseCase {
return GenerateWalletNameUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
return GenerateWalletNameUseCase(userWalletsListRepository = userWalletsListRepository)
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.tap.di.domain
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.earn.repository.EarnRepository
import com.tangem.domain.earn.usecase.*
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object EarnDomainModule {
@Provides
fun provideFetchEarnNetworksUseCase(repository: EarnRepository): FetchEarnNetworksUseCase {
return FetchEarnNetworksUseCase(repository)
}
@Provides
fun provideManageEarnNetworksUseCase(
earnRepository: EarnRepository,
userWalletsListRepository: UserWalletsListRepository,
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
): GetEarnNetworksUseCase {
return GetEarnNetworksUseCase(
earnRepository = earnRepository,
userWalletsListRepository = userWalletsListRepository,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
)
}
@Provides
fun provideFetchTopEarnTokensUseCase(repository: EarnRepository): FetchTopEarnTokensUseCase {
return FetchTopEarnTokensUseCase(repository)
}
@Provides
fun provideManageTopEarnTokensUseCase(repository: EarnRepository): GetTopEarnTokensUseCase {
return GetTopEarnTokensUseCase(repository)
}
@Provides
fun provideGetEarnTokensBatchFlowUseCase(repository: EarnRepository): GetEarnTokensBatchFlowUseCase {
return GetEarnTokensBatchFlowUseCase(repository)
}
}

View file

@ -13,8 +13,6 @@ import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -107,15 +105,11 @@ object MarketsDomainModule {
@Provides
@Singleton
fun provideFilterNetworksUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
excludedBlockchains: ExcludedBlockchains,
): FilterAvailableNetworksForWalletUseCase {
return FilterAvailableNetworksForWalletUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
shouldUseNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
excludedBlockchains = excludedBlockchains,
)
}

View file

@ -13,7 +13,6 @@ import com.tangem.domain.wallets.delegate.DefaultUserWalletsSyncDelegate
import com.tangem.domain.wallets.delegate.UserWalletsSyncDelegate
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.*
@ -22,7 +21,6 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.operations.attestation.CardArtworksProvider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -38,105 +36,61 @@ internal object WalletsDomainModule {
@Provides
fun providesUserWalletsSyncDelegate(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
dispatchers: CoroutineDispatcherProvider,
): UserWalletsSyncDelegate {
return DefaultUserWalletsSyncDelegate(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
dispatchers = dispatchers,
)
return DefaultUserWalletsSyncDelegate(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@Singleton
fun providesGetWalletsUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): GetWalletsUseCase {
return GetWalletsUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewListRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
fun providesGetWalletsUseCase(userWalletsListRepository: UserWalletsListRepository): GetWalletsUseCase {
return GetWalletsUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@Singleton
fun providesWalletNameMigrationUseCase(
userWalletsListManager: UserWalletsListManager,
walletNamesMigrationRepository: WalletNamesMigrationRepository,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): WalletNameMigrationUseCase {
return WalletNameMigrationUseCase(
userWalletsListManager = userWalletsListManager,
walletNamesMigrationRepository = walletNamesMigrationRepository,
userWalletsListRepository = userWalletsListRepository,
useNewListRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
}
@Provides
@Singleton
fun providesGetUserWalletUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): GetUserWalletUseCase {
return GetUserWalletUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewListRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
fun providesGetUserWalletUseCase(userWalletsListRepository: UserWalletsListRepository): GetUserWalletUseCase {
return GetUserWalletUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@Singleton
fun providesGetSelectedWalletSyncUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): GetSelectedWalletSyncUseCase {
return GetSelectedWalletSyncUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
return GetSelectedWalletSyncUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@Singleton
fun providesGetSelectedWalletUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): GetSelectedWalletUseCase {
return GetSelectedWalletUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
return GetSelectedWalletUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@Singleton
fun providesSaveWalletUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
walletsRepository: WalletsRepository,
analyticsEventHandler: AnalyticsEventHandler,
): SaveWalletUseCase {
return SaveWalletUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
walletsRepository = walletsRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
analyticsEventHandler = analyticsEventHandler,
)
}
@ -144,15 +98,9 @@ internal object WalletsDomainModule {
@Provides
@Singleton
fun providesIsWalletAlreadySavedUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): IsWalletAlreadySavedUseCase {
return IsWalletAlreadySavedUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
return IsWalletAlreadySavedUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@ -167,12 +115,6 @@ internal object WalletsDomainModule {
return GetExploreUrlUseCase(walletsManagersFacade = walletsManagersFacade)
}
@Provides
@Singleton
fun providesUnlockWalletsUseCase(userWalletsListManager: UserWalletsListManager): UnlockWalletsUseCase {
return UnlockWalletsUseCase(userWalletsListManager = userWalletsListManager)
}
@Provides
@Singleton
fun providesUnlockWalletUseCase(
@ -200,31 +142,19 @@ internal object WalletsDomainModule {
@Provides
@Singleton
fun providesSelectWalletUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
reduxStateHolder: ReduxStateHolder,
): SelectWalletUseCase {
return SelectWalletUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
reduxStateHolder = reduxStateHolder,
)
}
@Provides
@Singleton
fun providesUpdateWalletUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): UpdateWalletUseCase {
return UpdateWalletUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
fun providesUpdateWalletUseCase(userWalletsListRepository: UserWalletsListRepository): UpdateWalletUseCase {
return UpdateWalletUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@ -241,44 +171,14 @@ internal object WalletsDomainModule {
@Provides
@Singleton
fun providesGetWalletsSyncUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): GetWalletNamesUseCase {
return GetWalletNamesUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
fun providesGetWalletsSyncUseCase(userWalletsListRepository: UserWalletsListRepository): GetWalletNamesUseCase {
return GetWalletNamesUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@Singleton
fun providesDeleteWalletUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): DeleteWalletUseCase {
return DeleteWalletUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
}
@Provides
@Singleton
fun providesShouldSaveUserWalletsSyncUseCase(
walletsRepository: WalletsRepository,
): ShouldSaveUserWalletsSyncUseCase {
return ShouldSaveUserWalletsSyncUseCase(walletsRepository = walletsRepository)
}
@Provides
@Singleton
fun providesShouldSaveUserWalletsUseCase(walletsRepository: WalletsRepository): ShouldSaveUserWalletsUseCase {
return ShouldSaveUserWalletsUseCase(walletsRepository = walletsRepository)
fun providesDeleteWalletUseCase(userWalletsListRepository: UserWalletsListRepository): DeleteWalletUseCase {
return DeleteWalletUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@ -350,15 +250,9 @@ internal object WalletsDomainModule {
@Provides
@Singleton
fun providesGetSavedWalletChangesIdUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
): GetSavedWalletsCountUseCase {
return GetSavedWalletsCountUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
useNewRepository = hotWalletFeatureToggles.isHotWalletEnabled,
)
return GetSavedWalletsCountUseCase(userWalletsListRepository = userWalletsListRepository)
}
@Provides
@ -482,15 +376,11 @@ internal object WalletsDomainModule {
@Provides
@Singleton
fun provideGetWalletsForAutomaticallyPushEnablingUseCase(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
dispatcherProvider: CoroutineDispatcherProvider,
): GetWalletsForAutomaticallyPushEnablingUseCase {
return GetWalletsForAutomaticallyPushEnablingUseCase(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
shouldUseNewListRepository = hotWalletFeatureToggles.isHotWalletEnabled,
dispatchers = dispatcherProvider,
)
}
@ -508,4 +398,12 @@ internal object WalletsDomainModule {
fun provideSyncWalletWithRemoteUseCase(walletsRepository: WalletsRepository): SyncWalletWithRemoteUseCase {
return SyncWalletWithRemoteUseCase(walletsRepository = walletsRepository)
}
@Provides
@Singleton
fun provideApplyUserWalletListSortingUseCase(
userWalletsListRepository: UserWalletsListRepository,
): ApplyUserWalletListSortingUseCase {
return ApplyUserWalletListSortingUseCase(userWalletsListRepository = userWalletsListRepository)
}
}

View file

@ -16,20 +16,15 @@ import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.sdk.storage.AndroidSecureStorage
import com.tangem.sdk.storage.AndroidSecureStorageV2
import com.tangem.sdk.storage.createEncryptedSharedPreferences
import com.tangem.tap.domain.userWalletList.implementation.BiometricUserWalletsListManager
import com.tangem.tap.domain.userWalletList.implementation.GeneralUserWalletsListManager
import com.tangem.tap.domain.userWalletList.implementation.RuntimeUserWalletsListManager
import com.tangem.tap.domain.userWalletList.repository.DefaultUserWalletsListRepository
import com.tangem.tap.domain.userWalletList.repository.DelegatedKeystoreManager
import com.tangem.tap.domain.userWalletList.repository.UserWalletEncryptionKeysRepository
import com.tangem.tap.domain.userWalletList.repository.UserWalletsKeysStoreDecorator
import com.tangem.tap.domain.userWalletList.repository.implementation.BiometricUserWalletsKeysRepository
import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultSelectedUserWalletRepository
import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultUserWalletsPublicInformationRepository
import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultUserWalletsSensitiveInformationRepository
@ -48,77 +43,6 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
internal object UserWalletsListManagerModule {
@Provides
@Singleton
@Deprecated("Use UserWalletsListRepository instead")
fun provideGeneralUserWalletsListManager(
@ApplicationContext applicationContext: Context,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
analyticsEventHandler: AnalyticsEventHandler,
): UserWalletsListManager {
return GeneralUserWalletsListManager(
runtimeUserWalletsListManager = RuntimeUserWalletsListManager(),
biometricUserWalletsListManager = createBiometricUserWalletsListManager(
applicationContext = applicationContext,
analyticsEventHandler = analyticsEventHandler,
dispatchers = dispatchers,
),
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)
}
@Deprecated("Use UserWalletsListRepository instead")
private fun createBiometricUserWalletsListManager(
applicationContext: Context,
analyticsEventHandler: AnalyticsEventHandler,
dispatchers: CoroutineDispatcherProvider,
): UserWalletsListManager {
val moshi = buildMoshi()
val secureStorage = buildSecureStorage(applicationContext = applicationContext)
val authenticatedStorage = AuthenticatedStorage(
secureStorage = UserWalletsKeysStoreDecorator(
featureStorage = secureStorage,
cardSdkStorageProvider = Provider { tangemSdkManager.secureStorage },
),
keystoreManager = DelegatedKeystoreManager(
keystoreManagerProvider = Provider { tangemSdkManager.keystoreManager },
),
)
val keysRepository = BiometricUserWalletsKeysRepository(
moshi = moshi,
secureStorage = secureStorage,
authenticatedStorage = authenticatedStorage,
analyticsEventHandler = analyticsEventHandler,
)
val publicInformationRepository = DefaultUserWalletsPublicInformationRepository(
moshi = moshi,
secureStorage = secureStorage,
)
val sensitiveInformationRepository = DefaultUserWalletsSensitiveInformationRepository(
moshi = moshi,
secureStorage = secureStorage,
)
val selectedUserWalletRepository = DefaultSelectedUserWalletRepository(
secureStorage = secureStorage,
dispatchers = dispatchers,
)
return BiometricUserWalletsListManager(
keysRepository = keysRepository,
publicInformationRepository = publicInformationRepository,
sensitiveInformationRepository = sensitiveInformationRepository,
selectedUserWalletRepository = selectedUserWalletRepository,
dispatcherProvider = dispatchers,
)
}
@Provides
@Singleton
fun provideUserWalletsListRepository(
@ -186,7 +110,7 @@ internal object UserWalletsListManagerModule {
)
}
fun buildMoshi(): Moshi {
private fun buildMoshi(): Moshi {
return Moshi.Builder()
.add(WalletDerivedKeysMapAdapter())
.add(ScanResponseDerivedKeysMapAdapter())
@ -203,7 +127,7 @@ internal object UserWalletsListManagerModule {
.build()
}
fun buildSecureStorage(@ApplicationContext applicationContext: Context): SecureStorage {
private fun buildSecureStorage(@ApplicationContext applicationContext: Context): SecureStorage {
return AndroidSecureStorage(
preferences = SecureStorage.createEncryptedSharedPreferences(
context = applicationContext,

View file

@ -1,434 +0,0 @@
package com.tangem.tap.domain.userWalletList.implementation
import com.tangem.common.*
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.wallets.legacy.UserWalletsListError
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
import com.tangem.tap.domain.userWalletList.repository.SelectedUserWalletRepository
import com.tangem.tap.domain.userWalletList.repository.UserWalletsKeysRepository
import com.tangem.tap.domain.userWalletList.repository.UserWalletsPublicInformationRepository
import com.tangem.tap.domain.userWalletList.repository.UserWalletsSensitiveInformationRepository
import com.tangem.tap.domain.userWalletList.utils.encryptionKey
import com.tangem.tap.domain.userWalletList.utils.lockAll
import com.tangem.tap.domain.userWalletList.utils.toUserWallets
import com.tangem.tap.domain.userWalletList.utils.updateWith
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
@Suppress("LargeClass")
@OptIn(ExperimentalCoroutinesApi::class)
internal class BiometricUserWalletsListManager(
private val keysRepository: UserWalletsKeysRepository,
private val publicInformationRepository: UserWalletsPublicInformationRepository,
private val sensitiveInformationRepository: UserWalletsSensitiveInformationRepository,
private val selectedUserWalletRepository: SelectedUserWalletRepository,
private val dispatcherProvider: CoroutineDispatcherProvider,
) : UserWalletsListManager.Lockable {
private val state = MutableStateFlow(State())
private var hasSavedWallets: Boolean? = null
private val savedWalletMutex = Mutex()
override val isLockable: Boolean = true
override val userWallets: Flow<List<UserWallet>>
get() = state
.mapLatest { it.userWallets }
.distinctUntilChanged()
override val userWalletsSync: List<UserWallet>
get() = state.value.userWallets
@Deprecated("You should provide the selected wallet via routing parameters due to the scalability of the features")
override val selectedUserWallet: Flow<UserWallet>
get() = state
.mapLatest { state ->
findSelectedUserWallet(state.userWallets)
}
.filterNotNull()
.distinctUntilChanged()
@Deprecated("You should provide the selected wallet via routing parameters due to the scalability of the features")
override val selectedUserWalletSync: UserWallet?
get() = findSelectedUserWallet()
override val lockedState: Flow<Boolean>
get() = state
.mapLatest { it.isLocked }
.distinctUntilChanged()
override val isLocked: Boolean
get() = state.value.isLocked
override val hasUserWallets: Boolean
get() {
return runBlocking {
// workaround to avoid calling hasSavedEncryptionKeys many times because of performance
savedWalletMutex.withLock {
Timber.i("Checking if user has saved wallets")
val hasSavedWalletsLocal = hasSavedWallets
if (hasSavedWalletsLocal == null || !hasSavedWalletsLocal) {
val hasKeys = keysRepository.hasSavedEncryptionKeys()
hasSavedWallets = hasKeys
hasKeys
} else {
Timber.i("User has saved wallets (from cache)")
true
}
}
}
}
override val walletsCount: Int
get() = state.value.userWallets.size
override val savedWalletsCount: Flow<Int>
get() = state
.mapLatest { walletsCount }
.distinctUntilChanged()
override suspend fun unlock(type: UnlockType): CompletionResult<UserWallet> {
return withContext(dispatcherProvider.io) {
unlockAndSetSelectedUserWallet(type)
.mapFailure { error ->
Timber.e(error, "Unable to unlock user wallets")
if (error is UserWalletsListError) {
error
} else {
UserWalletsListError.UnableToUnlockUserWallets(error)
}
}
.map { selectedUserWallet ->
if (selectedUserWallet == null || selectedUserWallet.isLocked) {
Timber.e("Unable to find selected user wallet")
throw UserWalletsListError.NoUserWalletSelected
} else {
selectedUserWallet
}
}
}
}
override fun lock() {
state.update { prevState ->
prevState.copy(
encryptionKeys = emptyList(),
userWallets = prevState.userWallets.lockAll(),
isLocked = true,
)
}
}
override suspend fun select(userWalletId: UserWalletId): CompletionResult<UserWallet> = catching {
if (state.value.selectedUserWalletId == userWalletId) {
return@catching requireNotNull(findSelectedUserWallet()) {
"Wallet is not found"
}
}
selectedUserWalletRepository.set(userWalletId)
val newState = state.updateAndGet { prevState ->
prevState.copy(
selectedUserWalletId = userWalletId,
)
}
newState.userWallets.first { it.walletId == userWalletId }
}
override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult<Unit> {
return withContext(dispatcherProvider.io) {
if (canOverride) {
saveInternal(userWallet, changeSelectedUserWallet = true, canOverridePublicInfo = false)
} else {
val isWalletSaved = state.value.userWallets
.any {
it.walletId == userWallet.walletId
}
if (isWalletSaved) {
CompletionResult.Failure(UserWalletsListError.WalletAlreadySaved)
} else {
saveInternal(userWallet, changeSelectedUserWallet = true, canOverridePublicInfo = false)
}
}
}
}
override suspend fun update(
userWalletId: UserWalletId,
update: suspend (UserWallet) -> UserWallet,
): CompletionResult<UserWallet> {
return withContext(dispatcherProvider.io) {
get(userWalletId)
.map { storedUserWallet ->
update(storedUserWallet)
}
.flatMap { updatedUserWallet ->
saveInternal(updatedUserWallet, changeSelectedUserWallet = false, canOverridePublicInfo = true)
}
.flatMap {
get(userWalletId)
}
}
}
override suspend fun delete(userWalletIds: List<UserWalletId>): CompletionResult<Unit> {
val idsToRemove = state.value.userWallets
.takeIf { it.isNotEmpty() }
?.filter { it.walletId in userWalletIds }
?.map { it.walletId }
if (idsToRemove.isNullOrEmpty()) {
return CompletionResult.Success(Unit)
}
if (idsToRemove.size == state.value.userWallets.size) {
return clear()
}
return withContext(dispatcherProvider.io) {
sensitiveInformationRepository.delete(idsToRemove)
.flatMap { publicInformationRepository.delete(idsToRemove) }
.map { keysRepository.delete(idsToRemove) }
.map {
state.update { prevState ->
val remainingWallets = prevState.userWallets.filter { it.walletId !in idsToRemove }
val isSelectedWalletDeleted = prevState.selectedUserWalletId in idsToRemove
val newSelectedUserWallet = findOrSetSelectedWallet(
prevSelectedWalletId = prevState.selectedUserWalletId,
prevSelectedWalletIndex = prevState.userWallets.indexOfFirst {
it.walletId == prevState.selectedUserWalletId
},
userWallets = remainingWallets,
ignorePrevSelectedWallet = isSelectedWalletDeleted,
)
prevState.copy(
encryptionKeys = prevState.encryptionKeys.filter { it.walletId !in idsToRemove },
userWallets = remainingWallets,
isLocked = remainingWallets.any { it.isLocked },
selectedUserWalletId = newSelectedUserWallet?.walletId,
)
}
}
}
}
override suspend fun clear(): CompletionResult<Unit> {
savedWalletMutex.withLock {
hasSavedWallets = null
}
return withContext(dispatcherProvider.io) {
sensitiveInformationRepository.clear()
.flatMap { publicInformationRepository.clear() }
.map {
keysRepository.clear()
selectedUserWalletRepository.set(null)
state.value = State()
}
}
}
override suspend fun get(userWalletId: UserWalletId): CompletionResult<UserWallet> {
return catching {
state.value.userWallets.first { it.walletId == userWalletId }
}
}
private suspend fun saveInternal(
userWallet: UserWallet,
changeSelectedUserWallet: Boolean,
canOverridePublicInfo: Boolean,
): CompletionResult<Unit> {
val encryptionKey = userWallet.encryptionKey
?.let { UserWalletEncryptionKey(userWallet.walletId, it) }
?: return CompletionResult.Success(Unit) // No encryption key, no need to save
return keysRepository.save(encryptionKey)
.flatMap { sensitiveInformationRepository.save(userWallet, encryptionKey = encryptionKey.encryptionKey) }
.flatMap { publicInformationRepository.save(userWallet, canOverridePublicInfo) }
.flatMap {
loadUserWallets(
encryptionKeys = state.value.encryptionKeys
.plus(encryptionKey)
.distinctBy(UserWalletEncryptionKey::walletId),
)
}
.doOnSuccess { loadedState ->
if (changeSelectedUserWallet) {
selectedUserWalletRepository.set(userWallet.walletId)
state.value = loadedState.copy(
selectedUserWalletId = userWallet.walletId,
)
} else {
state.value = loadedState
}
}
.map { /* Type erasing */ }
}
private suspend fun unlockAndSetSelectedUserWallet(type: UnlockType): CompletionResult<UserWallet?> {
return keysRepository.getAll()
.flatMap { encryptionKeys ->
loadUserWallets(
encryptionKeys = state.value.encryptionKeys
.plus(encryptionKeys)
.distinctBy(UserWalletEncryptionKey::walletId),
)
}
.map { loadedState ->
when (type) {
UnlockType.ALL -> {
if (loadedState.isLocked) {
Timber.e("Some user wallets remain locked")
state.value = loadedState
throw UserWalletsListError.NotAllUserWalletsUnlocked
} else {
val prevState = state.value
val selectedWallet = findOrSetSelectedWallet(
prevSelectedWalletId = prevState.selectedUserWalletId,
userWallets = loadedState.userWallets,
prevSelectedWalletIndex = prevState.userWallets.indexOfFirst {
it.walletId == prevState.selectedUserWalletId
},
)
state.value = loadedState.copy(selectedUserWalletId = selectedWallet?.walletId)
selectedWallet
}
}
UnlockType.ANY -> {
val prevState = state.value
val selectedWallet = findOrSetSelectedWallet(
prevSelectedWalletId = state.value.selectedUserWalletId,
prevSelectedWalletIndex = prevState.userWallets.indexOfFirst {
it.walletId == prevState.selectedUserWalletId
},
userWallets = loadedState.userWallets,
)
state.value = loadedState.copy(selectedUserWalletId = selectedWallet?.walletId)
selectedWallet
}
UnlockType.ALL_WITHOUT_SELECT -> {
state.value = loadedState
findSelectedUserWallet()
}
}
}
}
private suspend fun loadUserWallets(encryptionKeys: List<UserWalletEncryptionKey>): CompletionResult<State> {
return publicInformationRepository.getAll()
.map { it.toUserWallets() }
.flatMap { userWallets ->
sensitiveInformationRepository.getAll(encryptionKeys)
.map { walletIdToSensitiveInformation ->
userWallets.updateWith(walletIdToSensitiveInformation)
}
}
.map { userWallets ->
val prevState = state.value
if (userWallets.isNotEmpty()) {
val newUserWallets = (userWallets + prevState.userWallets)
.distinctBy(UserWallet::walletId)
prevState.copy(
userWallets = newUserWallets,
encryptionKeys = encryptionKeys,
isLocked = newUserWallets.any(UserWallet::isLocked),
)
} else {
prevState
}
}
}
private suspend fun findOrSetSelectedWallet(
prevSelectedWalletId: UserWalletId?,
prevSelectedWalletIndex: Int,
userWallets: List<UserWallet>,
ignorePrevSelectedWallet: Boolean = false,
): UserWallet? {
var possibleSelectedUserWallet: UserWallet? = null
if (!ignorePrevSelectedWallet) {
val selectedWalletId = prevSelectedWalletId ?: selectedUserWalletRepository.get()
possibleSelectedUserWallet = findSelectedUserWallet(userWallets, selectedWalletId)
}
if (possibleSelectedUserWallet == null || possibleSelectedUserWallet.isLocked) {
possibleSelectedUserWallet =
userWallets.findAvailableUserWallet(prevSelectedIndex = prevSelectedWalletIndex)
}
selectedUserWalletRepository.set(possibleSelectedUserWallet?.walletId)
return possibleSelectedUserWallet
}
/**
* Find the nearest available wallet that can be selected
*
* Example:
* Number with *n* is previous selected wallet with index [prevSelectedIndex].
*
* 1. [*1*, 2, 3, 4] => delete 1 => [2, 3, 4] => find and select => [*2*, 3, 4]
* 2. [1, *2*, 3, 4] => delete 2 => [1, 3, 4] => find and select => [1, *3*, 4]
* 3. [1, 2, *3*, 4] => delete 3 => [1, 2, 4] => find and select => [1, 2, *4*]
* 4. [1, 2, 3, *4*] => delete 4 => [1, 2, 3] => find and select => [1, 2, *3*]
*
* @receiver list of user wallets without deleted wallet
*/
private fun List<UserWallet>.findAvailableUserWallet(prevSelectedIndex: Int): UserWallet? {
if (prevSelectedIndex == 0) return firstOrNull { !it.isLocked } ?: firstOrNull()
if (prevSelectedIndex in indices && !this[prevSelectedIndex].isLocked) return this[prevSelectedIndex]
for (offset in 1..size) {
val rightIndex = prevSelectedIndex + offset
if (rightIndex in indices && !this[rightIndex].isLocked) return this[rightIndex]
val leftIndex = prevSelectedIndex - offset
if (leftIndex in indices && !this[leftIndex].isLocked) return this[leftIndex]
}
return lastOrNull()
}
private fun findSelectedUserWallet(
userWallets: List<UserWallet> = state.value.userWallets,
selectedUserWalletId: UserWalletId? = state.value.selectedUserWalletId,
): UserWallet? {
return userWallets.firstOrNull { it.walletId == selectedUserWalletId }
}
private data class State(
val encryptionKeys: List<UserWalletEncryptionKey> = emptyList(),
val userWallets: List<UserWallet> = emptyList(),
val selectedUserWalletId: UserWalletId? = null,
val isLocked: Boolean = true,
)
}

View file

@ -1,203 +0,0 @@
package com.tangem.tap.domain.userWalletList.implementation
import com.tangem.common.CompletionResult
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import timber.log.Timber
/**
* General implementation of [UserWalletsListManager] that helps to switch between Runtime and Biometric
* implementations.
*
* @property runtimeUserWalletsListManager runtime user wallets list manager
* @property biometricUserWalletsListManager biometric user wallets list manager
* @property appPreferencesStore app preferences store
* @property dispatchers coroutine dispatcher provider
*
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class GeneralUserWalletsListManager(
private val runtimeUserWalletsListManager: UserWalletsListManager,
private val biometricUserWalletsListManager: UserWalletsListManager,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : UserWalletsListManager.Lockable {
private val applicationScope = CoroutineScope(dispatchers.io)
private val implementation: MutableStateFlow<UserWalletsListManager?> = MutableStateFlow(value = null)
private val requireImplementation: UserWalletsListManager
get() = requireNotNull(implementation.value) {
"UserWalletsListManager is not initialized"
}
init {
subscribeOnCurrentManager()
}
override val isLockable: Boolean
get() = requireImplementation.isLockable
override val userWallets: Flow<List<UserWallet>>
get() = implementation
.transformLatest { impl ->
if (impl != null) {
emitAll(impl.userWallets)
}
}
// To avoid returning empty flow to subscriber while implementation and userWallets are null
// Flow is called first time when implementation is null and then when its assigned with implementation
// that may have not user wallets (null or empty).
// As a result subscription occurs on empty flow, than will not change if user wallets are available
.filter { requireImplementation.hasUserWallets }
override val savedWalletsCount: Flow<Int>
get() = implementation
.transformLatest { impl ->
if (impl != null) {
emitAll(impl.savedWalletsCount)
}
}
override val userWalletsSync: List<UserWallet>
get() = requireImplementation.userWalletsSync
override val selectedUserWallet: Flow<UserWallet>
get() = implementation
.transformLatest { impl ->
if (impl != null) {
emitAll(impl.selectedUserWallet)
}
}
// To avoid returning empty flow to subscriber while implementation and userWallets are null
// Flow is called first time when implementation is null and then when its assigned with implementation
// that may have not user wallets (null or empty).
// As a result subscription occurs on empty flow, than will not change if user wallets are available
.filter { requireImplementation.hasUserWallets }
override val selectedUserWalletSync: UserWallet?
get() = requireImplementation.selectedUserWalletSync
override val hasUserWallets: Boolean
get() = requireImplementation.hasUserWallets
override val walletsCount: Int
get() = requireImplementation.walletsCount
override val lockedState: Flow<Boolean>
get() = implementation.transformLatest { impl ->
if (impl == null) return@transformLatest
if (impl is UserWalletsListManager.Lockable) {
emitAll(impl.lockedState)
} else {
error("RuntimeUserWalletsListManager is not lockable")
}
}
override val isLocked: Boolean
get() {
val impl = requireImplementation
return if (impl is UserWalletsListManager.Lockable) {
impl.isLocked
} else {
error("RuntimeUserWalletsListManager is not lockable")
}
}
override suspend fun select(userWalletId: UserWalletId): CompletionResult<UserWallet> {
return requireImplementation.select(userWalletId)
}
override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult<Unit> {
return requireImplementation.save(userWallet, canOverride)
}
override suspend fun update(
userWalletId: UserWalletId,
update: suspend (UserWallet) -> UserWallet,
): CompletionResult<UserWallet> {
return requireImplementation.update(userWalletId, update)
}
override suspend fun delete(userWalletIds: List<UserWalletId>): CompletionResult<Unit> {
return requireImplementation.delete(userWalletIds)
}
override suspend fun clear(): CompletionResult<Unit> {
return requireImplementation.clear()
}
override suspend fun get(userWalletId: UserWalletId): CompletionResult<UserWallet> {
return requireImplementation.get(userWalletId)
}
override suspend fun unlock(type: UserWalletsListManager.Lockable.UnlockType): CompletionResult<UserWallet> {
val implementation = requireImplementation
return if (implementation is UserWalletsListManager.Lockable) {
implementation.unlock(type)
} else {
error("RuntimeUserWalletsListManager is not lockable")
}
}
override fun lock() {
val implementation = requireImplementation
return if (implementation is UserWalletsListManager.Lockable) {
implementation.lock()
} else {
error("RuntimeUserWalletsListManager is not lockable")
}
}
private fun subscribeOnCurrentManager() {
appPreferencesStore.get(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false)
.distinctUntilChanged()
.onEach { shouldSaveUserWallets ->
val possibleManager = if (shouldSaveUserWallets) {
biometricUserWalletsListManager
} else {
runtimeUserWalletsListManager
}
if (possibleManager == implementation.value) {
Timber.e("Switch to the same manager ${possibleManager::class.simpleName.orEmpty()}")
}
Timber.i("Switch to ${possibleManager::class.simpleName.orEmpty()}")
val previousManager = implementation.value
implementation.value = copySelectedUserWallet(
sourceManager = previousManager,
destinationManager = possibleManager,
)
previousManager?.clear()
}
.flowOn(dispatchers.io)
.launchIn(applicationScope)
}
private suspend fun copySelectedUserWallet(
sourceManager: UserWalletsListManager?,
destinationManager: UserWalletsListManager,
): UserWalletsListManager {
sourceManager?.selectedUserWalletSync?.let { selectedWallet ->
destinationManager.save(selectedWallet, canOverride = true)
}
return destinationManager
}
}

View file

@ -1,115 +0,0 @@
package com.tangem.tap.domain.userWalletList.implementation
import com.tangem.common.CompletionResult
import com.tangem.common.catching
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.legacy.UserWalletsListError
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
@OptIn(ExperimentalCoroutinesApi::class)
internal class RuntimeUserWalletsListManager : UserWalletsListManager {
private val state = MutableStateFlow(State())
override val isLockable: Boolean = false
override val userWallets: Flow<List<UserWallet>>
get() = state
.mapLatest { listOfNotNull(it.userWallet) }
.distinctUntilChanged()
override val selectedUserWallet: Flow<UserWallet>
get() = state
.mapLatest { it.userWallet }
.filterNotNull()
.distinctUntilChanged()
override val userWalletsSync: List<UserWallet>
get() = listOfNotNull(state.value.userWallet)
override val selectedUserWalletSync: UserWallet?
get() = state.value.userWallet
override val hasUserWallets: Boolean
get() = state.value.userWallet != null
/**
* only 1 wallet stored in runtime implementation
*/
override val walletsCount: Int
get() = if (hasUserWallets) 1 else 0
override val savedWalletsCount: Flow<Int>
get() = state
.mapLatest { walletsCount }
.distinctUntilChanged()
override suspend fun select(userWalletId: UserWalletId): CompletionResult<UserWallet> = catching {
state.value.userWallet
?.takeIf { it.walletId == userWalletId }
?: walletNotFound()
}
override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult<Unit> {
return if (canOverride) {
saveInternal(userWallet)
} else {
val isWalletSaved = state.value.userWallet?.walletId == userWallet.walletId
if (isWalletSaved) {
CompletionResult.Failure(UserWalletsListError.WalletAlreadySaved)
} else {
saveInternal(userWallet)
}
}
}
override suspend fun update(
userWalletId: UserWalletId,
update: suspend (UserWallet) -> UserWallet,
): CompletionResult<UserWallet> = catching {
val wallet = state.value.userWallet
?.takeIf { it.walletId == userWalletId }
?: walletNotFound()
requireNotNull(
state.updateAndGet { prevState ->
prevState.copy(
userWallet = update(wallet),
)
}.userWallet,
) { "User wallet is null after update" }
}
override suspend fun delete(userWalletIds: List<UserWalletId>): CompletionResult<Unit> = clear()
override suspend fun clear(): CompletionResult<Unit> = catching {
state.update { prevState ->
prevState.copy(
userWallet = null,
)
}
}
override suspend fun get(userWalletId: UserWalletId): CompletionResult<UserWallet> = catching {
state.value.userWallet ?: walletNotFound()
}
private fun saveInternal(userWallet: UserWallet): CompletionResult<Unit> = catching {
state.update { prevState ->
prevState.copy(
userWallet = userWallet,
)
}
}
private fun walletNotFound(): Nothing {
throw NoSuchElementException("User wallet not found")
}
private data class State(
val userWallet: UserWallet? = null,
)
}

View file

@ -13,6 +13,7 @@ import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.domain.common.wallets.UserWalletTransformAction
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod
import com.tangem.domain.common.wallets.error.*
@ -30,19 +31,20 @@ import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
import com.tangem.tap.domain.userWalletList.utils.encryptionKey
import com.tangem.tap.domain.userWalletList.utils.lock
import com.tangem.tap.domain.userWalletList.utils.toUserWallets
import com.tangem.tap.domain.userWalletList.utils.updateWith
import com.tangem.tap.domain.userWalletList.utils.*
import com.tangem.utils.Provider
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.extensions.addOrReplace
import com.tangem.utils.extensions.indexOfFirstOrNull
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@Suppress("LongParameterList", "LargeClass")
internal class DefaultUserWalletsListRepository(
@ -64,8 +66,17 @@ internal class DefaultUserWalletsListRepository(
override val userWallets = MutableStateFlow<List<UserWallet>?>(null)
override val selectedUserWallet = MutableStateFlow<UserWallet?>(null)
private val mutex = Mutex()
override fun getSyncOrNull(id: UserWalletId): UserWallet? {
return userWallets.value?.find { it.walletId == id }
}
override fun getSyncStrict(id: UserWalletId): UserWallet {
return requireNotNull(getSyncOrNull(id)) { "Unable to find user wallet with provided ID: $id" }
}
override suspend fun load() {
mutex.withLock {
if (userWallets.value != null) return
@ -100,6 +111,13 @@ internal class DefaultUserWalletsListRepository(
}
}
override fun loadAndGet(): Flow<List<UserWallet>> = flow {
load()
userWallets.collect {
emit(requireNotNull(it))
}
}
override suspend fun userWalletsSync(): List<UserWallet> {
load()
return requireNotNull(userWallets.value) {
@ -295,8 +313,7 @@ internal class DefaultUserWalletsListRepository(
}
val scanResponse = unlockMethod.scanResponse ?: run {
val res = tangemSdkManagerProvider().scanProduct()
when (res) {
when (val res = tangemSdkManagerProvider().scanProduct()) {
is CompletionResult.Failure -> raise(UnlockWalletError.UserCancelled)
is CompletionResult.Success -> res.data
}
@ -395,6 +412,44 @@ internal class DefaultUserWalletsListRepository(
return userWallets.any { it.walletId !in unsecuredWalletIds }
}
override suspend fun transform(action: UserWalletTransformAction) {
val wallets = userWalletsSync()
val walletsMap = wallets.associateBy { it.walletId }
val transformedWallets = action.transform(wallets)
val transformedWalletsMap = transformedWallets.associateBy { it.walletId }
require(walletsMap.keys == transformedWalletsMap.keys) {
"The transformation action must not change the set of wallet IDs." +
"Original IDs: ${walletsMap.keys}, Transformed IDs: ${transformedWalletsMap.keys}"
}
updateWallets { transformedWallets }
withContext(NonCancellable) {
if (savePersistentInformation()) {
publicInformationRepository.transform {
transformedWallets.map { wallet -> wallet.publicInformation }
}
val changedSensitiveInfoWallets = wallets.filter { wallet ->
val transformedWallet = transformedWalletsMap[wallet.walletId]
transformedWallet != null && transformedWallet.sensitiveInformation != wallet.sensitiveInformation
}
changedSensitiveInfoWallets.forEach { wallet ->
if (wallet.isLocked.not()) {
sensitiveInformationRepository.save(wallet, wallet.encryptionKey)
}
checkForUpgradeAndDeleteHotWalletIfNeeded(
newUserWallet = wallet,
oldUserWallet = walletsMap[wallet.walletId] ?: error("This should never happen"),
)
}
}
}
}
private suspend fun checkForUpgradeAndDeleteHotWalletIfNeeded(
newUserWallet: UserWallet,
oldUserWallet: UserWallet,
@ -435,9 +490,7 @@ internal class DefaultUserWalletsListRepository(
authMode = true, // In auth mode user wallet can be deleted after 30 failed attempts
hasBiometry = hasBiometry(),
)
val result = passwordRequester.requestPassword(attemptRequest)
return when (result) {
return when (val result = passwordRequester.requestPassword(attemptRequest)) {
HotWalletPasswordRequester.Result.Dismiss -> {
passwordRequester.dismiss()
UnlockWalletError.UserCancelled.left()

View file

@ -12,4 +12,8 @@ internal interface UserWalletsPublicInformationRepository {
suspend fun delete(walletIds: List<UserWalletId>): CompletionResult<Unit>
suspend fun clear(): CompletionResult<Unit>
suspend fun transform(
block: (List<UserWalletPublicInformation>?) -> List<UserWalletPublicInformation>?,
): CompletionResult<Unit>
}

View file

@ -1,39 +0,0 @@
package com.tangem.tap.domain.userWalletList.repository.implementation
import com.tangem.common.core.TangemError
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.wallets.legacy.UserWalletsListError
import com.tangem.utils.converter.Converter
object BiometricFailReasonConverter : Converter<TangemError, Basic.BiometryFailed.BiometricFailReason> {
override fun convert(value: TangemError): Basic.BiometryFailed.BiometricFailReason {
// 1. Try handle TangemSdkError cases first if error has not been mapped
when (value) {
is TangemSdkError.AuthenticationCanceled ->
return Basic.BiometryFailed.BiometricFailReason.AuthenticationCancelled
is TangemSdkError.AuthenticationAlreadyInProgress ->
return Basic.BiometryFailed.BiometricFailReason.AuthenticationAlreadyInProgress
}
// 2. For other errors, check if they are of type UserWalletsListError
if (value !is UserWalletsListError) {
return Basic.BiometryFailed.BiometricFailReason.Other(value.customMessage)
}
// 3. Map UserWalletsListError to BiometricFailReason
return when (value) {
UserWalletsListError.AllKeysInvalidated ->
Basic.BiometryFailed.BiometricFailReason.AllKeysInvalidated
UserWalletsListError.BiometricsAuthenticationDisabled ->
Basic.BiometryFailed.BiometricFailReason.BiometricsAuthenticationDisabled
is UserWalletsListError.BiometricsAuthenticationLockout ->
if (value.isPermanent) {
Basic.BiometryFailed.BiometricFailReason.AuthenticationLockoutPermanent
} else {
Basic.BiometryFailed.BiometricFailReason.AuthenticationLockout
}
else -> Basic.BiometryFailed.BiometricFailReason.Other(value.customMessage)
}
}
}

View file

@ -1,207 +0,0 @@
package com.tangem.tap.domain.userWalletList.repository.implementation
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.tangem.common.*
import com.tangem.common.authentication.storage.AuthenticatedStorage
import com.tangem.common.core.TangemSdkError
import com.tangem.common.services.secure.SecureStorage
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.legacy.UserWalletsListError
import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey
import com.tangem.tap.domain.userWalletList.repository.UserWalletsKeysRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
internal class BiometricUserWalletsKeysRepository(
moshi: Moshi,
private val authenticatedStorage: AuthenticatedStorage,
private val secureStorage: SecureStorage,
private val analyticsEventHandler: AnalyticsEventHandler,
) : UserWalletsKeysRepository {
private val encryptionKeyAdapter: JsonAdapter<UserWalletEncryptionKey> = moshi.adapter(
UserWalletEncryptionKey::class.java,
)
private val userWalletsIdsListAdapter: JsonAdapter<List<UserWalletId>> = moshi.adapter(
Types.newParameterizedType(List::class.java, UserWalletId::class.java),
)
override suspend fun getAll(): CompletionResult<List<UserWalletEncryptionKey>> {
return withContext(Dispatchers.IO) {
getAllInternal()
.mapFailure { error ->
val mappedError = when (error) {
is TangemSdkError.AuthenticationLockout ->
UserWalletsListError.BiometricsAuthenticationLockout(isPermanent = false)
is TangemSdkError.AuthenticationPermanentLockout ->
UserWalletsListError.BiometricsAuthenticationLockout(isPermanent = true)
is TangemSdkError.KeystoreInvalidated ->
UserWalletsListError.AllKeysInvalidated
is TangemSdkError.AuthenticationUnavailable ->
UserWalletsListError.BiometricsAuthenticationDisabled
else -> error
}
analyticsEventHandler.send(
Basic.BiometryFailed(
source = AnalyticsParam.ScreensSources.SignIn,
reason = BiometricFailReasonConverter.convert(mappedError),
),
)
mappedError
}
}
}
override suspend fun save(encryptionKey: UserWalletEncryptionKey): CompletionResult<Unit> {
return withContext(Dispatchers.IO) {
storeEncryptionKey(encryptionKey)
}
}
override suspend fun delete(userWalletsIds: List<UserWalletId>) {
return withContext(Dispatchers.IO) {
userWalletsIds.forEach { userWalletId ->
deleteEncryptionKey(userWalletId)
}
deleteUserWalletsIds(userWalletsIds)
}
}
override suspend fun clear() {
return withContext(Dispatchers.IO) {
getUserWalletsIds()
.forEach { userWalletId ->
deleteEncryptionKey(userWalletId)
}
clearUserWalletsIds()
}
}
override fun hasSavedEncryptionKeys(): Boolean {
return runBlocking {
getUserWalletsIds().isNotEmpty()
}
}
private suspend fun getAllInternal(): CompletionResult<List<UserWalletEncryptionKey>> {
return catching {
val userWalletIds = getUserWalletsIds()
getEncryptionKeys(userWalletIds)
}
.doOnFailure { error ->
when (error) {
is TangemSdkError.KeystoreInvalidated -> {
getUserWalletsIds().forEach { userWalletId ->
deleteEncryptionKey(userWalletId)
}
}
else -> Unit
}
}
}
private suspend fun getEncryptionKeys(userWalletsIds: List<UserWalletId>): List<UserWalletEncryptionKey> {
val keys = userWalletsIds.map { userWalletId ->
StorageKey.UserWalletEncryptionKey(userWalletId).name
}
return authenticatedStorage.get(keys).mapNotNull { (_, encodedData) ->
encodedData.decodeToKey()
}
}
private suspend fun storeEncryptionKey(encryptionKey: UserWalletEncryptionKey): CompletionResult<Unit> {
return catching {
authenticatedStorage.store(
keyAlias = StorageKey.UserWalletEncryptionKey(encryptionKey.walletId).name,
data = encryptionKey.encode(),
)
}
.map { storeUserWalletId(encryptionKey.walletId) }
}
private fun deleteEncryptionKey(userWalletId: UserWalletId) {
authenticatedStorage.delete(StorageKey.UserWalletEncryptionKey(userWalletId).name)
}
private suspend fun getUserWalletsIds(): List<UserWalletId> {
return withContext(Dispatchers.IO) {
secureStorage.get(StorageKey.UserWalletIds.name)
.decodeToUserWalletsIds()
}
}
private suspend fun storeUserWalletId(userWalletId: UserWalletId) {
val userWalletIds = (getUserWalletsIds() + userWalletId).distinct()
withContext(Dispatchers.IO) {
secureStorage.store(userWalletIds.encode(), StorageKey.UserWalletIds.name)
}
}
private suspend fun deleteUserWalletsIds(userWalletsIds: List<UserWalletId>) {
val remainingIds = getUserWalletsIds() - userWalletsIds.toSet()
withContext(Dispatchers.IO) {
secureStorage.store(remainingIds.encode(), StorageKey.UserWalletIds.name)
}
}
private suspend fun clearUserWalletsIds() {
withContext(Dispatchers.IO) {
secureStorage.delete(StorageKey.UserWalletIds.name)
}
}
private suspend fun UserWalletEncryptionKey.encode(): ByteArray {
return withContext(Dispatchers.Default) {
encryptionKeyAdapter.toJson(this@encode)
.encodeToByteArray(throwOnInvalidSequence = true)
}
}
private suspend fun ByteArray?.decodeToKey(): UserWalletEncryptionKey? {
return withContext(Dispatchers.Default) {
this@decodeToKey
?.decodeToString(throwOnInvalidSequence = true)
?.let(encryptionKeyAdapter::fromJson)
}
}
private suspend fun List<UserWalletId>.encode(): ByteArray {
return withContext(Dispatchers.Default) {
userWalletsIdsListAdapter.toJson(this@encode)
.encodeToByteArray(throwOnInvalidSequence = true)
}
}
private suspend fun ByteArray?.decodeToUserWalletsIds(): List<UserWalletId> {
return withContext(Dispatchers.Default) {
this@decodeToUserWalletsIds
?.decodeToString(throwOnInvalidSequence = true)
?.let(userWalletsIdsListAdapter::fromJson)
.orEmpty()
}
}
private sealed interface StorageKey {
val name: String
class UserWalletEncryptionKey(userWalletId: UserWalletId) : StorageKey {
override val name: String = "user_wallet_encryption_key_${userWalletId.stringValue}"
}
object UserWalletIds : StorageKey {
override val name: String = "user_wallets_ids_with_saved_keys"
}
}
}

View file

@ -77,6 +77,21 @@ internal class DefaultUserWalletsPublicInformationRepository(
}
}
override suspend fun transform(
block: (List<UserWalletPublicInformation>?) -> List<UserWalletPublicInformation>?,
): CompletionResult<Unit> {
return withContext(Dispatchers.IO) {
getAll().flatMap { currentInfo ->
val transformed = block(currentInfo)
if (transformed != null) {
save(transformed)
} else {
CompletionResult.Success(Unit)
}
}
}
}
@JvmName("saveWithPublicInformation")
private suspend fun save(publicInformation: List<UserWalletPublicInformation>): CompletionResult<Unit> = catching {
withContext(Dispatchers.IO) {

View file

@ -104,8 +104,6 @@ internal fun List<UserWallet>.updateWith(
}
}
internal fun List<UserWallet>.lockAll(): List<UserWallet> = map(UserWallet::lock)
internal fun UserWallet.lock(): UserWallet = when (this) {
is UserWallet.Cold -> {
copy(

View file

@ -3,15 +3,12 @@ package com.tangem.tap.features.details.redux
import com.tangem.common.CompletionResult
import com.tangem.common.doOnFailure
import com.tangem.common.doOnSuccess
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.Analytics
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
@ -43,7 +40,7 @@ class DetailsMiddleware {
if (!DemoHelper.tryHandle(stateProvider)) {
val detailsState = stateProvider()?.detailsState
if (detailsState != null) {
handleAction(detailsState, action)
handleAction(action)
}
}
next(action)
@ -51,9 +48,9 @@ class DetailsMiddleware {
}
}
private fun handleAction(state: DetailsState, action: Action) {
private fun handleAction(action: Action) {
when (action) {
is DetailsAction.AppSettings -> appSettingsMiddleware.handle(state, action)
is DetailsAction.AppSettings -> appSettingsMiddleware.handle(action)
}
}
@ -61,12 +58,10 @@ class DetailsMiddleware {
private val checkBiometricsStatusJobHolder = JobHolder()
fun handle(state: DetailsState, action: DetailsAction.AppSettings) {
fun handle(action: DetailsAction.AppSettings) {
when (action) {
is DetailsAction.AppSettings.SwitchPrivacySetting -> {
when (action.setting) {
AppSetting.SaveWallets -> toggleSaveWallets(state, enable = action.enable)
AppSetting.SaveAccessCode -> toggleSaveAccessCodes(state, enable = action.enable)
AppSetting.RequireAccessCode -> toggleRequireAccessCode(enable = action.enable)
AppSetting.BiometricAuthentication -> toggleBiometricsAuthentication(enable = action.enable)
}
@ -221,118 +216,6 @@ class DetailsMiddleware {
}
}
private fun toggleSaveWallets(state: DetailsState, enable: Boolean) = scope.launch {
// Nothing to change
val walletsRepository = store.inject(DaggerGraphState::walletsRepository)
if (walletsRepository.shouldSaveUserWalletsSync() == enable) {
store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success)
return@launch
}
toggleSaveWallets(state.scanResponse, enable)
.doOnFailure {
store.dispatchWithMain(
DetailsAction.AppSettings.SwitchPrivacySetting.Failure(
prevState = !enable,
setting = AppSetting.SaveWallets,
),
)
}
.doOnSuccess {
store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success)
}
}
private suspend fun toggleSaveWallets(scanResponse: ScanResponse?, enable: Boolean): CompletionResult<Unit> {
return if (enable) {
saveCurrentWallet(scanResponse, enableAccessCodesSaving = false)
} else {
deleteSavedWalletsAndAccessCodes()
}
}
private fun toggleSaveAccessCodes(state: DetailsState, enable: Boolean) = scope.launch {
val shouldSaveAccessCodes = store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes()
// Nothing to change
if (shouldSaveAccessCodes == enable) {
store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success)
return@launch
}
toggleSaveAccessCodes(state.scanResponse, state.appSettingsState.saveWallets, enable)
.doOnFailure {
store.dispatchWithMain(
DetailsAction.AppSettings.SwitchPrivacySetting.Failure(
prevState = !enable,
setting = AppSetting.SaveAccessCode,
),
)
}
.doOnSuccess {
store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success)
}
}
private suspend fun toggleSaveAccessCodes(
scanResponse: ScanResponse?,
isWalletsSavingEnabled: Boolean,
enable: Boolean,
): CompletionResult<Unit> {
return if (enable) {
if (!isWalletsSavingEnabled) {
saveCurrentWallet(scanResponse, enableAccessCodesSaving = true)
} else {
saveAccessCodes(scanResponse)
}
} else {
deleteSavedAccessCodes()
}
}
private suspend fun saveCurrentWallet(
scanResponse: ScanResponse?,
enableAccessCodesSaving: Boolean,
): CompletionResult<Unit> {
store.inject(DaggerGraphState::walletsRepository).saveShouldSaveUserWallets(item = true)
return if (enableAccessCodesSaving) {
saveAccessCodes(scanResponse)
} else {
CompletionResult.Success(Unit)
}
.doOnSuccess {
Analytics.send(Settings.AppSettings.SaveWalletSwitcherChanged(AnalyticsParam.OnOffState.On))
}
.doOnFailure { error ->
Timber.e(error, "Unable to save user wallet")
}
}
private suspend fun deleteSavedWalletsAndAccessCodes(): CompletionResult<Unit> {
Analytics.send(Settings.AppSettings.SaveWalletSwitcherChanged(AnalyticsParam.OnOffState.Off))
deleteSavedAccessCodes()
store.inject(DaggerGraphState::walletsRepository).saveShouldSaveUserWallets(item = false)
store.dispatchNavigationAction { replaceAll(AppRoute.Home()) }
return CompletionResult.Success(Unit)
}
private suspend fun saveAccessCodes(scanResponse: ScanResponse?): CompletionResult<Unit> {
Analytics.send(Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.On))
store.inject(DaggerGraphState::settingsRepository).setShouldSaveAccessCodes(value = true)
store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = scanResponse?.card?.isAccessCodeSet == true,
)
return CompletionResult.Success(Unit)
}
private suspend fun deleteSavedAccessCodes(): CompletionResult<Unit> {
return tangemSdkManager.clearSavedUserCodes()
.doOnSuccess {

View file

@ -38,15 +38,6 @@ private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: Detail
return when (action) {
is DetailsAction.AppSettings.SwitchPrivacySetting -> state.copy(
appSettingsState = when (action.setting) {
AppSetting.SaveWallets -> state.appSettingsState.copy(
isInProgress = true,
saveWallets = action.enable,
)
AppSetting.SaveAccessCode -> state.appSettingsState.copy(
isInProgress = true,
saveWallets = true, // User can't enable access codes saving without wallets saving
saveAccessCodes = action.enable,
)
AppSetting.RequireAccessCode -> state.appSettingsState.copy(
isInProgress = true,
requireAccessCode = action.enable,
@ -64,14 +55,6 @@ private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: Detail
)
is DetailsAction.AppSettings.SwitchPrivacySetting.Failure -> state.copy(
appSettingsState = when (action.setting) {
AppSetting.SaveWallets -> state.appSettingsState.copy(
isInProgress = false,
saveWallets = action.prevState,
)
AppSetting.SaveAccessCode -> state.appSettingsState.copy(
isInProgress = false,
saveAccessCodes = action.prevState,
)
AppSetting.RequireAccessCode -> state.appSettingsState.copy(
isInProgress = false,
requireAccessCode = action.prevState,
@ -105,9 +88,6 @@ private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: Detail
// state should be copied to avoid concurrent modifications from different sources
is DetailsAction.AppSettings.Prepare -> state.copy(
appSettingsState = state.appSettingsState.copy(
saveWallets = action.state.saveWallets,
saveAccessCodes = action.state.saveAccessCodes,
isBiometricsAvailable = action.state.isBiometricsAvailable,
isHidingEnabled = action.state.isHidingEnabled,
selectedAppCurrency = action.state.selectedAppCurrency,
selectedThemeMode = action.state.selectedThemeMode,

View file

@ -13,12 +13,6 @@ data class DetailsState(
@Suppress("BooleanPropertyNaming")
data class AppSettingsState(
@Deprecated("Delete after hot wallet release")
val saveWallets: Boolean = false,
@Deprecated("Delete after hot wallet release")
val saveAccessCodes: Boolean = false,
@Deprecated("Delete after hot wallet release")
val isBiometricsAvailable: Boolean = false,
val requireAccessCode: Boolean = false,
val useBiometricAuthentication: Boolean = false,
val needEnrollBiometrics: Boolean = false,
@ -32,5 +26,5 @@ data class AppSettingsState(
enum class SecurityOption { LongTap, PassCode, AccessCode }
enum class AppSetting {
SaveWallets, SaveAccessCode, RequireAccessCode, BiometricAuthentication,
RequireAccessCode, BiometricAuthentication,
}

View file

@ -9,26 +9,6 @@ import kotlinx.collections.immutable.toImmutableList
internal class AppSettingsDialogsFactory {
fun createDeleteSavedWalletsAlert(onDelete: () -> Unit, onDismiss: () -> Unit): Dialog.Alert {
return Dialog.Alert(
title = resourceReference(R.string.common_attention),
description = resourceReference(R.string.app_settings_off_saved_wallet_alert_message),
confirmText = resourceReference(R.string.common_delete),
onConfirm = onDelete,
onDismiss = onDismiss,
)
}
fun createDeleteSavedAccessCodesAlert(onDelete: () -> Unit, onDismiss: () -> Unit): Dialog.Alert {
return Dialog.Alert(
title = resourceReference(R.string.common_attention),
description = resourceReference(R.string.app_settings_off_saved_access_code_alert_message),
confirmText = resourceReference(R.string.common_delete),
onConfirm = onDelete,
onDismiss = onDismiss,
)
}
fun createThemeModeSelectorDialog(
selectedModeIndex: Int,
onSelect: (AppThemeMode) -> Unit,

View file

@ -19,21 +19,6 @@ internal class AppSettingsItemsFactory {
)
}
fun createSaveWalletsSwitch(
isChecked: Boolean,
isEnabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
): Item.Switch {
return Item.Switch(
id = ID_SAVE_WALLETS_SWITCH,
title = resourceReference(R.string.app_settings_saved_wallet),
description = resourceReference(R.string.app_settings_saved_wallet_footer),
isEnabled = isEnabled,
isChecked = isChecked,
onCheckedChange = onCheckedChange,
)
}
fun createUseBiometricsSwitch(
isChecked: Boolean,
isEnabled: Boolean,
@ -127,7 +112,6 @@ internal class AppSettingsItemsFactory {
companion object {
const val ID_ENROLL_BIOMETRICS_CARD = "enroll_biometrics_card"
const val ID_SAVE_WALLETS_SWITCH = "save_wallets_switch"
const val ID_SAVE_ACCESS_CODES_SWITCH = "save_access_codes_switch"
const val ID_FLIP_TO_HIDE_BALANCE_SWITCH = "flip_to_hide_balance_switch"
const val ID_SELECT_APP_CURRENCY_BUTTON = "select_app_currency_button"

View file

@ -97,7 +97,6 @@ private class AppSettingsScreenStateProvider : CollectionPreviewParameterProvide
val items = persistentListOf(
itemsFactory.createEnrollBiometricsCard {},
itemsFactory.createSelectAppCurrencyButton(currentAppCurrencyName = "US Dollar") {},
itemsFactory.createSaveWalletsSwitch(isChecked = true, isEnabled = true, { _ -> }),
itemsFactory.createSaveAccessCodeSwitch(isChecked = false, isEnabled = true) { _ -> },
itemsFactory.createFlipToHideBalanceSwitch(isChecked = false, isEnabled = true) { _ -> },
itemsFactory.createSelectThemeModeButton(AppThemeMode.DEFAULT, {}),

View file

@ -43,12 +43,12 @@ private fun AlertDialogPreview(@PreviewParameter(AlertDialogProvider::class) dia
}
}
private class AlertDialogProvider : CollectionPreviewParameterProvider<Dialog.Alert>(
private class AlertDialogProvider : CollectionPreviewParameterProvider<Dialog>(
collection = buildList {
val dialogsFactory = AppSettingsDialogsFactory()
add(dialogsFactory.createDeleteSavedAccessCodesAlert({}, {}))
add(dialogsFactory.createDeleteSavedWalletsAlert({}, {}))
add(dialogsFactory.createThemeModeSelectorDialog(selectedModeIndex = 0, onSelect = {}, onDismiss = {}))
add(dialogsFactory.createDisableBiometricAuthenticationAlert(onDisable = {}, onDismiss = {}))
},
)
// endregion Preview

View file

@ -14,10 +14,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
@ -52,14 +49,11 @@ internal class AppSettingsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val appCurrencyRepository: AppCurrencyRepository,
private val walletsRepository: WalletsRepository,
private val canUseBiometryUseCase: CanUseBiometryUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
private val balanceHidingRepository: BalanceHidingRepository,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appThemeModeRepository: AppThemeModeRepository,
private val settingsRepository: SettingsRepository,
private val appSettingsItemsAnalyticsSender: AppSettingsItemsAnalyticsSender,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val uiMessageSender: UiMessageSender,
) : Model(), StoreSubscriber<DetailsState> {
@ -120,47 +114,25 @@ internal class AppSettingsModel @Inject constructor(
),
)
if (hotWalletFeatureToggles.isHotWalletEnabled) {
val canUseBiometrics =
!state.needEnrollBiometrics && !state.isInProgress && state.hasSecuredWallets
val canUseBiometrics =
!state.needEnrollBiometrics && !state.isInProgress && state.hasSecuredWallets
add(
itemsFactory.createUseBiometricsSwitch(
isChecked = state.useBiometricAuthentication,
isEnabled = canUseBiometrics,
onCheckedChange = ::onBiometricAuthenticationToggled,
onDisabledClick = ::onBiometricAuthenticationDisabledClicked,
),
)
add(
itemsFactory.createUseBiometricsSwitch(
isChecked = state.useBiometricAuthentication,
isEnabled = canUseBiometrics,
onCheckedChange = ::onBiometricAuthenticationToggled,
onDisabledClick = ::onBiometricAuthenticationDisabledClicked,
),
)
add(
itemsFactory.createRequireAccessCodeSwitch(
isChecked = state.requireAccessCode || !state.useBiometricAuthentication,
isEnabled = canUseBiometrics && state.useBiometricAuthentication,
onCheckedChange = ::onRequireAccessCodeToggled,
),
)
} else {
if (state.isBiometricsAvailable) {
val canUseBiometrics = !state.needEnrollBiometrics && !state.isInProgress
add(
itemsFactory.createSaveWalletsSwitch(
isChecked = state.saveWallets,
isEnabled = canUseBiometrics,
onCheckedChange = ::onSaveWalletsToggled,
),
)
add(
itemsFactory.createSaveAccessCodeSwitch(
isChecked = state.saveAccessCodes,
isEnabled = canUseBiometrics,
onCheckedChange = ::onSaveAccessCodesToggled,
),
)
}
}
add(
itemsFactory.createRequireAccessCodeSwitch(
isChecked = state.requireAccessCode || !state.useBiometricAuthentication,
isEnabled = canUseBiometrics && state.useBiometricAuthentication,
onCheckedChange = ::onRequireAccessCodeToggled,
),
)
add(
itemsFactory.createFlipToHideBalanceSwitch(
@ -261,42 +233,6 @@ internal class AppSettingsModel @Inject constructor(
}
}
private fun onSaveWalletsToggled(isChecked: Boolean) {
if (isChecked) {
onSettingsToggled(AppSetting.SaveWallets, enable = true)
} else {
updateContentState {
copy(
dialog = dialogsFactory.createDeleteSavedWalletsAlert(
onDelete = {
onSettingsToggled(AppSetting.SaveWallets, enable = false)
dismissDialog()
},
onDismiss = ::dismissDialog,
),
)
}
}
}
private fun onSaveAccessCodesToggled(isChecked: Boolean) {
if (isChecked) {
onSettingsToggled(AppSetting.SaveAccessCode, enable = true)
} else {
updateContentState {
copy(
dialog = dialogsFactory.createDeleteSavedAccessCodesAlert(
onDelete = {
onSettingsToggled(AppSetting.SaveAccessCode, enable = false)
dismissDialog()
},
onDismiss = ::dismissDialog,
),
)
}
}
}
private fun onSettingsToggled(setting: AppSetting, enable: Boolean) {
store.dispatch(DetailsAction.AppSettings.SwitchPrivacySetting(enable = enable, setting = setting))
}
@ -326,9 +262,6 @@ internal class AppSettingsModel @Inject constructor(
private fun bootstrapBiometricsUpdates() = modelScope.launch {
val state = AppSettingsState(
saveWallets = walletsRepository.shouldSaveUserWalletsSync(),
saveAccessCodes = settingsRepository.shouldSaveAccessCodes(),
isBiometricsAvailable = canUseBiometryUseCase(),
useBiometricAuthentication = walletsRepository.useBiometricAuthentication(),
requireAccessCode = walletsRepository.requireAccessCode(),
isHidingEnabled = balanceHidingRepository.getBalanceHidingSettings().isHidingEnabledInSettings,

View file

@ -14,12 +14,9 @@ import com.tangem.domain.card.ResetCardUseCase
import com.tangem.domain.card.ResetCardUserCodeParams
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.legacy.asLockable
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.tap.common.analytics.events.Settings
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.tap.common.extensions.onUserWalletSelected
@ -52,10 +49,8 @@ internal class ResetCardModel @Inject constructor(
private val resetCardUseCase: ResetCardUseCase,
private val deleteSavedAccessCodesUseCase: DeleteSavedAccessCodesUseCase,
private val deleteWalletUseCase: DeleteWalletUseCase,
private val userWalletsListManager: UserWalletsListManager,
private val analyticsEventHandler: AnalyticsEventHandler,
private val cardSettingsInteractor: CardSettingsInteractor,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) : Model() {
private val params = paramsContainer.require<ResetCardComponent.Params>()
@ -277,16 +272,7 @@ internal class ResetCardModel @Inject constructor(
if (newSelectedWallet != null) {
store.dispatchNavigationAction { popTo<AppRoute.Wallet>() }
} else {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
store.dispatchNavigationAction { replaceAll(AppRoute.Home()) }
} else {
val isLocked = runCatching { userWalletsListManager.asLockable()?.isLocked }.isSuccess
if (isLocked && userWalletsListManager.hasUserWallets) {
store.dispatchNavigationAction { popTo<AppRoute.Welcome>() }
} else {
store.dispatchNavigationAction { replaceAll(AppRoute.Home()) }
}
}
store.dispatchNavigationAction { replaceAll(AppRoute.Home()) }
}
}

View file

@ -1,36 +0,0 @@
package com.tangem.tap.features.welcome.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.tap.features.welcome.model.WelcomeModel
import com.tangem.tap.features.welcome.ui.components.WelcomeScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultWelcomeComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: WelcomeComponent.Params,
) : WelcomeComponent, AppComponentContext by context {
private val model: WelcomeModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
WelcomeScreen(
modifier = modifier,
state = state,
)
}
@AssistedFactory
interface Factory : WelcomeComponent.Factory {
override fun create(context: AppComponentContext, params: WelcomeComponent.Params): DefaultWelcomeComponent
}
}

View file

@ -1,14 +0,0 @@
package com.tangem.tap.features.welcome.component
import com.tangem.common.routing.entity.InitScreenLaunchMode
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface WelcomeComponent : ComposableContentComponent {
data class Params(
val launchMode: InitScreenLaunchMode,
)
interface Factory : ComponentFactory<Params, WelcomeComponent>
}

View file

@ -1,20 +0,0 @@
package com.tangem.tap.features.welcome.component.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.tap.features.welcome.component.WelcomeComponent
import com.tangem.tap.features.welcome.ui.WelcomeScreenState
import com.tangem.tap.features.welcome.ui.components.WelcomeScreen
internal class PreviewWelcomeComponent(
private val initialState: WelcomeScreenState = WelcomeScreenState(),
) : WelcomeComponent {
@Composable
override fun Content(modifier: Modifier) {
WelcomeScreen(
modifier = modifier,
state = initialState,
)
}
}

View file

@ -1,18 +0,0 @@
package com.tangem.tap.features.welcome.di
import com.tangem.tap.features.welcome.component.DefaultWelcomeComponent
import com.tangem.tap.features.welcome.component.WelcomeComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindWelcomeComponentFactory(factory: DefaultWelcomeComponent.Factory): WelcomeComponent.Factory
}

View file

@ -1,20 +0,0 @@
package com.tangem.tap.features.welcome.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.tap.features.welcome.model.WelcomeModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(WelcomeModel::class)
fun bindWelcomeModel(model: WelcomeModel): Model
}

View file

@ -1,140 +0,0 @@
package com.tangem.tap.features.welcome.model
import com.tangem.common.core.TangemError
import com.tangem.common.routing.entity.InitScreenLaunchMode
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.domain.wallets.legacy.UserWalletsListError
import com.tangem.tap.common.analytics.events.SignIn
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.core.ui.extensions.TextReference
import com.tangem.tap.features.welcome.component.WelcomeComponent
import com.tangem.tap.features.welcome.redux.WelcomeAction
import com.tangem.tap.features.welcome.redux.WelcomeState
import com.tangem.tap.features.welcome.ui.WelcomeScreenState
import com.tangem.tap.features.welcome.ui.model.WarningModel
import com.tangem.tap.store
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.rekotlin.StoreSubscriber
import javax.inject.Inject
// FIXME: Remove redux: [REDACTED_JIRA]
@ModelScoped
internal class WelcomeModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val appFinisher: AppFinisher,
private val analyticsEventsHandler: AnalyticsEventHandler,
paramsContainer: ParamsContainer,
) : Model(), StoreSubscriber<WelcomeState> {
private val params: WelcomeComponent.Params = paramsContainer.require()
private val initialState: WelcomeScreenState = WelcomeScreenState(
onPopBack = appFinisher::finish,
onUnlockClick = this::unlockWallets,
onScanCardClick = this::scanCard,
onCloseError = this::closeError,
)
val state: MutableStateFlow<WelcomeScreenState> = MutableStateFlow(initialState)
init {
subscribeToStoreChanges()
initGlobalState()
val welcomeAction = when (params.launchMode) {
is InitScreenLaunchMode.WithCardScan -> WelcomeAction.ProceedWithCard
is InitScreenLaunchMode.Standard -> WelcomeAction.ProceedWithBiometrics
}
store.dispatch(welcomeAction)
}
private fun unlockWallets() {
analyticsEventsHandler.send(SignIn.ButtonBiometricSignIn())
store.dispatch(WelcomeAction.ProceedWithBiometrics)
}
private fun scanCard() {
analyticsEventsHandler.send(SignIn.ButtonCardSignIn())
store.dispatch(WelcomeAction.ProceedWithCard)
}
private fun closeError() {
store.dispatch(WelcomeAction.CloseError)
}
override fun newState(state: WelcomeState) {
val warning = createWarningIfNeeded(state.error)
this.state.update { prevState ->
prevState.copy(
isUnlockWithBiometricsProgressVisible = state.isUnlockWithBiometricsInProgress,
isUnlockWithCardProgressVisible = state.isUnlockWithCardInProgress,
warning = warning,
error = state.error
?.takeIf { !it.silent && warning == null }
?.let { error ->
val messageResId = error.messageResId
if (messageResId != null) {
TextReference.Res(messageResId)
} else {
TextReference.Str(error.customMessage)
}
},
)
}
}
override fun onDestroy() {
store.unsubscribe(subscriber = this)
super.onDestroy()
}
private fun createWarningIfNeeded(error: TangemError?): WarningModel? {
return when (error) {
is UserWalletsListError.BiometricsAuthenticationLockout -> WarningModel.BiometricsLockoutWarning(
isPermanent = error.isPermanent,
onDismiss = this::dismissWarning,
)
is UserWalletsListError.AllKeysInvalidated,
is UserWalletsListError.NoUserWalletSelected,
-> WarningModel.KeyInvalidatedWarning(
onDismiss = this::dismissWarning,
)
is UserWalletsListError.BiometricsAuthenticationDisabled -> WarningModel.BiometricsDisabledWarning(
onDismiss = this::clearUserWallets,
)
else -> null
}
}
private fun dismissWarning() {
state.update { prevState ->
prevState.copy(
warning = null,
)
}
closeError()
}
private fun clearUserWallets() {
store.dispatch(WelcomeAction.ClearUserWallets)
}
private fun subscribeToStoreChanges() {
store.subscribe(this) { appState ->
appState.skip { old, new -> old.welcomeState == new.welcomeState }
.select { it.welcomeState }
}
}
private fun initGlobalState() {
store.dispatch(GlobalAction.RestoreAppCurrency)
}
}

View file

@ -1,22 +0,0 @@
package com.tangem.tap.features.welcome.redux
import com.tangem.common.core.TangemError
import org.rekotlin.Action
internal sealed interface WelcomeAction : Action {
data object ProceedWithBiometrics : WelcomeAction {
object Success : WelcomeAction
data class Error(val error: TangemError) : WelcomeAction
}
object ProceedWithCard : WelcomeAction {
object Success : WelcomeAction
data class Error(val error: TangemError) : WelcomeAction
data class ChangeProgress(val isProgress: Boolean) : WelcomeAction
}
object CloseError : WelcomeAction
object ClearUserWallets : WelcomeAction
}

View file

@ -1,164 +0,0 @@
package com.tangem.tap.features.welcome.redux
import com.tangem.common.core.TangemSdkError
import com.tangem.common.doOnFailure
import com.tangem.common.doOnResult
import com.tangem.common.doOnSuccess
import com.tangem.common.flatMap
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.utils.popTo
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.card.analytics.ParamCardCurrencyConverter
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType
import com.tangem.domain.wallets.legacy.unlockIfLockable
import com.tangem.tap.*
import com.tangem.tap.common.extensions.*
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.proxy.redux.DaggerGraphState
import kotlinx.coroutines.launch
import org.rekotlin.Middleware
import timber.log.Timber
internal class WelcomeMiddleware {
val middleware: Middleware<AppState> = { _, appStateProvider ->
{ next ->
{ action ->
val appState = appStateProvider()
if (action is WelcomeAction && appState != null) {
handleAction(action)
}
next(action)
}
}
}
private fun handleAction(action: WelcomeAction) {
mainScope.launch {
when (action) {
is WelcomeAction.ProceedWithBiometrics -> proceedWithBiometrics()
is WelcomeAction.ProceedWithCard -> proceedWithCard()
is WelcomeAction.ClearUserWallets -> disableUserWalletsSaving()
else -> Unit
}
}
}
private suspend fun proceedWithBiometrics() {
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
userWalletsListManager.unlockIfLockable(type = UnlockType.ANY)
.doOnFailure { error ->
Timber.e(error, "Unable to unlock user wallets with biometrics")
store.dispatchWithMain(WelcomeAction.ProceedWithBiometrics.Error(error))
}
.doOnSuccess { selectedUserWallet ->
sendSignedInAnalyticsEvent(
userWallet = selectedUserWallet,
signInType = Basic.SignedInLegacy.SignInType.Biometric,
)
store.dispatchNavigationAction { replaceAll(AppRoute.Wallet) }
store.dispatchWithMain(WelcomeAction.ProceedWithBiometrics.Success)
store.onUserWalletSelected(userWallet = selectedUserWallet)
}
}
private suspend fun proceedWithCard() {
scanCardInternal { scanResponse ->
val userWalletBuilder = store.inject(DaggerGraphState::coldUserWalletBuilderFactory).create(scanResponse)
val userWallet = userWalletBuilder.build() ?: return@scanCardInternal
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
userWalletsListManager.save(userWallet, canOverride = true)
.doOnFailure { error ->
Timber.e(error, "Unable to save user wallet")
store.dispatchWithMain(WelcomeAction.ProceedWithCard.Error(error))
}
.doOnSuccess {
sendSignedInAnalyticsEvent(userWallet, signInType = Basic.SignedInLegacy.SignInType.Card)
store.dispatchNavigationAction { replaceAll(AppRoute.Wallet) }
store.dispatchWithMain(WelcomeAction.ProceedWithCard.Success)
store.onUserWalletSelected(userWallet = userWallet)
}
}
}
private fun sendSignedInAnalyticsEvent(userWallet: UserWallet, signInType: Basic.SignedInLegacy.SignInType) {
if (userWallet !is UserWallet.Cold) {
return
}
val scanResponse = userWallet.scanResponse
val currency = ParamCardCurrencyConverter().convert(
value = scanResponse.cardTypesResolver,
)
val trackingContextProxy = store.inject(DaggerGraphState::trackingContextProxy)
trackingContextProxy.addContext(scanResponse)
if (currency != null) {
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
Analytics.send(
event = Basic.SignedInLegacy(
currency = currency,
batch = scanResponse.card.batchId,
signInType = signInType,
walletsCount = userWalletsListManager.walletsCount.toString(),
isImported = userWallet.isImported,
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
}
}
private suspend fun disableUserWalletsSaving() {
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
userWalletsListManager.clear()
.flatMap { tangemSdkManager.clearSavedUserCodes() }
.doOnFailure { e ->
Timber.e(e, "Unable to clear user wallets")
}
.doOnResult {
store.dispatchWithMain(WelcomeAction.CloseError)
store.dispatchNavigationAction { popTo<AppRoute.Home>() }
}
}
private suspend inline fun scanCardInternal(crossinline onCardScanned: suspend (ScanResponse) -> Unit) {
val shouldSaveAccessCodes = store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes()
store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy(
isBiometricsRequestPolicy = shouldSaveAccessCodes,
)
store.inject(DaggerGraphState::scanCardProcessor).scan(
analyticsSource = AnalyticsParam.ScreensSources.SignIn,
onSuccess = { scanResponse ->
scope.launch { onCardScanned(scanResponse) }
},
onFailure = { error ->
when (error) {
is TangemSdkError.ExceptionError -> {
store.dispatchOnMain(WelcomeAction.ProceedWithCard.Success)
}
else -> {
store.dispatchOnMain(WelcomeAction.ProceedWithCard.Error(error))
}
}
},
onProgressStateChange = {
store.dispatchWithMain(WelcomeAction.ProceedWithCard.ChangeProgress(it))
},
onWalletNotCreated = {
store.dispatchOnMain(WelcomeAction.ProceedWithCard.Success)
},
)
}
}

View file

@ -1,36 +0,0 @@
package com.tangem.tap.features.welcome.redux
import com.tangem.tap.common.redux.AppState
import org.rekotlin.Action
internal object WelcomeReducer {
fun reduce(action: Action, state: AppState): WelcomeState {
return if (action is WelcomeAction) {
internalReduce(action, state.welcomeState)
} else {
state.welcomeState
}
}
private fun internalReduce(action: WelcomeAction, state: WelcomeState): WelcomeState {
return when (action) {
is WelcomeAction.ProceedWithBiometrics -> state.copy(isUnlockWithBiometricsInProgress = true)
is WelcomeAction.ProceedWithCard -> state.copy(isUnlockWithCardInProgress = true)
is WelcomeAction.ProceedWithBiometrics.Error -> state.copy(
error = action.error,
isUnlockWithBiometricsInProgress = false,
)
is WelcomeAction.ProceedWithCard.Error -> state.copy(
error = action.error,
isUnlockWithCardInProgress = false,
)
is WelcomeAction.ProceedWithCard.ChangeProgress -> state.copy(
isUnlockWithCardInProgress = action.isProgress,
)
is WelcomeAction.ProceedWithBiometrics.Success -> state.copy(isUnlockWithBiometricsInProgress = false)
is WelcomeAction.ProceedWithCard.Success -> state.copy(isUnlockWithCardInProgress = false)
is WelcomeAction.CloseError -> state.copy(error = null)
else -> state
}
}
}

View file

@ -1,10 +0,0 @@
package com.tangem.tap.features.welcome.redux
import com.tangem.common.core.TangemError
import org.rekotlin.StateType
data class WelcomeState(
val isUnlockWithBiometricsInProgress: Boolean = false,
val isUnlockWithCardInProgress: Boolean = false,
val error: TangemError? = null,
) : StateType

View file

@ -1,15 +0,0 @@
package com.tangem.tap.features.welcome.ui
import com.tangem.core.ui.extensions.TextReference
import com.tangem.tap.features.welcome.ui.model.WarningModel
internal data class WelcomeScreenState(
val onPopBack: () -> Unit = {},
val isUnlockWithBiometricsProgressVisible: Boolean = false,
val isUnlockWithCardProgressVisible: Boolean = false,
val warning: WarningModel? = null,
val error: TextReference? = null,
val onUnlockClick: () -> Unit = {},
val onScanCardClick: () -> Unit = {},
val onCloseError: () -> Unit = {},
)

View file

@ -1,136 +0,0 @@
package com.tangem.tap.features.welcome.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.BasicDialog
import com.tangem.core.ui.components.DialogButtonUM
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.tap.features.welcome.ui.model.WarningModel
import com.tangem.wallet.R
@Composable
internal fun WarningDialog(warning: WarningModel?) {
when (warning) {
null -> Unit
is WarningModel.BiometricsLockoutWarning -> {
BasicDialog(
title = stringResourceSafe(id = R.string.biometric_lockout_warning_title),
message = stringResourceSafe(
id = if (warning.isPermanent) {
R.string.biometric_lockout_permanent_warning_description
} else {
R.string.biometric_lockout_warning_description
},
),
onDismissDialog = warning.onDismiss,
confirmButton = DialogButtonUM(
title = stringResourceSafe(id = R.string.common_ok),
onClick = warning.onDismiss,
),
)
}
is WarningModel.KeyInvalidatedWarning -> {
BasicDialog(
title = stringResourceSafe(id = R.string.common_attention),
message = stringResourceSafe(id = R.string.key_invalidated_warning_description),
onDismissDialog = warning.onDismiss,
confirmButton = DialogButtonUM(
title = stringResourceSafe(id = R.string.common_ok),
onClick = warning.onDismiss,
),
)
}
is WarningModel.BiometricsDisabledWarning -> {
BasicDialog(
title = stringResourceSafe(id = R.string.common_warning),
message = stringResourceSafe(id = R.string.biometric_unavailable_warning),
onDismissDialog = warning.onDismiss,
isDismissable = false,
confirmButton = DialogButtonUM(
title = stringResourceSafe(id = R.string.common_ok),
onClick = warning.onDismiss,
),
)
}
}
}
// region Preview
@Composable
private fun BiometricsLockoutDialogSample(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
WarningDialog(
warning = WarningModel.BiometricsLockoutWarning(
isPermanent = false,
onDismiss = {},
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun BiometricsLockoutDialogPreview() {
TangemThemePreview {
BiometricsLockoutDialogSample()
}
}
@Composable
private fun BiometricsLockoutDialog_Permanent_Sample(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
WarningDialog(
warning = WarningModel.BiometricsLockoutWarning(
isPermanent = true,
onDismiss = {},
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun BiometricsLockoutDialog_Permanent_Preview() {
TangemThemePreview {
BiometricsLockoutDialog_Permanent_Sample()
}
}
@Composable
private fun KeyInvalidatedWarningSample(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
WarningDialog(warning = WarningModel.KeyInvalidatedWarning(onDismiss = {}))
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun KeyInvalidatedWarningPreview() {
TangemThemePreview {
KeyInvalidatedWarningSample()
}
}
@Composable
private fun BiometricDisabledWarningSample(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
WarningDialog(warning = WarningModel.BiometricsDisabledWarning(onDismiss = {}))
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun BiometricDisabledWarningPreview() {
TangemThemePreview {
BiometricDisabledWarningSample()
}
}
// endregion Preview

View file

@ -1,101 +0,0 @@
package com.tangem.tap.features.welcome.ui.components
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.tap.features.welcome.component.WelcomeComponent
import com.tangem.tap.features.welcome.component.impl.PreviewWelcomeComponent
import com.tangem.tap.features.welcome.ui.WelcomeScreenState
import com.tangem.tap.features.welcome.ui.model.WarningModel
@Composable
internal fun WelcomeScreen(state: WelcomeScreenState, modifier: Modifier = Modifier) {
val snackbarHostState = remember { SnackbarHostState() }
val errorMessage by rememberUpdatedState(newValue = state.error?.resolveReference())
val warning by rememberUpdatedState(newValue = state.warning)
BackHandler(onBack = state.onPopBack)
Box(
modifier = modifier
.background(TangemTheme.colors.background.primary)
.systemBarsPadding(),
) {
WelcomeScreenContent(
showUnlockProgress = state.isUnlockWithBiometricsProgressVisible,
showScanCardProgress = state.isUnlockWithCardProgressVisible,
onUnlockClick = state.onUnlockClick,
onScanCardClick = state.onScanCardClick,
)
SnackbarHost(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(vertical = 16.dp)
.fillMaxWidth(),
hostState = snackbarHostState,
)
}
WarningDialog(warning)
LaunchedEffect(errorMessage, state.onCloseError) {
errorMessage?.let { message ->
snackbarHostState.showSnackbar(message)
state.onCloseError()
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_WelcomeScreen(
@PreviewParameter(WelcomeComponentPreviewProvider::class) component: WelcomeComponent,
) {
TangemThemePreview {
component.Content(Modifier)
}
}
private class WelcomeComponentPreviewProvider : PreviewParameterProvider<WelcomeComponent> {
override val values: Sequence<WelcomeComponent>
get() = sequenceOf(
PreviewWelcomeComponent(),
PreviewWelcomeComponent(
initialState = WelcomeScreenState(
isUnlockWithBiometricsProgressVisible = true,
isUnlockWithCardProgressVisible = true,
),
),
PreviewWelcomeComponent(
initialState = WelcomeScreenState(
error = TextReference.Str(value = "Error"),
),
),
PreviewWelcomeComponent(
initialState = WelcomeScreenState(
warning = WarningModel.KeyInvalidatedWarning(onDismiss = {}),
),
),
)
}
// endregion Preview

View file

@ -1,113 +0,0 @@
package com.tangem.tap.features.welcome.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.wallet.R
@Suppress("LongMethod")
@Composable
internal fun WelcomeScreenContent(
showUnlockProgress: Boolean,
showScanCardProgress: Boolean,
onUnlockClick: () -> Unit,
onScanCardClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
SpacerHMax()
Icon(
modifier = Modifier.size(TangemTheme.dimens.size96),
painter = painterResource(id = R.drawable.img_tangem_logo_96),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
SpacerH32()
Text(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResourceSafe(R.string.welcome_unlock_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
SpacerH12()
Text(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing44)
.fillMaxWidth(),
text = stringResourceSafe(
id = R.string.welcome_unlock_description,
stringResourceSafe(id = R.string.common_biometric_authentication),
),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
SpacerHMax()
SecondaryButton(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResourceSafe(
id = R.string.welcome_unlock,
stringResourceSafe(id = R.string.common_biometrics),
),
showProgress = showUnlockProgress,
onClick = onUnlockClick,
)
SpacerH12()
PrimaryButtonIconEnd(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
text = stringResourceSafe(R.string.welcome_unlock_card),
showProgress = showScanCardProgress,
iconResId = R.drawable.ic_tangem_24,
onClick = onScanCardClick,
)
SpacerH16()
}
}
// region Preview
@Composable
private fun WelcomeScreenContentSample(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(TangemTheme.colors.background.primary),
) {
WelcomeScreenContent(
showUnlockProgress = false,
showScanCardProgress = false,
onUnlockClick = { /* no-op */ },
onScanCardClick = { /* no-op */ },
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun WelcomeScreenContentPreview() {
TangemThemePreview {
WelcomeScreenContentSample()
}
}
// endregion Preview

View file

@ -1,16 +0,0 @@
package com.tangem.tap.features.welcome.ui.model
internal sealed interface WarningModel {
data class BiometricsLockoutWarning(
val isPermanent: Boolean,
val onDismiss: () -> Unit,
) : WarningModel
data class KeyInvalidatedWarning(
val onDismiss: () -> Unit,
) : WarningModel
data class BiometricsDisabledWarning(
val onDismiss: () -> Unit,
) : WarningModel
}

View file

@ -6,14 +6,11 @@ import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.utils.Provider
import com.tangem.utils.ProviderSuspend
internal class DefaultAuthProvider(
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val shouldUseNewListRepository: Boolean = false,
private val environmentConfigStorage: EnvironmentConfigStorage,
) : AuthProvider {
@ -71,18 +68,10 @@ internal class DefaultAuthProvider(
}
private suspend fun getWallets(): List<UserWallet> {
return if (shouldUseNewListRepository) {
userWalletsListRepository.userWalletsSync()
} else {
userWalletsListManager.userWalletsSync
}
return userWalletsListRepository.userWalletsSync()
}
private suspend fun getSelectedWallet(): UserWallet? {
return if (shouldUseNewListRepository) {
userWalletsListRepository.selectedUserWalletSync()
} else {
userWalletsListManager.selectedUserWalletSync
}
return userWalletsListRepository.selectedUserWalletSync()
}
}

View file

@ -3,16 +3,10 @@ package com.tangem.tap.network.auth.di
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
import com.tangem.tap.network.auth.DefaultAppVersionProvider
import com.tangem.tap.network.auth.DefaultAuthProvider
import com.tangem.tap.network.auth.DefaultExpressAuthProvider
import com.tangem.tap.network.auth.DefaultP2PEthPoolAuthProvider
import com.tangem.tap.network.auth.DefaultStakeKitAuthProvider
import com.tangem.tap.network.auth.*
import com.tangem.utils.version.AppVersionProvider
import dagger.Module
import dagger.Provides
@ -27,15 +21,11 @@ internal class AuthModule {
@Provides
@Singleton
fun provideAuthProvider(
userWalletsListManager: UserWalletsListManager,
userWalletsListRepository: UserWalletsListRepository,
hotWalletFeatureToggles: HotWalletFeatureToggles,
environmentConfigStorage: EnvironmentConfigStorage,
): AuthProvider {
return DefaultAuthProvider(
userWalletsListManager = userWalletsListManager,
userWalletsListRepository = userWalletsListRepository,
shouldUseNewListRepository = hotWalletFeatureToggles.isHotWalletEnabled,
environmentConfigStorage = environmentConfigStorage,
)
}

View file

@ -163,4 +163,5 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency?
Linea, LineaTestnet -> null
ArbitrumNova -> null
Plasma, PlasmaTestnet -> null
Monad, MonadTestnet -> null
}

View file

@ -31,9 +31,7 @@ import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.operations.attestation.CardArtworksProvider
@ -53,7 +51,6 @@ data class DaggerGraphState(
val appThemeModeRepository: AppThemeModeRepository? = null,
val balanceHidingRepository: BalanceHidingRepository? = null,
val walletsRepository: WalletsRepository? = null,
val generalUserWalletsListManager: UserWalletsListManager? = null,
val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase? = null,
val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase? = null,
val cardRepository: CardRepository? = null,
@ -78,7 +75,6 @@ data class DaggerGraphState(
val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory? = null,
val userTokensResponseStore: UserTokensResponseStore? = null,
val userWalletsListRepository: UserWalletsListRepository? = null,
val hotWalletFeatureToggles: HotWalletFeatureToggles? = null,
val tangemHotSdk: TangemHotSdk? = null,
val trackingContextProxy: TrackingContextProxy? = null,
) : StateType

View file

@ -29,7 +29,6 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy
import com.tangem.features.walletconnect.components.WcRoutingComponent
import com.tangem.hot.sdk.TangemHotSdk
@ -69,7 +68,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val userWalletsListRepository: UserWalletsListRepository,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
@ -260,16 +258,14 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
}
private suspend fun trackSignInEvent() {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
val userWallets = userWalletsListRepository.userWalletsSync()
val selectedWallet = userWalletsListRepository.selectedUserWalletSync() ?: return
trackingContextProxy.addContext(selectedWallet)
analyticsEventHandler.send(
event = Basic.SignedIn(
signInType = Basic.SignedIn.SignInType.NoSecurity,
walletsCount = userWallets.size,
),
)
}
val userWallets = userWalletsListRepository.userWalletsSync()
val selectedWallet = userWalletsListRepository.selectedUserWalletSync() ?: return
trackingContextProxy.addContext(selectedWallet)
analyticsEventHandler.send(
event = Basic.SignedIn(
signInType = Basic.SignedIn.SignInType.NoSecurity,
walletsCount = userWallets.size,
),
)
}
}

View file

@ -52,7 +52,6 @@ import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent
import com.tangem.tap.features.details.ui.cardsettings.coderecovery.api.AccessCodeRecoveryComponent
import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent
import com.tangem.tap.features.details.ui.securitymode.api.SecurityModeComponent
import com.tangem.tap.features.welcome.component.WelcomeComponent
import com.tangem.tap.routing.component.RoutingComponent.Child
import dagger.hilt.android.scopes.ActivityScoped
import javax.inject.Inject
@ -76,7 +75,6 @@ internal class ChildFactory @Inject constructor(
private val sellCryptoComponentFactory: SellCryptoComponent.Factory,
private val swapSelectTokensComponentFactory: SwapSelectTokensComponent.Factory,
private val onboardingEntryComponentFactory: OnboardingEntryComponent.Factory,
private val welcomeComponentFactory: WelcomeComponent.Factory,
private val newWelcomeComponentFactory: NewWelcomeComponent.Factory,
private val storiesComponentFactory: StoriesComponent.Factory,
private val stakingComponentFactory: StakingComponent.Factory,
@ -118,7 +116,6 @@ internal class ChildFactory @Inject constructor(
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
private val kycComponentFactory: KycComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val feedFeatureToggle: FeedFeatureToggle,
) {
@ -163,21 +160,11 @@ internal class ChildFactory @Inject constructor(
)
}
is AppRoute.Welcome -> {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
createComponentChild(
context = context,
params = Unit,
componentFactory = newWelcomeComponentFactory,
)
} else {
createComponentChild(
context = context,
params = WelcomeComponent.Params(
launchMode = route.launchMode,
),
componentFactory = welcomeComponentFactory,
)
}
createComponentChild(
context = context,
params = Unit,
componentFactory = newWelcomeComponentFactory,
)
}
is AppRoute.WalletSettings -> {
createComponentChild(

View file

@ -1,216 +0,0 @@
package com.tangem.tap.domain.userWalletList.implementation
import com.google.common.truth.Truth
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.mockk
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import kotlin.reflect.full.declaredFunctions
import kotlin.reflect.jvm.isAccessible
/**
[REDACTED_AUTHOR]
*/
@RunWith(Parameterized::class)
internal class BiometricUserWalletsListManagerTest(private val model: Model) {
private val manager = BiometricUserWalletsListManager(
keysRepository = mockk(),
publicInformationRepository = mockk(),
sensitiveInformationRepository = mockk(),
selectedUserWalletRepository = mockk(),
dispatcherProvider = mockk(),
)
@Test
fun testFindAvailableUserWallet() {
with(model) {
val actual = userWallets.findAvailableUserWallet(prevSelectedIndex)
Truth.assertThat(actual).isEqualTo(newSelectedWallet)
}
}
private fun List<UserWallet>.findAvailableUserWallet(prevSelectedIndex: Int): UserWallet? {
return manager::class
.declaredFunctions
.firstOrNull { it.name == "findAvailableUserWallet" }
?.apply { isAccessible = true }
?.call(manager, this, prevSelectedIndex)
as? UserWallet
}
data class Model(
val userWallets: List<UserWallet>,
val prevSelectedIndex: Int,
val newSelectedWallet: UserWallet?,
)
private companion object {
val userWallet0 = createUserWallet(id = "0", isLocked = false)
val lockedUserWallet0 = createUserWallet(id = "0", isLocked = true)
val userWallet1 = createUserWallet(id = "1", isLocked = false)
val lockedUserWallet1 = createUserWallet(id = "1", isLocked = true)
val userWallet2 = createUserWallet(id = "2", isLocked = false)
val lockedUserWallet2 = createUserWallet(id = "2", isLocked = true)
val userWallet3 = createUserWallet(id = "3", isLocked = false)
val lockedUserWallet3 = createUserWallet(id = "3", isLocked = true)
val unlockedWallets = listOf(userWallet0, userWallet1, userWallet2, userWallet3)
val lockedWallets = listOf(lockedUserWallet0, lockedUserWallet1, lockedUserWallet2, lockedUserWallet3)
@JvmStatic
@Parameterized.Parameters
fun data(): Collection<Model> {
return listOf(
Model(userWallets = emptyList(), prevSelectedIndex = 0, newSelectedWallet = null),
*getTestsWithUnlockedWallets().toTypedArray(),
*getTestsIfPrevSelectedIndexIs0().toTypedArray(),
*getTestsIfPrevSelectedIndexIsLastIndex().toTypedArray(),
*getTestsIfNewSelectedIndexIsNearby().toTypedArray(),
*getTestsIfNewSelectedIndexIsThroughOne().toTypedArray(),
)
}
fun getTestsWithUnlockedWallets() = listOf(
// [*0*, 1, 2, 3, 4] => delete 0 => [1, 2, 3, 4] => select 1 => [*1*, 2, 3, 4]
Model(userWallets = unlockedWallets, prevSelectedIndex = 0, newSelectedWallet = userWallet0),
// [0, *1*, 2, 3, 4] => delete 1 => [0, 2, 3, 4] => select 2 => [0, *2*, 3, 4]
Model(userWallets = unlockedWallets, prevSelectedIndex = 1, newSelectedWallet = userWallet1),
// [0, 1, *2*, 3, 4] => delete 2 => [0, 1, 3, 4] => select 3 => [0, 1, *3*, 4]
Model(userWallets = unlockedWallets, prevSelectedIndex = 2, newSelectedWallet = userWallet2),
// [0, 1, 2, *3*, 4] => delete 3 => [0, 1, 2, 4] => select 4 => [0, 1, 2, 4]
Model(userWallets = unlockedWallets, prevSelectedIndex = 3, newSelectedWallet = userWallet3),
// [0, 1, 2, 3, *4*] => delete 4 => [0, 1, 2, 3] => select 3 => [0, 1, 2, *3*]
Model(userWallets = unlockedWallets, prevSelectedIndex = 4, newSelectedWallet = userWallet3),
)
fun getTestsIfPrevSelectedIndexIs0() = listOf(
// [*0*, -1-, 2, 3, 4] => delete 0 => [-1-, 2, 3, 4] => select 2 => [-1-, *2*, 3, 4]
Model(
userWallets = listOf(lockedUserWallet0, userWallet1, userWallet2, userWallet3),
prevSelectedIndex = 0,
newSelectedWallet = userWallet1,
),
// [*0*, -1-, -2-, 3, 4] => delete 0 => [-1-, -2-, 3, 4] => select 3 => [-1-, -2-, *3*, 4]
Model(
userWallets = listOf(lockedUserWallet0, lockedUserWallet1, userWallet2, userWallet3),
prevSelectedIndex = 0,
newSelectedWallet = userWallet2,
),
// [*0*, -1-, -2-, -3-, 4] => delete 0 => [-1-, -2-, -3-, 4] => select 4 => [-1-, -2-, -3-, *4*]
Model(
userWallets = listOf(lockedUserWallet0, lockedUserWallet1, lockedUserWallet2, userWallet3),
prevSelectedIndex = 0,
newSelectedWallet = userWallet3,
),
// [*0*, -1-, -2-, -3-, -4-] => delete 0 => [-1-, -2-, -3-, -4-] => select 1 => [*-1-*, -2-, -3-, -4-]
Model(userWallets = lockedWallets, prevSelectedIndex = 0, newSelectedWallet = lockedUserWallet0),
)
fun getTestsIfPrevSelectedIndexIsLastIndex() = listOf(
// [0, 1, 2, -3-, *4*] => delete 4 => [0, 1, 2, -3-] => select 2 => [0, 1, *2*, -3-]
Model(
userWallets = listOf(userWallet0, userWallet1, userWallet2, lockedUserWallet3),
prevSelectedIndex = 4,
newSelectedWallet = userWallet2,
),
// [0, 1, -2-, -3-, *4*] => delete 4 => [0, 1, -2-, -3-] => select 1 => [0, *1*, -2-, -3-]
Model(
userWallets = listOf(userWallet0, userWallet1, lockedUserWallet2, lockedUserWallet3),
prevSelectedIndex = 4,
newSelectedWallet = userWallet1,
),
// [0, -1-, -2-, -3-, *4*] => delete 4 => [0, -1-, -2-, -3-] => select 0 => [*0*, -1-, -2-, -3-]
Model(
userWallets = listOf(userWallet0, lockedUserWallet1, lockedUserWallet2, lockedUserWallet3),
prevSelectedIndex = 4,
newSelectedWallet = userWallet0,
),
// [-0-, -1-, -2-, -3-, *4*] => delete 4 => [-0-, -1-, -2-, -3-] => select 3 => [-0-, -1-, -2-, *-3-*]
Model(userWallets = lockedWallets, prevSelectedIndex = 4, newSelectedWallet = lockedUserWallet3),
)
fun getTestsIfNewSelectedIndexIsNearby() = listOf(
// [0, *1*, 2, 3, 4] => delete 1 => [0, 2, 3, 4] => select 2 => [0, *2*, 3, 4]
Model(userWallets = unlockedWallets, prevSelectedIndex = 1, newSelectedWallet = userWallet1),
// [0, *1*, -2-, 3, 4] => delete 1 => [0, -2-, 3, 4] => select 3 => [0, -2-, *3*, 4]
Model(
userWallets = listOf(userWallet0, lockedUserWallet1, userWallet2, userWallet3),
prevSelectedIndex = 1,
newSelectedWallet = userWallet2,
),
// [0, *1*, -2-, -3-, 4] => delete 1 => [0, -2-, -3-, 4] => select 0 => [*0*, -2-, -3-, 4]
Model(
userWallets = listOf(userWallet0, lockedUserWallet1, lockedUserWallet2, userWallet3),
prevSelectedIndex = 1,
newSelectedWallet = userWallet0,
),
// [0, 1, 2, *3*, 4] => delete 3 => [0, 1, 2, 4] => select 4 => [0, 1, 2, *4*]
Model(userWallets = unlockedWallets, prevSelectedIndex = 3, newSelectedWallet = userWallet3),
// [0, 1, 2, *3*, -4-] => delete 3 => [0, 1, 2, -4-] => select 2 => [0, 1, *2*, -4-]
Model(
userWallets = listOf(userWallet0, userWallet1, userWallet2, lockedUserWallet3),
prevSelectedIndex = 3,
newSelectedWallet = userWallet2,
),
)
fun getTestsIfNewSelectedIndexIsThroughOne() = listOf(
// [-0-, *1*, -2-, 3, 4] => delete 1 => [-0-, -2-, 3, 4] => select 3 => [-0-, -2-, *3*, 4]
Model(
userWallets = listOf(lockedUserWallet0, lockedUserWallet1, userWallet2, userWallet3),
prevSelectedIndex = 1,
newSelectedWallet = userWallet2,
),
// [-0-, *1*, -2-, -3-, 4] => delete 1 => [-0-, -2-, -3-, 4] => select 4 => [-0-, -2-, -3-, *4*]
Model(
userWallets = listOf(lockedUserWallet0, lockedUserWallet1, lockedUserWallet2, userWallet3),
prevSelectedIndex = 1,
newSelectedWallet = userWallet3,
),
// [0, 1, -2-, *3*, -4-] => delete 3 => [0, 1, -2-, -4-] => select 1 => [0, *1*, -2-, -4-]
Model(
userWallets = listOf(userWallet0, userWallet1, lockedUserWallet2, lockedUserWallet3),
prevSelectedIndex = 3,
newSelectedWallet = userWallet1,
),
// [0, -1-, -2-, *3*, -4-] => delete 3 => [*0*, -1-, -2-, -4-] => select 0 => [0, -1-, -2-, -4-]
Model(
userWallets = listOf(userWallet0, lockedUserWallet1, lockedUserWallet2, lockedUserWallet3),
prevSelectedIndex = 3,
newSelectedWallet = userWallet0,
),
)
fun createUserWallet(id: String, isLocked: Boolean): UserWallet {
return UserWallet.Cold(
name = "Wallet $id",
walletId = UserWalletId(stringValue = id),
cardsInWallet = emptySet(),
isMultiCurrency = true,
hasBackupError = false,
scanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(maxWalletCount = 1),
derivedKeys = emptyMap(),
).let {
if (isLocked) {
it.copy(
card = it.card.copy(wallets = emptyList()),
)
} else {
it
}
},
)
}
}
}

View file

@ -13,6 +13,10 @@ android {
dependencies {
implementation(projects.core.utils)
api(projects.domain.models)
api(projects.domain.appCurrency.models)
api(projects.domain.staking.models)
api(projects.libs.crypto)
// region Firebase libraries
implementation(platform(deps.firebase.bom))

View file

@ -0,0 +1,33 @@
package com.tangem.common
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
/**
* Calculates the total fiat amount by adding the main fiat amount and the fiat value of the staked balance.
*/
fun CryptoCurrencyStatus.getTotalFiatAmount(): BigDecimal? {
val fiatAmount = value.fiatAmount
val fiatStakedBalance = value.fiatRate?.times(getStakedBalance().orZero()) ?: return fiatAmount
val totalAmount = fiatAmount?.plus(fiatStakedBalance) ?: return fiatStakedBalance
return totalAmount
}
/**
* Calculates the total cryptocurrency amount by adding the main crypto amount and the staked balance.
*/
fun CryptoCurrencyStatus.getTotalCryptoAmount(): BigDecimal? {
val cryptoAmount = value.amount
val cryptoStakedBalance = getStakedBalance() ?: return cryptoAmount
val totalAmount = cryptoAmount?.plus(cryptoStakedBalance) ?: return cryptoStakedBalance
return totalAmount
}
private fun CryptoCurrencyStatus.getStakedBalance() = (value.stakingBalance as? StakingBalance.Data)
?.getTotalWithRewardsStakingBalance(blockchainId = currency.network.rawId)

View file

@ -1,4 +1,4 @@
package com.tangem.domain.staking.utils
package com.tangem.common
import com.tangem.domain.models.staking.BalanceType
import com.tangem.domain.models.staking.StakingBalance

View file

@ -9,6 +9,7 @@ android {
}
dependencies {
api(projects.common)
/** Compose */
implementation(deps.compose.material3)

View file

@ -7,6 +7,7 @@ import kotlinx.collections.immutable.PersistentList
data class ExpressTransactionsBlockState(
val transactions: PersistentList<ExpressTransactionStateUM>,
val transactionsToDisplay: PersistentList<ExpressTransactionStateUM>,
val bottomSheetSlot: BottomSheetSlot?,
val dialogSlot: DialogSlot?,
)

View file

@ -94,6 +94,7 @@ private fun SwapStoriesText(current: SwapStoriesUM.Content.Config) {
),
color = TangemTheme.colors.text.constantWhite,
textAlign = TextAlign.Center,
modifier = Modifier.testTag(SwapStoriesScreenTestTags.TITLE),
)
Text(
text = current.subtitle.resolveReference(),
@ -105,6 +106,7 @@ private fun SwapStoriesText(current: SwapStoriesUM.Content.Config) {
),
color = SubtitleColor,
textAlign = TextAlign.Center,
modifier = Modifier.testTag(SwapStoriesScreenTestTags.SUBTITLE),
)
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.common.ui.tokens
import com.tangem.common.getTotalCryptoAmount
import com.tangem.common.getTotalFiatAmount
import com.tangem.common.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
@ -25,11 +27,8 @@ import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
@ -156,29 +155,6 @@ class TokenItemStateConverter(
}
companion object {
fun CryptoCurrencyStatus.getFormattedFiatAmount(appCurrency: AppCurrency): String {
val fiatAmount = value.fiatAmount ?: return DASH_SIGN
val fiatYieldBalance = value.fiatRate?.times(getStakedBalance()).orZero()
val totalAmount = fiatAmount.plus(fiatYieldBalance)
return totalAmount.format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
}
fun CryptoCurrencyStatus.getFormattedCryptoAmount(): String {
val cryptoAmount = value.amount ?: return DASH_SIGN
val totalAmount = cryptoAmount.plus(getStakedBalance())
return totalAmount.format { crypto(currency) }
}
private fun CryptoCurrencyStatus.getStakedBalance() = (value.stakingBalance as? StakingBalance.Data)
?.getTotalWithRewardsStakingBalance(blockchainId = currency.network.rawId).orZero()
private fun createTitleState(
currencyStatus: CryptoCurrencyStatus,
yieldModuleApyMap: Map<String, BigDecimal>,
@ -348,7 +324,9 @@ class TokenItemStateConverter(
is CryptoCurrencyStatus.NoAccount,
-> {
TokenItemState.Subtitle2State.TextContent(
text = status.getFormattedCryptoAmount(),
text = status.getTotalCryptoAmount().format {
crypto(status.currency)
},
isFlickering = status.value.isFlickering(),
)
}
@ -371,7 +349,12 @@ class TokenItemStateConverter(
is CryptoCurrencyStatus.NoAccount,
-> {
TokenItemState.FiatAmountState.Content(
text = status.getFormattedFiatAmount(appCurrency = appCurrency),
text = status.getTotalFiatAmount().format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
isFlickering = status.value.isFlickering(),
icons = buildList {
if (status.value.yieldSupplyStatus?.isActive == true &&

View file

@ -31,18 +31,10 @@
"name": "SWAP_REDESIGN_ENABLED",
"version": "undefined"
},
{
"name": "HOT_WALLET_ENABLED",
"version": "5.32.0"
},
{
"name": "HOT_WALLET_CREATION_RESTRICTION_ENABLED",
"version": "5.32.0"
},
{
"name": "HOT_WALLET_VISIBLE",
"version": "5.32.1"
},
{
"name": "TANGEM_PAY_ENABLED",
"version": "5.31.0"
@ -86,5 +78,9 @@
{
"name": "HOLD_TO_CONFIRM_BUTTON_ENABLED",
"version": "undefined"
},
{
"name": "WALLET_REORDER_FEATURE_ENABLED",
"version": "undefined"
}
]

View file

@ -205,4 +205,18 @@ interface TangemTechApi {
*/
@POST("v1/transaction-events")
suspend fun transactionEvents(@Body name: TransactionEventBody): ApiResponse<Unit>
// region Earn
@GET("v1/earn/markets")
suspend fun getEarnTokens(
@Query("isForEarn") isForEarn: Boolean?,
@Query("page") page: String? = null,
@Query("limit") limit: Int? = null,
@Query("type") type: String? = null,
@Query("network") network: String? = null,
): ApiResponse<EarnListResponse>
@GET("v1/earn/networks")
suspend fun getEarnNetworks(@Query("type") type: String): ApiResponse<EarnNetworkListResponse>
// endregion
}

View file

@ -0,0 +1,14 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class EarnNetworkListResponse(
@Json(name = "items") val items: List<EarnNetworkResponse>,
)
@JsonClass(generateAdapter = true)
data class EarnNetworkResponse(
@Json(name = "networkId") val networkId: String,
)

View file

@ -0,0 +1,34 @@
package com.tangem.datasource.api.tangemTech.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class EarnListResponse(
@Json(name = "meta") val meta: MetaEarnListResponse,
@Json(name = "items") val items: List<EarnResponse>,
)
@JsonClass(generateAdapter = true)
data class EarnResponse(
@Json(name = "apy") val apy: String,
@Json(name = "networkId") val networkId: String,
@Json(name = "rewardType") val rewardType: String,
@Json(name = "type") val type: String,
@Json(name = "token") val token: EarnTokenResponse,
)
@JsonClass(generateAdapter = true)
data class EarnTokenResponse(
@Json(name = "id") val id: String,
@Json(name = "symbol") val symbol: String,
@Json(name = "name") val name: String,
@Json(name = "address") val address: String,
@Json(name = "decimalCount") val decimalCount: Int? = null,
)
@JsonClass(generateAdapter = true)
data class MetaEarnListResponse(
@Json(name = "page") val page: Int,
@Json(name = "limit") val limit: Int,
)

View file

@ -30,6 +30,10 @@ internal object BlockchainSDKConfigConverter : Converter<EnvironmentConfigModel,
apiKey = value.quiknodePlasmaApiKey,
subdomain = value.quiknodePlasmaSubdomain,
),
quickNodeMonadCredentials = QuickNodeCredentials(
apiKey = value.quiknodeMonadApiKey,
subdomain = value.quiknodeMonadSubdomain,
),
infuraProjectId = value.infuraProjectId,
tronGridApiKey = value.tronGridApiKey,
nowNodeCredentials = NowNodeCredentials(value.nowNodesApiKey),
@ -110,6 +114,8 @@ internal object BlockchainSDKConfigConverter : Converter<EnvironmentConfigModel,
sui = GetBlockAccessToken(jsonRpc = accessTokens.sui?.jsonRPC),
telos = GetBlockAccessToken(jsonRpc = accessTokens.telos?.jsonRPC),
tezos = GetBlockAccessToken(rest = accessTokens.tezos?.rest),
monad = GetBlockAccessToken(rest = accessTokens.monad?.rest),
stellar = GetBlockAccessToken(rest = accessTokens.stellar?.rest),
)
}
}

View file

@ -18,6 +18,8 @@ class EnvironmentConfigModel(
@Json(name = "bscQuiknodeApiKey") val bscQuiknodeApiKey: String,
@Json(name = "quiknodePlasmaSubdomain") val quiknodePlasmaSubdomain: String,
@Json(name = "quiknodePlasmaApiKey") val quiknodePlasmaApiKey: String,
@Json(name = "quiknodeMonadSubdomain") val quiknodeMonadSubdomain: String,
@Json(name = "quiknodeMonadApiKey") val quiknodeMonadApiKey: String,
@Json(name = "nowNodesApiKey") val nowNodesApiKey: String,
@Json(name = "getBlockAccessTokens") val getBlockAccessTokens: GetBlockAccessTokens?,
@Json(name = "tonCenterApiKey") val tonCenterKeys: TonCenterKeys,
@ -97,6 +99,8 @@ data class GetBlockAccessTokens(
@Json(name = "sui") val sui: GetBlockToken?,
@Json(name = "telos") val telos: GetBlockToken?,
@Json(name = "tezos") val tezos: GetBlockToken?,
@Json(name = "monad") val monad: GetBlockToken?,
@Json(name = "stellar") val stellar: GetBlockToken?,
)
@JsonClass(generateAdapter = true)

View file

@ -0,0 +1,5 @@
package com.tangem.core.navigation.review
class DummyReviewManager : ReviewManager {
override fun request(onDismissClick: () -> Unit) = Unit
}

View file

@ -0,0 +1,12 @@
package com.tangem.core.navigation.review
/**
* Interface to manage in-app review requests.
*/
interface ReviewManager {
/**
* Requests an in-app review flow.
*/
fun request(onDismissClick: () -> Unit)
}

View file

@ -86,7 +86,7 @@
<string name="address_qr_code_message_format">このアドレスには%3$s ネットワークから%1$s (%2$s) のみを送信してください。他のトークンやネットワークを使用すると、資金を失う可能性があります。</string>
<string name="address_type_default">デフォルト</string>
<string name="address_type_legacy">レガシー</string>
<string name="alert_authentication_error_message">デバイスの生体認証をリセットするか、サポートにお問い合わせください</string>
<string name="alert_authentication_error_message">生体認証で問題が発生しました。端末の生体認証をリセットするか、サポートにお問い合わせください</string>
<string name="alert_authentication_error_title">認証エラー</string>
<string name="alert_button_how_to_scan">スキャン方法</string>
<string name="alert_button_request_support">サポートをリクエストする</string>
@ -461,9 +461,15 @@
<string name="domain_receive_assets_onboarding_network_name">%sネットワーク</string>
<string name="domain_receive_assets_onboarding_title">下記のみを使用して資金を送金する</string>
<string name="earn_best_opportunities">おすすめ</string>
<string name="earn_clear_filter">絞り込みを解除</string>
<string name="earn_empty">リストは現在更新中のため、一時的に空になっています。しばらくしてからご確認ください。</string>
<string name="earn_filter_all_networks">すべてのネットワーク</string>
<string name="earn_filter_all_types">すべての種類</string>
<string name="earn_filter_by">絞り込み</string>
<string name="earn_filter_my_networks">マイネットワーク</string>
<string name="earn_filter_networks">ネットワーク</string>
<string name="earn_mostly_used">よく使われています</string>
<string name="earn_no_results">該当する結果はありません</string>
<string name="earn_title">運用</string>
<string name="email_preface_wc_error">こんにちは、サポートチームの皆さん、コード %s のエラーが発生しました。</string>
<string name="email_subject_wc_error">WalletConnectエラー</string>
@ -814,6 +820,7 @@
<string name="markets_token_details_volume">取引量</string>
<string name="markets_tooltip_message">これをドラッグするか、検索窓をタップして、マーケットから直接トークンを追加します</string>
<string name="markets_tooltip_title">トークンを追加</string>
<string name="markets_tooltip_v2_title">トークンを追加</string>
<string name="markets_yield_supply_banner_description">資産を常時アクセス可能な状態に保ったまま、パワーアップさせよう。%s</string>
<string name="markets_yield_supply_banner_title">利息モードを有効にする</string>
<string name="mobile_wallet_requires_min_os_warning_body">モバイルウォレットを作成するには、%1$sにアップデートする必要があります</string>
@ -827,7 +834,7 @@
<item quantity="other">%d分前</item>
</plurals>
<string name="news_quick_recap">クイックまとめ</string>
<string name="news_related_news">関連ニュース</string>
<string name="news_related_news">ニュース</string>
<string name="news_related_tokens">関連トークン</string>
<string name="news_sources">関連ニュース</string>
<string name="news_stay_in_the_loop">最新情報を入手</string>

View file

@ -311,6 +311,7 @@
<string name="common_go_to_token">Перейти до токену</string>
<string name="common_got_it">Зrozуміло</string>
<string name="common_hide">Приховати</string>
<string name="common_hold_to">Утримуйте, щоб %s</string>
<string name="common_hour">година</string>
<string name="common_import">Імпортувати</string>
<string name="common_in_progress">В процесі</string>
@ -337,10 +338,12 @@
<string name="common_no">Ні</string>
<string name="common_no_address">Немає адреси</string>
<string name="common_not_added">Не додано</string>
<string name="common_not_available">Недоступно</string>
<string name="common_not_now">Не зараз</string>
<string name="common_now">Зараз</string>
<string name="common_ok">ОК</string>
<string name="common_open_in_browser">Відкрити в браузері</string>
<string name="common_or">або</string>
<string name="common_origin_card">Основна картка</string>
<string name="common_origin_ring">Основне кільце</string>
<string name="common_passphrase">Парольна фраза</string>
@ -386,6 +389,7 @@
<string name="common_swap">Обмін</string>
<string name="common_tangem">Tangem</string>
<string name="common_tangem_wallet">Tangem Wallet</string>
<string name="common_tap_and_hold_hint">Натисніть і утримуйте</string>
<string name="common_terms_and_conditions">умови участі</string>
<string name="common_terms_of_use">Умовами використання</string>
<string name="common_to">До</string>
@ -453,6 +457,7 @@
<string name="details_manage_security_long_tap_description">Цей механізм захищає картку або кільце від безконтактних атак. Між скануванням картки та виконанням команди буде додана затримка.</string>
<string name="details_manage_security_passcode">Пароль</string>
<string name="details_manage_security_passcode_description">Перед виконанням будь-якої команди, що тягне за собою зміну стану картки, вам необхідно буде ввести пароль.</string>
<string name="details_mobile_wallet_upgrade_action_title">Оновлення до апаратного гаманця</string>
<string name="details_nft_title">NFT</string>
<string name="details_referral_title">Реферальна програма</string>
<string name="details_row_description_flip_to_hide">Переверніть екран пристрою вниз, щоб швидко приховати та відобразити баланси</string>
@ -466,6 +471,7 @@
<string name="details_row_title_signed_hashes">Підписано</string>
<string name="details_send_feedback">Надіслати відгук</string>
<string name="details_title">Деталі</string>
<string name="details_wallets_section_description">Ви можете мати лише один мобільний гаманець. Оновіть його до апаратного гаманця Tangem або додайте новий апаратний гаманець.</string>
<string name="disclaimer_error_loading">Перевірте підключення до інтернету або змініть мережу</string>
<string name="disclaimer_title">Умови використання</string>
<string name="domain_receive_assets_default_address">Основна адреса</string>
@ -475,6 +481,15 @@
<string name="domain_receive_assets_onboarding_description">Надсилання активів в інші мережі призведе до безповоротної втрати.</string>
<string name="domain_receive_assets_onboarding_network_name">%s мережа</string>
<string name="domain_receive_assets_onboarding_title">Надсилайте кошти, використовуючи лише</string>
<string name="earn_best_opportunities">Найкращі можливості</string>
<string name="earn_clear_filter">Очистити фільтр</string>
<string name="earn_filter_all_networks">Всі мережі</string>
<string name="earn_filter_all_types">Всі типи</string>
<string name="earn_filter_by">Фільтрувати за</string>
<string name="earn_filter_my_networks">Мої мережі</string>
<string name="earn_filter_networks">Мережі</string>
<string name="earn_mostly_used">Часто використовані</string>
<string name="earn_no_results">Немає результатів</string>
<string name="email_preface_wc_error">Привіт, команда підтримки, я зіткнувся з помилкою з кодом: %s</string>
<string name="email_subject_wc_error">Помилка WalletConnect</string>
<string name="error_wrong_wallet_tapped">Ви використали картку або кільце від іншого гаманця. Прикладіть картку або кільце, пов\'язану з цим гаманцем.</string>
@ -555,6 +570,10 @@
<string name="express_transaction_id">ID: %s</string>
<string name="express_transaction_id_copied">ID транзакції скопійовано</string>
<string name="exсhange_token_description">Обміняйте будь-який актив у своєму портфелі на цей токен</string>
<string name="fee_selector_choose_speed_description">Більш вища швидкість означає більш швидше підтвердження, але і більш вищу мережеву комісію. %s</string>
<string name="fee_selector_choose_speed_title">Обрати швидкість</string>
<string name="fee_selector_choose_token_description">Оберіть, який токен буде використовуватися для оплати мережевої комісії. %s</string>
<string name="fee_selector_choose_token_title">Обрати токен</string>
<string name="feed_market_and_news">Ринок та Новини</string>
<string name="feed_trending_now">В тренді</string>
<string name="feedback_data_collection_message">Інформація нижче не є обов\'язковою. Ви можете стерти її, якщо бажаєте.</string>
@ -570,6 +589,8 @@
<string name="feedback_subject_support_tangem">Звернення в підтримку Tangem</string>
<string name="feedback_subject_tx_failed">Не вдається відправити транзакцію</string>
<string name="feedback_token_description_error">Помилка в описі монети</string>
<string name="gasless_not_enough_funds_to_cover_token_fee">Недостатньо коштів</string>
<string name="gasless_transaction_fee">Комісія за транзакцію</string>
<string name="generic_error">Виникла помилка</string>
<string name="generic_error_code">Виникла помилка. Код: %s.</string>
<string name="generic_requires_memo_error">Вимагається memo</string>
@ -619,15 +640,20 @@
<string name="hw_backup_need_title">Спочатку завершіть резервне копіювання</string>
<string name="hw_backup_no_backup">Не завершено</string>
<string name="hw_backup_section_other_title">Інші методи</string>
<string name="hw_backup_seed_code_description">Збережіть фразу відновлення в безпечному місці та тримайте її в таємниці, щоб захистити свої кошти, а також налаштуйте код доступу для додаткової безпеки.</string>
<string name="hw_backup_seed_description">Збережіть фразу відновлення у безпечному місці і тримайте її у таємниці.</string>
<string name="hw_backup_seed_title">Фраза відновлення</string>
<string name="hw_backup_to_secure_description">Щоб захистити свій гаманець за допомогою коду доступу, завершіть процес резервного копіювання.</string>
<string name="hw_backup_to_upgrade_description">Щоб покращити гаманець до апаратного, спочатку створіть резервну копію.</string>
<string name="hw_backup_upgrade_description">Оновіть свій мобільний гаманець до апаратного гаманця Tangem для найвищого рівня безпеки. Імпортуйте існуючий гаманець або створіть новий.</string>
<string name="hw_backup_upgrade_title">Оновлення до холодного гаманця</string>
<string name="hw_create_keys_description">Ваші приватні ключі надійно зашифровані та зберігаються на вашому телефоні</string>
<string name="hw_create_keys_title">Ключі зберігаються у застосунку</string>
<string name="hw_create_seed_description">Створіть або відновіть свій гаманець за допомогою вашої фрази відновлення.</string>
<string name="hw_create_seed_title">Резервна копія</string>
<string name="hw_create_title">Мобільний гаманець</string>
<string name="hw_create_upgrade_description">Перемістіть свій мобільний гаманець на Tangem картку або кільце у будь який час.</string>
<string name="hw_create_upgrade_title">Оновлення до апаратного гаманця</string>
<string name="hw_import_existing_wallet">Імпортувати існуючий гаманець</string>
<string name="hw_import_seed_phrase_already_imported">Ця фраза відновлення вже була імпортована</string>
<string name="hw_mobile_wallet">Мобільний гаманець</string>
@ -821,6 +847,7 @@
<string name="markets_token_details_volume">Обсяг</string>
<string name="markets_tooltip_message">Потягніть вгору або торкніться панелі пошуку, щоб додати токени безпосередньо з маркету</string>
<string name="markets_tooltip_title">Додати токени</string>
<string name="markets_tooltip_v2_title">Додати токени</string>
<string name="markets_yield_supply_banner_description">Збільшуйте дохід з активів, зберігаючи миттєвий доступ до них. %s</string>
<string name="markets_yield_supply_banner_title">Активувати режим дохідності</string>
<string name="mobile_wallet_requires_min_os_warning_body">Оновіться до версії %1$s, щоб створити мобільний гаманець</string>
@ -1017,6 +1044,7 @@
<string name="onboarding_wallet_info_title_fourth">Відновлення коду доступу</string>
<string name="onboarding_wallet_info_title_second">Ідентичні картки</string>
<string name="onboarding_wallet_info_title_third">Код доступу</string>
<string name="only_one_mobile_wallet_explanation">Ви можете мати лише один мобільний гаманець. Оновіть його до апаратного гаманця Tangem або додайте новий апаратний гаманець.</string>
<string name="onramp_all_offers_button_title">Усі пропозиції</string>
<string name="onramp_avaiable_with_payment_methods">Доступно з %s</string>
<string name="onramp_choose_provider_title_hint">Tangem забезпечує доступ к покупці через сторонніх провайдерів згідно з їхніми умовами</string>
@ -1131,6 +1159,7 @@
<string name="reset_card_to_factory_button_title">Скинути картку</string>
<string name="reset_card_to_factory_condition_1">Я розумію, що після виконання цієї дії у мене більше не буде доступу до поточного гаманця</string>
<string name="reset_card_to_factory_condition_2">Я розумію, що не можу використати цю картку для відновлення свого коду доступу на інших картках поточного гаманця</string>
<string name="reset_card_to_factory_condition_3">Я розумію, що повністю втрачу доступ до своєї картки Tangem Pay та всіх коштів на ній без можливості відновлення.</string>
<string name="reset_card_with_backup_to_factory_message">Скидання до заводських налаштувань призведе до повного видалення гаманця з обраної картки або кільця. Ви не зможете відновити поточний гаманець або використати цю картку або кільце для відновлення коду доступу.</string>
<string name="reset_card_without_backup_to_factory_message">Скидання до заводських налаштувань призведе до повного видалення гаманця з обраної картки або кільця. Ви не зможете відновити поточний гаманець.</string>
<string name="reset_cards_dialog_complete_description">Усі пристрої Tangem було скинуто.</string>
@ -1279,6 +1308,11 @@
<string name="settings_card_settings_footer">Підготуйтеся до сканування кільця або картки, яку ви хочете налаштувати.</string>
<string name="settings_forget_wallet">Забути гаманець</string>
<string name="settings_forget_wallet_footer">Це призведе до видалення гаманця з застосунку. Сам гаманець можна додати знову.</string>
<string name="settings_upgrade_banner_advantage_feature_title">Простий у використанні</string>
<string name="settings_upgrade_banner_description">Зберігає ваші криптовалюти в безпеці та офлайн. Тонкий, як кредитна картка, безпечніший за банківське сховище.</string>
<string name="settings_upgrade_banner_seed_phrase_feature_title">Без seed-фрази</string>
<string name="settings_upgrade_banner_superiority_feature_title">Найкращий у своєму класі</string>
<string name="settings_upgrade_banner_title">Холодний гаманець Tangem</string>
<string name="settings_wallet_name_title">Ім\'я</string>
<string name="stake_token_description">Змусьте свій токен працювати</string>
<string name="staking_account_initialization_footer">Мережева комісія — це невелика оплата, необхідна для обробки та підтвердження вашої транзакції в блокчейні.</string>
@ -1444,6 +1478,7 @@
<string name="swap_story_third_subtitle">Просто та зручно — обмін токенів в декілька дотиків</string>
<string name="swap_story_third_title">Легше, ніж будь-коли</string>
<string name="swap_via_provider">Обмін через провайдера</string>
<string name="swap_your_assets_title">Ваші активи</string>
<string name="swapping_alert_cex_description">Сума включає: \n• комісію постачальника послуг\n• комісію мережі за відправлення %s з біржі назад на адресу користувача.</string>
<string name="swapping_alert_cex_description_with_slippage">У суму входить:\n- комісія провайдера\n- мережева комісія за відправку %1$s з біржі назад на адресу користувача. \n\nПроскакування провайдера становить до %2$s</string>
<string name="swapping_alert_dex_description">Сума включає комісію постачальника послуг.</string>
@ -1458,6 +1493,7 @@
<string name="swapping_insufficient_funds">Недостатньо коштів</string>
<string name="swapping_permission_header">Надати дозвіл</string>
<string name="swapping_swap_action">Обміняти</string>
<string name="swapping_swap_action_in_progress">Обмін...</string>
<string name="swapping_to_title">Ви отримаєте</string>
<string name="swapping_token_list_title">Оберіть токен</string>
<string name="swapping_token_not_available">недоступно</string>
@ -1476,7 +1512,9 @@
<string name="tangem_pay_freeze_card_freeze">Заморозити</string>
<string name="tangem_pay_freeze_card_success">Вашу картку заморожено.</string>
<string name="tangem_pay_get_help">Звернутися до підтримки</string>
<string name="tangem_pay_history_item_spend_mc_declined_reason">Причина: %s</string>
<string name="tangem_pay_other">Інше</string>
<string name="tangem_pay_rooted_device_subtitle">Неможливо використовувати на пристроях з root-правами.</string>
<string name="tangem_pay_status_completed">Завершено</string>
<string name="tangem_pay_status_declined">Відхилено</string>
<string name="tangem_pay_status_pending">В очікуванні</string>
@ -1652,6 +1690,7 @@
<string name="universal_error">Виникла помилка. Код помилки: %s. Спробуйте, будь ласка, знову. Якщо проблема знову виникає — зверніться до нашої служби підтримки.</string>
<string name="unlock_wallet_description_full">Використовуйте %s або відскануйте картку/кільце, щоб отримати доступ до свого гаманця</string>
<string name="unsupported_wc_version">Не вдалося встановити з\'єднання: Цей dApp використовує Wallet Connect версії 1.0, яка не підтримується. Будь ласка, переконайтеся, що dApp підтримує Wallet Connect версії 2.0 для успішного підключення.</string>
<string name="upgrade_to_hardware_wallet_button_title">Оновлення до апаратного гаманця</string>
<string name="user_push_notification_agreement_argument_one">Будьте в курсі останніх функцій та новин</string>
<string name="user_push_notification_agreement_argument_one_subtitle">Миттєві сповіщення про транзакції, обміни та важливі оновлення.</string>
<string name="user_push_notification_agreement_argument_one_title">Сповіщення про транзакції</string>
@ -1799,7 +1838,18 @@
<string name="warning_button_ok">Зрозуміло!</string>
<string name="warning_button_really_cool">Дуже круто!</string>
<string name="warning_button_refresh">Оновити</string>
<string name="warning_clore_migration_button">Почати міграцію</string>
<string name="warning_clore_migration_copy_button">Копіювати</string>
<string name="warning_clore_migration_description">Щоб зберегти доступ до своїх коштів, розпочніть міграцію згідно з офіційними рекомендаціями Clore.</string>
<string name="warning_clore_migration_error_signing_not_supported">Підписання повідомлень не підтримується в цій мережі</string>
<string name="warning_clore_migration_error_wallet_manager_not_found">Неможливо підписати повідомлення. Будь ласка, спробуйте пізніше.</string>
<string name="warning_clore_migration_message">Згідно з офіційною документацією Clore, усі монети, отримані до 21 грудня, будуть мігровані в токен Clore (ERC-20); монети, отримані після цієї дати, — ні. Рішення для переказу перебуває в розробці — стежте за оновленнями.</string>
<string name="warning_clore_migration_message_label">Повідомлення</string>
<string name="warning_clore_migration_open_portal_button">Відкрити портал клейму</string>
<string name="warning_clore_migration_sheet_description">Щоб зберегти доступ до своїх активів, почніть міграцію у відповідності до офіційної інструкції Clore.</string>
<string name="warning_clore_migration_sheet_title">Міграція мережі Clore</string>
<string name="warning_clore_migration_sign_button">Підписати</string>
<string name="warning_clore_migration_signature_label">Підпис</string>
<string name="warning_clore_migration_title">Міграція мережі Clore</string>
<string name="warning_demo_mode_message">Ви перебуваєте в демонстраційному режимі</string>
<string name="warning_demo_mode_title">Демонстраційний режим активовано</string>
@ -1981,6 +2031,7 @@
<string name="welcome_create_wallet_feature_delivery">Швидка доставка</string>
<string name="welcome_create_wallet_feature_one_tap">Почніть в один клік</string>
<string name="welcome_create_wallet_feature_seamless">Просто та надійно</string>
<string name="welcome_create_wallet_feature_seedphrase">Без seed-фрази</string>
<string name="welcome_create_wallet_feature_use">Простий у використанні</string>
<string name="welcome_create_wallet_hardware_description">Створіть апаратний гаманець з Tangem. Тонкий, як банківська картка, надійний, як банківське сховище.</string>
<string name="welcome_create_wallet_mobile_description">Створіть або імпортуйте програмний гаманець</string>

View file

@ -1436,6 +1436,7 @@
<string name="swap_give_permission_fee_footer">The network will charge a token approval fee to verify that you are authorizing the use of your token for the swap.</string>
<string name="swap_promo_text">Exchange more tokens at better rates directly in your wallet.</string>
<string name="swap_promo_title">New Swap Provider Available!</string>
<string name="swap_search_suggestion_hint">Looking for something else?/nTry searching or explore another crypto!</string>
<string name="swap_story_fifth_subtitle">Feel confident with round-the-clock support to help with any issues</string>
<string name="swap_story_fifth_title">Always Here</string>
<string name="swap_story_first_subtitle">Multiple trusted providers in one place—swap any asset effortlessly in your wallet</string>

View file

@ -0,0 +1,10 @@
package com.tangem.core.ui.components.buttons.actions
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
val IsDimmedKey = SemanticsPropertyKey<Boolean>("IsDimmed")
val HasBadgeKey = SemanticsPropertyKey<Boolean>("HasBadge")
var SemanticsPropertyReceiver.isDimmed by IsDimmedKey
var SemanticsPropertyReceiver.hasBadge by HasBadgeKey

View file

@ -22,7 +22,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -136,11 +135,8 @@ fun ActionBaseButton(
}
.clip(shape)
.semantics {
contentDescription = if (config.shouldDimContent) {
"Action button is dimmed"
} else {
"Action button is not dimmed"
}
isDimmed = config.shouldDimContent
hasBadge = config.shouldShowBadge
}
.combinedClickable(
enabled = config.isEnabled,

Some files were not shown because too many files have changed in this diff Show more