Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-06 12:40:48 +03:00
commit 870c3fa9d3
291 changed files with 4985 additions and 5870 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(
@ -23,3 +27,19 @@ fun assertElementDoesNotExist(
}
}
}
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

@ -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,58 +1,45 @@
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
}
fun tokenWithTitle(title: String): KNode {
return lazyList.child<KNode> {
hasText(title)
val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title))
useUnmergedTree = true
}
fun tokenWithTitle(title: String): KNode {
return child {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)
hasText(title)
}
}
}

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

@ -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()
}
store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) }
settingsRepository.setShouldOpenWelcomeScreenOnResume(value = false)
}
@ -115,11 +107,8 @@ 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()
@ -131,22 +120,5 @@ internal class LockUserWalletsTimer(
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()) }
}
}
}
}

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,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,19 +16,14 @@ 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.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
@ -47,77 +42,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(
@ -183,7 +107,7 @@ internal object UserWalletsListManagerModule {
)
}
fun buildMoshi(): Moshi {
private fun buildMoshi(): Moshi {
return Moshi.Builder()
.add(WalletDerivedKeysMapAdapter())
.add(ScanResponseDerivedKeysMapAdapter())
@ -200,7 +124,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.*
@ -31,6 +32,8 @@ 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.publicInformation
import com.tangem.tap.domain.userWalletList.utils.sensitiveInformation
import com.tangem.tap.domain.userWalletList.utils.toUserWallets
import com.tangem.tap.domain.userWalletList.utils.updateWith
import com.tangem.utils.Provider
@ -38,10 +41,12 @@ 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.MutableStateFlow
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(
@ -384,6 +389,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,

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,7 +114,6 @@ internal class AppSettingsModel @Inject constructor(
),
)
if (hotWalletFeatureToggles.isHotWalletEnabled) {
val canUseBiometrics =
!state.needEnrollBiometrics && !state.isInProgress && state.hasSecuredWallets
@ -140,27 +133,6 @@ internal class AppSettingsModel @Inject constructor(
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.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()) }
}
}
}
}

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,
@ -254,7 +252,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
}
private suspend fun trackSignInEvent() {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
val userWallets = userWalletsListRepository.userWalletsSync()
val selectedWallet = userWalletsListRepository.selectedUserWalletSync() ?: return
trackingContextProxy.addContext(selectedWallet)
@ -266,4 +263,3 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
)
}
}
}

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,
)
}
}
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

@ -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"
@ -82,5 +74,9 @@
{
"name": "EARN_BLOCK_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),
@ -109,6 +113,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,
@ -96,6 +98,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

@ -468,9 +468,12 @@
<string name="domain_receive_assets_onboarding_network_name">%s Netzwerk</string>
<string name="domain_receive_assets_onboarding_title">Sende Geld nur mit</string>
<string name="earn_best_opportunities">Beste Gelegenheiten</string>
<string name="earn_clear_filter">Filter löschen</string>
<string name="earn_filter_all_networks">Alle Netzwerke</string>
<string name="earn_filter_all_types">Alle Arten</string>
<string name="earn_filter_by">Filtern nach</string>
<string name="earn_mostly_used">Meist verwendet</string>
<string name="earn_no_results">Keine Ergebnisse</string>
<string name="earn_title">Verdienen</string>
<string name="email_preface_wc_error">Hallo Support-Team, ich habe einen Fehler mit dem Code %s festgestellt.</string>
<string name="email_subject_wc_error">WalletConnect-Fehler</string>

View file

@ -468,9 +468,14 @@
<string name="domain_receive_assets_onboarding_network_name">%s réseau</string>
<string name="domain_receive_assets_onboarding_title">Envoyez des fonds en utilisant uniquement</string>
<string name="earn_best_opportunities">Meilleures opportunités</string>
<string name="earn_clear_filter">Effacer le filtre</string>
<string name="earn_filter_all_networks">Tous les réseaux</string>
<string name="earn_filter_all_types">Tous les types</string>
<string name="earn_filter_by">Filtrer par</string>
<string name="earn_filter_my_networks">Mes réseaux</string>
<string name="earn_filter_networks">Réseaux</string>
<string name="earn_mostly_used">Principalement utilisé</string>
<string name="earn_no_results">Pas de résultat</string>
<string name="earn_title">Gagner</string>
<string name="email_preface_wc_error">Bonjour équipe de support, jai rencontré une erreur avec le code : %s</string>
<string name="email_subject_wc_error">Erreur WalletConnect</string>
@ -823,6 +828,7 @@
<string name="markets_token_details_volume">Volume</string>
<string name="markets_tooltip_message">Tirez vers le haut ou appuyez sur la barre de recherche pour ajouter des jetons directement depuis le marché</string>
<string name="markets_tooltip_title">Ajouter des jetons</string>
<string name="markets_tooltip_v2_title">Ajouter plus de tokens</string>
<string name="markets_yield_supply_banner_description">Optimisez vos actifs tout en leur fournissant un accès instantané. %s</string>
<string name="markets_yield_supply_banner_title">Activer le mode rendement</string>
<string name="mobile_wallet_requires_min_os_warning_body">Vous devez effectuer la mise à jour %1$s afin de créer un portefeuille mobile.</string>
@ -1849,6 +1855,7 @@
<string name="warning_button_refresh">Rafraîchir</string>
<string name="warning_clore_migration_button">Lancer la migration</string>
<string name="warning_clore_migration_copy_button">Copier</string>
<string name="warning_clore_migration_description">Pour conserver l\'accès à vos fonds, lancez la migration conformément aux directives officielles de Clore.</string>
<string name="warning_clore_migration_error_signing_not_supported">La signature des messages n\'est pas prise en charge pour ce réseau.</string>
<string name="warning_clore_migration_error_wallet_manager_not_found">Impossible de signer le message. Veuillez réessayer.</string>
<string name="warning_clore_migration_message">Selon la documentation officielle de Clore, toutes les pièces reçues avant le 21 décembre seront migrées vers Clore (token ERC-20) ; les pièces reçues après cette date ne le seront pas. Une solution de transfert arrive — restez à l\'écoute.</string>

View file

@ -310,6 +310,7 @@
<string name="common_go_to_token">Перейти в токен</string>
<string name="common_got_it">Понятно</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>
@ -387,6 +388,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>
@ -478,9 +480,14 @@
<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_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>
@ -1810,6 +1817,7 @@
<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>

View file

@ -469,9 +469,11 @@
<string name="domain_receive_assets_onboarding_title">Send funds using only</string>
<string name="earn_best_opportunities">Best opportunities</string>
<string name="earn_clear_filter">Clear filter</string>
<string name="earn_empty">The list is temporarily empty as its being refreshed. Check back in a moment.</string>
<string name="earn_filter_all_networks">All networks</string>
<string name="earn_filter_all_types">All types</string>
<string name="earn_filter_by">Filter by</string>
<string name="earn_filter_my_networks">My networks</string>
<string name="earn_filter_networks">Networks</string>
<string name="earn_mostly_used">Mostly used</string>
<string name="earn_no_results">No results</string>
@ -828,6 +830,7 @@
<string name="markets_token_details_volume">Volume</string>
<string name="markets_tooltip_message">Pull this up or tap the search bar to add tokens directly from the market</string>
<string name="markets_tooltip_title">Add tokens</string>
<string name="markets_tooltip_v2_title">Add more tokens</string>
<string name="markets_yield_supply_banner_description">Power up your assets while supplying them with instant access. %s</string>
<string name="markets_yield_supply_banner_title">Activate Yield Mode</string>
<string name="mobile_wallet_requires_min_os_warning_body">You must update to %1$s before creating a mobile 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,

View file

@ -74,13 +74,14 @@ private const val DEFAULT_SCALE = 1f
private const val SHAKE_AMPLITUDE_DP = 5f
private const val SHAKE_DURATION_MS = 120
private const val FADE_DURATION_MS = 150
private const val FADE_DURATION_MS = 100
private const val BOUNCE_VELOCITY_MULTIPLIER = 1.25f
private const val HAPTIC_INITIAL_INTERVAL_MS = 200L
private const val HAPTIC_MIN_INTERVAL_MS = 30L
private const val HAPTIC_INITIAL_INTERVAL_MS = 50L
private const val HAPTIC_MIN_INTERVAL_MS = 15L
private const val HAPTIC_HEARTBEAT_INTERVAL_MS = 100L
private const val HAPTIC_SUCCESS_DELAY_MS = 300L
// Easing: smooth acceleration, gradually picking up speed
private val AccelerateEasing = CubicBezierEasing(
@ -288,6 +289,7 @@ private fun Modifier.holdToConfirmGestures(
state.isProgressVisible = true
// Soft heartbeat on success
delay(HAPTIC_SUCCESS_DELAY_MS)
performSuccessHapticFeedback(config.hapticManager)
state.scaleProgress.animateTo(
@ -324,7 +326,7 @@ private suspend fun performAcceleratingHapticFeedback(hapticManager: HapticManag
// Exponential decrease: interval decreases faster as progress increases
val interval = (maxInterval * (1f - progress * progress) + minInterval).toLong()
hapticManager.perform(TangemHapticEffect.View.SegmentTick)
hapticManager.perform(TangemHapticEffect.View.ClockTick)
delay(interval)
}
}
@ -333,18 +335,18 @@ private suspend fun performAcceleratingHapticFeedback(hapticManager: HapticManag
* Performs strong heartbeat haptic feedback on release (two heavy clicks).
*/
private suspend fun performReleaseHapticFeedback(hapticManager: HapticManager) {
hapticManager.perform(TangemHapticEffect.OneTime.HeavyClick)
hapticManager.perform(TangemHapticEffect.View.Reject)
delay(HAPTIC_HEARTBEAT_INTERVAL_MS)
hapticManager.perform(TangemHapticEffect.OneTime.HeavyClick)
hapticManager.perform(TangemHapticEffect.View.Reject)
}
/**
* Performs soft heartbeat haptic feedback on success (two light clicks).
*/
private suspend fun performSuccessHapticFeedback(hapticManager: HapticManager) {
hapticManager.perform(TangemHapticEffect.OneTime.Click)
hapticManager.perform(TangemHapticEffect.View.Confirm)
delay(HAPTIC_HEARTBEAT_INTERVAL_MS)
hapticManager.perform(TangemHapticEffect.OneTime.Click)
hapticManager.perform(TangemHapticEffect.View.Confirm)
}
private suspend fun CoroutineScope.handleReleaseAnimation(

View file

@ -38,6 +38,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.SearchBarTestTags
@Composable
fun SearchBar(
@ -126,7 +127,9 @@ private fun DecorationBox(
contentPadding = PaddingValues(all = TangemTheme.dimens.spacing12),
leadingIcon = {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size20),
modifier = Modifier
.size(TangemTheme.dimens.size20)
.testTag(SearchBarTestTags.ICON),
painter = painterResource(id = R.drawable.ic_search_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
@ -137,6 +140,7 @@ private fun DecorationBox(
state = state,
focusManager = focusManager,
keyboardController = keyboardController,
modifier = Modifier.testTag(SearchBarTestTags.CLEAR_BUTTON),
)
},
placeholder = {
@ -146,6 +150,7 @@ private fun DecorationBox(
style = TangemTheme.typography.body2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(SearchBarTestTags.PLACEHOLDER_TEXT),
)
},
)

View file

@ -8,16 +8,20 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.SwapStoriesScreenTestTags
import kotlinx.coroutines.delay
private const val STORIES_ANIMATION_SPEED_ZERO_DURATION = 3000L
@ -99,6 +103,7 @@ fun StoriesProgressBar(
.clip(RoundedCornerShape(2.dp))
.background(TangemColorPalette.White)
.fillMaxHeight()
.testTag(SwapStoriesScreenTestTags.PROGRESS_BAR_ITEM)
.let { modifier ->
when (index) {
currentStep -> modifier.fillMaxWidth(progress.value)

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