Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-06 20:37:03 +07:00
commit e52f9de341
987 changed files with 20247 additions and 8171 deletions

View file

@ -0,0 +1,471 @@
# Navigation Graph
Complete navigation map of the app based on `AppRoute` sealed class and feature-internal routes.
## 1. All AppRoute Paths
58 top-level routes defined in `common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt`.
| # | Route | Path | Description |
|---|-------|------|-------------|
| 1 | `Initial` | `/initial` | App entry point (splash) |
| 2 | `Home` | `/home` | Stories/home screen with launch mode |
| 3 | `Welcome` | `/welcome` | Welcome screen for returning users |
| 4 | `Disclaimer` | `/disclaimer` | Terms of service / disclaimer |
| 5 | `Wallet` | `/wallet` | Main wallet portfolio screen |
| 6 | `CurrencyDetails` | `/currency_details/{walletId}/{currencyId}` | Token/coin detail screen |
| 7 | `Send` | `/send/{walletId}/{currencyId}` | Send cryptocurrency |
| 8 | `Details` | `/details/{walletId}` | Wallet details / settings hub |
| 9 | `DetailsSecurity` | `/details/security` | Security mode settings |
| 10 | `Usedesk` | `/usedesk/{walletId}` | Customer support (Usedesk) |
| 11 | `CardSettings` | `/card_settings/{walletId}` | Card-specific settings |
| 12 | `AppSettings` | `/app_settings` | Global app settings |
| 13 | `ResetToFactory` | `/reset_to_factory/{walletId}/{cardId}/...` | Factory reset flow |
| 14 | `AccessCodeRecovery` | `/access_code_recovery` | Access code recovery |
| 15 | `ManageTokens` | `{source}/manage_tokens/{accountId}` | Add/remove tokens in portfolio |
| 16 | `ChooseManagedTokens` | `/{source}/choose_managed_tokens/...` | Token chooser for send-via-swap |
| 17 | `WalletConnectSessions` | `/wallet_connect_sessions` | WalletConnect sessions list |
| 18 | `QrScanning` | `/{source}/qr_scanning` | QR code scanner |
| 19 | `ReferralProgram` | `/referral_program` | Referral program |
| 20 | `Swap` | `/swap/{fromId}/{toId}/{walletId}/...` | Token swap screen |
| 21 | `AppCurrencySelector` | `/app_currency_selector` | Fiat currency selector |
| 22 | `Staking` | `/staking/{walletId}/{currencyId}/{integrationId}` | Staking screen |
| 23 | `PushNotification` | `/push_notification` | Push notification opt-in |
| 24 | `WalletSettings` | `/wallet_settings/{walletId}` | Per-wallet settings |
| 25 | `WalletBackup` | `/wallet_backup/{walletId}/{coldOption}` | Wallet backup options |
| 26 | `WalletHardwareBackup` | `/wallet_hardware_backup/{walletId}` | Hardware wallet backup |
| 27 | `Markets` | `/markets` | Markets token list |
| 28 | `MarketsTokenDetails` | `/markets_token_details/{tokenId}/{showPortfolio}` | Market token detail |
| 29 | `Onramp` | `/onramp/{walletId}/{symbol}` | Buy crypto (onramp) |
| 30 | `OnrampSuccess` | `/onramp/success/{txId}` | Onramp success screen |
| 31 | `BuyCrypto` | `/buy_crypto/{walletId}` | Buy crypto token selector |
| 32 | `SellCrypto` | `/sell_crypto/{walletId}` | Sell crypto token selector |
| 33 | `SwapCrypto` | `/swap_crypto/{walletId}` | Swap crypto token selector |
| 34 | `Onboarding` | `/onboarding_v2/{mode}` | Onboarding flow (v2) |
| 35 | `Stories` | `/stories$storyId` | Stories / promotional content |
| 36 | `NFT` | `/nft/{walletId}` | NFT collection list |
| 37 | `NFTSend` | `/send/nft/{walletId}/{collection}/{assetId}` | Send NFT |
| 38 | `CreateWalletSelection` | `/create_wallet_selection` | Choose wallet creation type |
| 39 | `CreateWalletStart` | `/create_wallet_start` | Wallet creation intro (cold/hot) |
| 40 | `CreateHardwareWallet` | `/create_hardware_wallet` | Create hardware wallet flow |
| 41 | `CreateMobileWallet` | `/create_mobile_wallet` | Create mobile (hot) wallet |
| 42 | `UpgradeWallet` | `/upgrade_wallet/{walletId}` | Upgrade hot wallet to hardware |
| 43 | `AddExistingWallet` | `/add_existing_wallet` | Import existing wallet |
| 44 | `WalletActivation` | `/wallet_activation/{walletId}` | Activate wallet post-creation |
| 45 | `CreateWalletBackup` | `/create_wallet_backup/{walletId}` | Backup flow for created wallet |
| 46 | `UpdateAccessCode` | `/update_access_code/{walletId}` | Change access code |
| 47 | `ViewPhrase` | `/view_seed_phrase/{walletId}` | View recovery phrase |
| 48 | `ForgetWallet` | `/forget_wallet/{walletId}` | Remove wallet from app |
| 49 | `SendEntryPoint` | `/send_entry_point/{walletId}/{currencyId}` | Send entry with swap option |
| 50 | `CreateAccount` | `/create_account/{walletId}` | Create new account |
| 51 | `EditAccount` | `/edit_account/{accountId}` | Edit account |
| 52 | `AccountDetails` | `/account_details/{accountId}` | Account details screen |
| 53 | `ArchivedAccountList` | `/archived_account/{walletId}` | Archived accounts list |
| 54 | `TangemPayDetails` | `/tangem_pay_details/{walletId}` | Tangem Pay card details |
| 55 | `TangemPayOnboarding` | `/tangem_pay_onboarding/{mode}` | Tangem Pay onboarding |
| 56 | `Kyc` | `/kyc` | KYC verification |
| 57 | `YieldSupplyEntry` | `/yield_supply_entry/{walletId}/{symbol}` | Yield/supply entry point |
| 58 | `NewsDetails` | `/news_details/{newsId}` | News article detail |
## 2. Navigation Edges
Each entry shows: **Source route** → target routes it can navigate to (via `push` or `replaceAll`).
### Initial / Bootstrap
| Source | Target | Method | Trigger |
|--------|--------|--------|---------|
| `Initial` | `Home`, `Welcome`, `Disclaimer`, `Onboarding`, etc. | `replaceAll` | App startup (DefaultRoutingComponent) |
### Home
| Target | Method | Trigger |
|--------|--------|---------|
| `ManageTokens(STORIES)` | push | After scan, manage tokens |
| `CreateWalletStart` | push | Create wallet from home |
| `Wallet` | replaceAll | After wallet saved / already saved |
### Welcome
| Target | Method | Trigger |
|--------|--------|---------|
| `CreateWalletSelection` | push | "Add new wallet" button |
| `Home()` | replaceAll | When wallets list becomes empty |
| `Wallet` | replaceAll | After scan / wallet unlock / biometric |
### Disclaimer
| Target | Method | Trigger |
|--------|--------|---------|
| `PushNotification(Stories)` | push | After accepting TOS (stories flow) |
| `Home()` | replaceAll | After accepting TOS (non-stories flow) |
### Wallet (main portfolio)
| Target | Method | Trigger |
|--------|--------|---------|
| `Details` | push | Open wallet details |
| `ManageTokens(ACCOUNT)` | push | Manage tokens for account |
| `Onboarding` | push | Continue backup / onboarding |
| `CurrencyDetails` | push | Tap on a token |
| `Home` | push | Open stories |
| `NFT` | push | Open NFT collection |
| `TangemPayOnboarding` | push | Tangem Pay banner |
| `TangemPayDetails` | push | Tangem Pay card details |
| `YieldSupplyEntry` | push | Yield supply action |
| `QrScanning(MainScreen)` | push | QR scanner |
| `Send` | push | Send from QR / action |
| `WalletBackup` | push | Backup warning banner |
### CurrencyDetails (token details)
| Target | Method | Trigger |
|--------|--------|---------|
| `Onramp` | push | Buy action |
| `SendEntryPoint` | push | Send action |
| `Swap` | push | Swap action |
| `CurrencyDetails` | push | Navigate to related token (from staking router) |
| `Staking` | push | Open staking (from token details router) |
### Details (wallet details hub)
| Target | Method | Trigger |
|--------|--------|---------|
| `CreateWalletSelection` | push | Add new wallet |
| `WalletSettings` | push | Open wallet settings |
| `WalletConnectSessions` | push | WalletConnect item |
| `AppSettings` | push | App settings item |
| `Disclaimer(isTosAccepted=true)` | push | View TOS |
| `Usedesk` | push | Customer support |
| `TangemPayOnboarding(FromBannerInSettings)` | push | Tangem Pay banner |
### WalletSettings
| Target | Method | Trigger |
|--------|--------|---------|
| `ReferralProgram` | push | Referral program |
| `WalletHardwareBackup` | push | Hardware backup |
| `CardSettings` | push | Card settings |
| `ForgetWallet` | push | Delete/forget wallet |
| `ViewPhrase` | push | View seed phrase |
| `AccountDetails` | push | Open account details |
| `ArchivedAccountList` | push | View archived accounts |
| `CreateAccount` | push | Create new account |
| `Home()` | replaceAll | After wallet deletion completes |
### WalletBackup
| Target | Method | Trigger |
|--------|--------|---------|
| `WalletActivation` | push | Start activation (no backup) |
| `ViewPhrase` | push | View phrase option |
| `WalletHardwareBackup` | push | Hardware backup option |
### WalletHardwareBackup
| Target | Method | Trigger |
|--------|--------|---------|
| `CreateHardwareWallet` | push | Create new hardware wallet |
| `UpgradeWallet` | push | Upgrade current hot wallet |
| `CreateWalletBackup` | push | Backup existing wallet |
### CreateWalletSelection
| Target | Method | Trigger |
|--------|--------|---------|
| `CreateMobileWallet` | push | Choose mobile wallet |
| `CreateHardwareWallet` | push | Choose hardware wallet |
### CreateWalletStart
| Target | Method | Trigger |
|--------|--------|---------|
| `CreateMobileWallet` | push | Create mobile wallet |
| `Wallet` | replaceAll | After wallet creation completes |
### CreateMobileWallet
| Target | Method | Trigger |
|--------|--------|---------|
| `AddExistingWallet` | push | Import existing wallet |
| `Wallet` | replaceAll | After creation completes |
### CreateHardwareWallet
| Target | Method | Trigger |
|--------|--------|---------|
| `Wallet` | replaceAll | After hardware wallet created |
### AddExistingWallet
| Target | Method | Trigger |
|--------|--------|---------|
| `Wallet` | replaceAll | After import completes |
### UpgradeWallet
| Target | Method | Trigger |
|--------|--------|---------|
| `Onboarding(UpgradeHotWallet)` | push | Start upgrade onboarding |
### CreateWalletBackup
| Target | Method | Trigger |
|--------|--------|---------|
| `UpgradeWallet` | push | After backup, continue to upgrade |
### AccountDetails
| Target | Method | Trigger |
|--------|--------|---------|
| `EditAccount` | push | Edit account |
### ForgetWallet
| Target | Method | Trigger |
|--------|--------|---------|
| `Home()` | replaceAll | After wallet forgotten |
### WalletConnectSessions
| Target | Method | Trigger |
|--------|--------|---------|
| `QrScanning(WalletConnect)` | push | Scan WC QR code |
### Onboarding
| Target | Method | Trigger |
|--------|--------|---------|
| `Home()` | replaceAll | Onboarding completed (no wallets) |
| `Wallet` | replaceAll | Onboarding completed (has wallets) |
### PushNotification
| Target | Method | Trigger |
|--------|--------|---------|
| `Home()` | replaceAll | After push notification opt-in (via nextRoute param) |
### Staking
| Target | Method | Trigger |
|--------|--------|---------|
| `CurrencyDetails` | push | Back to token details |
### TangemPayDetails
| Target | Method | Trigger |
|--------|--------|---------|
| `Swap` | push | Top up / withdraw via swap |
### NFT
| Target | Method | Trigger |
|--------|--------|---------|
| `NFTSend` | push | Send NFT |
### Send (notifications)
| Target | Method | Trigger |
|--------|--------|---------|
| `CurrencyDetails` | push | Navigate to fee token |
### SwapCrypto / BuyCrypto / SellCrypto
| Target | Method | Trigger |
|--------|--------|---------|
| `Swap` | push | After token selection (SwapCrypto) |
| `Onramp` | push | After token selection (BuyCrypto/SellCrypto) |
### Deep Link Handlers (push to AppRoute)
| Handler | Target Route |
|---------|-------------|
| `OnrampDeepLinkHandler` | Processes onramp callback params |
| `SellRedirectDeepLinkHandler` | `Send` (with sell redirect params) |
| `BuyDeepLinkHandler` | `BuyCrypto` |
| `SellDeepLinkHandler` | `SellCrypto` |
| `SwapDeepLinkHandler` | `SwapCrypto` |
| `ReferralDeepLinkHandler` | Referral handling |
| `WalletDeepLinkHandler` | Wallet handling |
| `TokenDetailsDeepLinkHandler` | `CurrencyDetails` |
| `StakingDeepLinkHandler` | `Staking` |
| `MarketsDeepLinkHandler` | `Markets` |
| `MarketsTokenDetailDeepLinkHandler` | `MarketsTokenDetails` |
| `WalletConnectDeepLinkHandler` | WalletConnect pairing |
| `PromoDeeplinkHandler` | Promo handling |
| `OnboardVisaDeepLinkHandler` | `TangemPayOnboarding` |
| `NewsDetailsDeepLinkHandler` | `NewsDetails` |
## 3. Nested Routes (Feature-Internal Navigation)
### OnboardingRoute
**File:** `features/onboarding-v2/impl/.../routing/OnboardingRoute.kt`
| Route | Description |
|-------|-------------|
| `None` | Initial empty state |
| `Note` | Single-card onboarding note |
| `MultiWallet` | Multi-wallet onboarding (with seed phrase flow option) |
| `Visa` | Visa card onboarding |
| `Twins` | Twin cards onboarding |
| `ManageTokens` | Token management during onboarding |
| `AskBiometry` | Biometry setup prompt |
| `Done` | Onboarding completion |
### WalletRoute
**File:** `features/wallet/impl/.../navigation/WalletRoute.kt`
| Route | Description |
|-------|-------------|
| `Wallet` | Main wallet view |
| `OrganizeTokens` | Reorder tokens in portfolio |
### SendEntryRoute
**File:** `features/send-v2/api/.../entry/SendEntryRoute.kt`
| Route | Description |
|-------|-------------|
| `Send` | Direct send flow |
| `SendWithSwap` | Send with swap option |
| `ChooseToken` | Token chooser for send-via-swap |
### CommonSendRoute (Send internal)
Used internally by `SendModel` and `NFTSendModel`:
- `Amount``Destination``Confirm``ConfirmSuccess`
- Edit mode: `Confirm``Destination(edit)` or `Amount(edit)`
### FeeSelectorRoute (Send internal)
- `ChooseToken` — select fee token
- `ChooseSpeed` — select fee speed
### WcInnerRoute (WalletConnect)
**File:** `features/walletconnect/impl/.../routing/WcInnerRoute.kt`
| Route | Description |
|-------|-------------|
| `Method.Send` | WC send transaction |
| `Method.SignMessage` | WC sign message |
| `Method.AddNetwork` | WC add network |
| `Method.SwitchNetwork` | WC switch network |
| `Pair` | WC pairing request |
| `UnsupportedMethodAlert` | Unsupported method alert |
| `WcDappDisconnected` | DApp disconnected alert |
| `TangemUnsupportedNetwork` | Unsupported network alert |
| `RequiredAddNetwork` | Required network add |
| `RequiredReconnectWithNetwork` | Required network reconnect |
### TangemPayDetailsInnerRoute
**File:** `features/tangempay/details/impl/.../navigation/TangemPayDetailsInnerRoute.kt`
| Route | Description |
|-------|-------------|
| `Details` | Main details view |
| `ChangePIN` | Change PIN flow |
| `ChangePINSuccess` | PIN change success |
| `AddToWallet` | Add card to device wallet |
Transitions: `Details``ChangePIN``ChangePINSuccess`, `Details``AddToWallet`
### FeedEntryRoute
**File:** `features/feed/api/.../components/FeedEntryRoute.kt`
| Route | Description |
|-------|-------------|
| `MarketTokenDetails` | Market token detail view |
| `MarketTokenList` | Markets list |
| `NewsDetail` | News article detail |
### CreateWalletBackupRoute
**File:** `features/hot-wallet/impl/.../createwalletbackup/routing/CreateWalletBackupRoute.kt`
| Route | Description |
|-------|-------------|
| `RecoveryPhraseStart` | Backup intro |
| `RecoveryPhrase` | Show recovery phrase |
| `ConfirmBackup` | Confirm backup |
| `BackupCompleted` | Backup complete (with upgrade/last screen flags) |
Transitions: `RecoveryPhraseStart``RecoveryPhrase``ConfirmBackup``BackupCompleted`
### AddExistingWalletRoute
**File:** `features/hot-wallet/impl/.../addexistingwallet/entry/routing/AddExistingWalletRoute.kt`
| Route | Description |
|-------|-------------|
| `Import` | Seed phrase import |
| `BackupCompleted` | Backup completed |
| `SetAccessCode` | Set access code |
| `ConfirmAccessCode` | Confirm access code |
| `PushNotifications` | Push notification opt-in |
| `SetupFinished` | Setup complete |
Transitions: `Import``BackupCompleted``SetAccessCode``ConfirmAccessCode``PushNotifications``SetupFinished`
### UpdateAccessCodeRoute
**File:** `features/hot-wallet/impl/.../updateaccesscode/routing/UpdateAccessCodeRoute.kt`
| Route | Description |
|-------|-------------|
| `SetAccessCode` | Enter new access code |
| `ConfirmAccessCode` | Confirm new access code |
| `SetupFinished` | Update complete |
Transitions: `SetAccessCode``ConfirmAccessCode``SetupFinished`
### WalletActivationRoute
**File:** `features/hot-wallet/impl/.../walletactivation/entry/routing/WalletActivationRoute.kt`
| Route | Description |
|-------|-------------|
| `ManualBackupStart` | Backup intro |
| `ManualBackupPhrase` | Show recovery phrase |
| `ManualBackupCheck` | Verify backup |
| `ManualBackupCompleted` | Backup success |
| `SetAccessCode` | Set access code |
| `ConfirmAccessCode` | Confirm access code |
| `PushNotifications` | Push notification opt-in |
| `SetupFinished` | Activation complete |
Transitions: `ManualBackupStart``ManualBackupPhrase``ManualBackupCheck``ManualBackupCompleted``SetAccessCode``ConfirmAccessCode``PushNotifications``SetupFinished`
## 4. Deep Links
### URI Schemes
| Scheme | Value | Usage |
|--------|-------|-------|
| `Tangem` | `tangem://` | Primary app deep links |
| `WalletConnect` | `wc://` | WalletConnect pairing |
| `Https` | `https://` | Web links (tangem.com) |
### Tangem Scheme Routes (`tangem://{host}`)
| Host | Handler | Target |
|------|---------|--------|
| `onramp` | `OnrampDeepLinkHandler` | Onramp callback processing |
| `redirect_sell` | `SellRedirectDeepLinkHandler` | `Send` (sell redirect with tx params) |
| `redirect` | — | Buy redirect (no-op) |
| `buy` | `BuyDeepLinkHandler` | `BuyCrypto` |
| `sell` | `SellDeepLinkHandler` | `SellCrypto` |
| `swap` | `SwapDeepLinkHandler` | `SwapCrypto` |
| `referral` | `ReferralDeepLinkHandler` | Referral flow |
| `main` | `WalletDeepLinkHandler` | Wallet screen |
| `token` | `TokenDetailsDeepLinkHandler` | `CurrencyDetails` |
| `staking` | `StakingDeepLinkHandler` | `Staking` |
| `markets` | `MarketsDeepLinkHandler` | `Markets` |
| `token_chart` | `MarketsTokenDetailDeepLinkHandler` | `MarketsTokenDetails` |
| `wc` | `WalletConnectDeepLinkHandler` | WalletConnect pairing |
| `promo` | `PromoDeeplinkHandler` | Promo handling |
| `onboard-visa` | `OnboardVisaDeepLinkHandler` | `TangemPayOnboarding` |
### HTTPS Routes (`https://tangem.com/...`)
| Path prefix | Handler | Target |
|-------------|---------|--------|
| `/pay-app` | `OnboardVisaDeepLinkHandler` | `TangemPayOnboarding` |
| `/news` | `NewsDetailsDeepLinkHandler` | `NewsDetails` |
### Deep Link Readiness
Deep links are only processed when the app is on a "ready" route. These routes **block** deep link processing:
- `Initial`, `Home`, `Welcome`, `PushNotification`, `Disclaimer`, `Stories`, `Onboarding`

View file

@ -0,0 +1,19 @@
# Image Resources
## Naming
There are 3 types of icons:
1. Black or single color icon (naming: `ic_name_24`, where number is size)
2. Icon with constant color, and tint could be applied (naming: `img_name_24`)
3. Large image with different colors and shapes (naming: `ill_name`)
Examples:
1. `ic_chevron_24`
2. `img_walletconnect_24`
3. `ill_bussiness`
## Attention
For complex vector images (named with `ill_name`), you should use `.png` resources, because when the project is compiled, all complex vectors are converted to large, heavy PNGs for different dimensions.

View file

@ -0,0 +1,107 @@
# Domain Components
Key domain mechanisms that orchestrate data flow: suppliers, fetchers, and use cases.
## Retrieving Core Models
### UserWallet
#### UserWalletsListRepository
**Location:** `domain/common``com.tangem.domain.common.wallets.UserWalletsListRepository`
Repository for managing user wallets list. Provides `StateFlow<List<UserWallet>?>` for the wallets list and `StateFlow<UserWallet?>` for the selected wallet. Supports loading, selecting, saving, locking/unlocking (biometric, access code), deleting, and reordering wallets.
### Account / AccountList
#### SingleAccountSupplier
**Location:** `domain/account``com.tangem.domain.account.supplier.SingleAccountSupplier`
Supplier that provides a single `Account` by `AccountId`. Has convenience methods `filterPaymentAccount` and `filterCryptoPortfolioAccount` to filter by account subtype.
#### SingleAccountListSupplier
**Location:** `domain/account``com.tangem.domain.account.supplier.SingleAccountListSupplier`
Supplier that provides an `AccountList` for a specific user wallet by `UserWalletId`.
#### MultiAccountListSupplier
**Location:** `domain/account``com.tangem.domain.account.supplier.MultiAccountListSupplier`
Supplier that provides a list of `AccountList`s for all user wallets. Extends `FlowCachingSupplier`.
#### SingleAccountListFetcher
**Location:** `domain/account``com.tangem.domain.account.fetcher.SingleAccountListFetcher`
Fetcher that fetches a list of accounts for a single wallet by `UserWalletId`. Extends `FlowFetcher`.
### AccountStatus / AccountStatusList
#### SingleAccountStatusSupplier
**Location:** `domain/account/status``com.tangem.domain.account.status.supplier.SingleAccountStatusSupplier`
Supplier that provides a single `AccountStatus` by account identifier. Extends `FlowCachingSupplier`.
#### SingleAccountStatusListSupplier
**Location:** `domain/account/status``com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier`
Same as `SingleAccountListSupplier` but provides `AccountStatusList` (accounts with balances) for a specific user wallet.
#### MultiAccountStatusListSupplier
**Location:** `domain/account/status``com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier`
Same as `MultiAccountListSupplier` but provides a list of `AccountStatusList`s for all user wallets.
### Network / NetworkStatus
#### SingleNetworkStatusSupplier
**Location:** `domain/networks``com.tangem.domain.networks.single.SingleNetworkStatusSupplier`
Supplier of `NetworkStatus` for a specific network and wallet. Extends `FlowCachingSupplier`.
#### MultiNetworkStatusSupplier
**Location:** `domain/networks``com.tangem.domain.networks.multi.MultiNetworkStatusSupplier`
Supplier of all `NetworkStatus`es (as `Set<NetworkStatus>`) for a selected wallet. Extends `FlowCachingSupplier`.
#### SingleNetworkStatusFetcher
**Location:** `domain/networks``com.tangem.domain.networks.single.SingleNetworkStatusFetcher`
Fetcher of network status for a single `Network` by `UserWalletId`. Extends `FlowFetcher`.
#### MultiNetworkStatusFetcher
**Location:** `domain/networks``com.tangem.domain.networks.multi.MultiNetworkStatusFetcher`
Fetcher of network statuses for a set of `Network`s for a multi-currency wallet by `UserWalletId`. Extends `FlowFetcher`.
## Updating Balances
### WalletBalanceFetcher
**Location:** `domain/tokens``com.tangem.domain.tokens.wallet.WalletBalanceFetcher`
Fetcher of wallet balances by `UserWalletId`. Selects the appropriate fetching strategy based on wallet type (multi-wallet, single wallet with tokens, single wallet). Delegates to `BalanceFetchingOperations` for shared fetching logic.
### CryptoCurrencyBalanceFetcher
**Location:** `domain/account/status``com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher`
Fetches and refreshes balances for specific crypto currencies. Uses per-wallet mutexes to allow concurrent refreshes for different wallets while preventing concurrent refreshes for the same wallet. Delegates to `BalanceFetchingOperations`.
## Managing Portfolio (User Tokens)
### ManageCryptoCurrenciesUseCase
**Location:** `domain/account/status``com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase`
Use case for adding and removing crypto currencies in an account.

View file

@ -0,0 +1,142 @@
# Domain Models
Core business models used across the application. Models are defined in `domain/models/` and `domain/account/`.
## StatusSource
**Location:** `domain/models``com.tangem.domain.models.StatusSource`
Enum representing data loading/refresh status. Used across all status models (NetworkStatus, QuoteStatus, YieldBalance, CryptoCurrencyStatus.Sources):
- `CACHE` — initial status, data loaded from cache
- `ACTUAL` — terminal status, data successfully fetched from server
- `ONLY_CACHE` — terminal status, data could not be refreshed (only cached data available)
## CryptoCurrency
**Location:** `domain/models``com.tangem.domain.models.currency.CryptoCurrency`
Sealed class representing a cryptocurrency — either a native coin (`Coin`) or a token (`Token`). Used throughout the application: portfolio, token search, swaps, buy/sell, staking, etc.
## CryptoCurrencyStatus
**Location:** `domain/models``com.tangem.domain.models.currency.CryptoCurrencyStatus`
Model representing a currency with its balance state. Primarily used to display user's coin balance in the portfolio. Wraps `CryptoCurrency` with a `Value` sealed interface:
| Value subtype | Description |
|---|---|
| `Loading` | First-time fetch; once data is loaded, subsequent updates use cache via StatusSource, bypassing Loading |
| `Loaded` | Full data available |
| `Custom` | Custom token in portfolio; some data may be missing (e.g., no balance if backend has no quotes for it) |
| `NoQuote` | Balance known, no price data |
| `NoAccount` | Account not created (e.g., Solana reserve) |
| `Unreachable` | Network error |
| `NoAmount` | Coin is added to portfolio but no blockchain data available for it |
| `MissedDerivation` | Coin has no derivations — failed to obtain a blockchain network address |
All Value subtypes carry `sources: Sources` tracking data freshness per dimension: `networkSource`, `quoteSource`, `stakingBalanceSource`, and aggregated `total`.
## Network
**Location:** `domain/models``com.tangem.domain.models.network.Network`
Represents a blockchain network (e.g., Ethereum, Bitcoin). Contains network metadata: ID, name, currency symbol, derivation path, standard type (ERC20, TRC20, BEP20, etc.), and capabilities (token support, transaction extras, name resolving).
## NetworkStatus
**Location:** `domain/models``com.tangem.domain.models.network.NetworkStatus`
Blockchain balances for all tokens of a network at a specific address. Only `Verified` and `NoAccount` are cached.
| Value subtype | Description |
|---|---|
| `Verified` | Successful response from blockchain |
| `Unreachable` | Failed response from blockchain |
| `NoAccount` | Blockchain-specific status for chains that require a deposit to an address before it can be used |
| `MissedDerivation` | Derivation failed — no blockchain network address |
## QuoteStatus
**Location:** `domain/models``com.tangem.domain.models.quote.QuoteStatus`
Exchange rate between the app's selected fiat currency and a coin's currency.
## YieldBalance
**Location:** `domain/models``com.tangem.domain.models.staking.YieldBalance`
Staking yield balance for a specific `StakingID` (integrationId + address).
## TotalFiatBalance
**Location:** `domain/models``com.tangem.domain.models.TotalFiatBalance`
Aggregate fiat balance across all tokens. Sealed interface with three states: `Loading`, `Failed`, `Loaded(amount, source)`.
## TokenList
**Location:** `domain/models``com.tangem.domain.models.tokenlist.TokenList`
List of cryptocurrency tokens for display in portfolio. Sealed interface with subtypes:
- `GroupedByNetwork` — tokens grouped by `Network`, each group contains a list of `CryptoCurrencyStatus`
- `Ungrouped` — flat list of `CryptoCurrencyStatus`
- `Empty` — no tokens
All subtypes carry `totalFiatBalance: TotalFiatBalance` and `sortedBy: TokensSortType`.
## Account
**Location:** `domain/models``com.tangem.domain.models.account.Account`
Model representing a user account. Subtypes:
- **`Account.CryptoPortfolio`** — crypto portfolio with coins. All tokens in the account share the account's derivation (main account is an exception). Has a `DerivationIndex`: `0` for main account, `1..19` for secondary
- **`Account.Payment`** — account for Visa card integration
## AccountStatus
**Location:** `domain/models``com.tangem.domain.models.account.AccountStatus`
Model representing an account with balances. Has a similar structure to `Account`: `CryptoPortfolio` and `Payment` subtypes.
## AccountList
**Location:** `domain/account``com.tangem.domain.account.models.AccountList`
List of all accounts for a user wallet (`UserWallet`).
Business rules (enforced by factory returning `Either<Error, AccountList>`):
- Accounts list cannot be empty
- Exactly 1 main account
- Max 20 active accounts (`MAX_ACCOUNTS_COUNT`), max 1000 archived
- No duplicate AccountIds or custom AccountNames
- `totalAccounts >= activeAccounts`
## AccountStatusList
**Location:** `domain/account``com.tangem.domain.account.models.AccountStatusList`
Same as `AccountList` but with balances (wraps `AccountStatus` instead of `Account`).
## UserWallet
**Location:** `domain/models``com.tangem.domain.models.wallet.UserWallet`
Top-level model representing a user's wallet stored in the app. Subtypes:
- **`Cold`** — wallet backed by a physical Tangem card (NFC). Contains `ScanResponse`, card info, backup state
- **`Hot`** — software (hot) wallet without a physical card
## Model Hierarchy
```
UserWallet
└─ AccountList / AccountStatusList
└─ Account.CryptoPortfolio / AccountStatus.CryptoPortfolio
├─ CryptoCurrency (Coin | Token)
│ └─ CryptoCurrencyStatus (currency + Value state)
│ ├─ built from NetworkStatus (per network)
│ ├─ built from QuoteStatus (per rawCurrencyId)
│ └─ built from YieldBalance (per stakingId)
├─ AccountId (SHA-256 hash)
├─ DerivationIndex (0 = main)
└─ CryptoPortfolioIcon (icon + color)
```

View file

@ -0,0 +1,22 @@
# Git Rules
## Branch Naming
| Type | Format | Example |
|------|--------|---------|
| Feature | `feature/AND-xxx_short_description` | `feature/AND-13391_balance_fetcher` |
| Bugfix | `bugfix/AND-xxx_short_description` | `bugfix/AND-14000_fix_crash` |
| Pre-release | `x.x_pre_release` | `5.36_pre_release` |
**Key branches:**
- `develop` — main integration branch, all feature/bugfix branches merge here
- `x.x_pre_release` — branched from `develop` on the last day of sprint for the upcoming release; receives regression bugfixes and additional release items
- `release` — merging into this branch triggers appTester build and production artifacts; PRs come from `x.x_pre_release`
## Commit Messages
Format: `AND-xxx Description`
- Start with the Jira task number (AND-xxx)
- Followed by a space and a short description in English
- Example: `[REDACTED_TASK_KEY] Finalize CryptoCurrencyBalanceFetcher refactoring`

View file

@ -0,0 +1,16 @@
# Tangem SDK & Libraries
## In-house SDKs (via `tangem_dependencies.toml`)
- **Blockchain SDK** (`com.tangem:blockchain`) — multichain SDK for working with blockchains: creating/signing transactions, fetching balances, managing addresses. Wrapped in `libs/blockchain-sdk/`
- **Card SDK** (`com.tangem.tangem-sdk-kotlin:core`, `:android`) — SDK for interacting with physical Tangem cards via NFC: scanning, wallet creation, key derivation, passcode management, backup. Wrapped in `libs/tangem-sdk-api/`
- **Hot SDK** (`com.tangem.tangem-hot-sdk-kotlin:core`, `:android`) — SDK for hot (software) wallets
- **Vico** (`com.tangem.vico`) — forked charting library Vico, adapted for project needs
## Wrapper Modules (`libs/`)
- `libs/blockchain-sdk/` — wrapper around Blockchain SDK, provides domain-level abstractions for blockchain operations
- `libs/tangem-sdk-api/` — wrapper around Card SDK, exposes NFC card interaction API to the app
- `libs/crypto/` — cryptographic utilities: derivation, address handling, blockchain-specific helpers
- `libs/auth/` — API key provider interfaces for external services (Express, StakeKit)
- `libs/visa/` — Visa integration: smart contracts, limits, balances via Web3j

View file

@ -0,0 +1,215 @@
---
name: analyze-logs
description: Analyze Tangem app user logs — extract device info, navigation path, errors, and key events timeline. Use when user provides a log file for bug investigation.
allowed-tools: Read, Grep
argument-hint: /path/to/logfile.txt [/path/to/logs.rtf]
---
Analyze the Tangem app user log file at path: `$ARGUMENTS`
## File Input
The user provides one or two file paths:
- **Log file** (`.txt`) — main application log, always required
- **User info file** (`.rtf` or `.txt`) — optional, contains card/device/error info from the user's feedback email
If two paths are provided, the first is the log file and the second is the user info file.
**If only the log file is provided**, ask the user if they have a user info file (`logs.rtf` or `logs.txt`). If they don't have it or don't respond, fill Device Context, Card Info, and Transaction Context sections from the log file data (Steps 2+3). Mark fields that could not be determined as "N/A".
## User Info File (logs.rtf / logs.txt)
If a user info file is provided, Read it and extract the plain text fields. The file contains structured key-value pairs like:
```
Card ID: AF36000002151580
Firmware version: 6.33r
Linked cards count: 2
Has seed phrase: true
Signed hashes [secp256k1]: 0
----------
Blockchain: Polygon
Explorer link: https://polygonscan.com/address/0x...
Derivation path: m/44'/60'/0'/0/0
Host: https://rpc-mainnet.matic.quiknode.pro/
Token: USDC
Error: Could not construct a recoverable key.
----------
Source address: 0x...
Destination address: 0x...
Amount: 11.319684
Fee: 0.004973
----------
Phone model: SM-S921B
OS version: 36
App version: 5.34.1
```
Extract all fields and include them in the **Device Context**, **Card Info**, and **Analysis Summary** sections of the report. If the RTF contains an `Error:` field, treat it as a key clue for the investigation.
Note: RTF files contain formatting markup (`\cb3`, `\cf4`, `{\field{...}}`). Ignore all RTF tags — only extract the plain text values after each colon.
## Log Format
Each line follows the pattern:
```
DD.MM HH:MM:SS.mmm: TAG Message
```
- Date format: `DD.MM` (day.month), no year — infer from context
- Multi-line entries (JSON bodies, stack traces) continue without the timestamp prefix
- Sensitive data is masked with `******`
- Continuation lines may start with `|` for structured data: `|- Duration millis: 300000`
## Analysis Steps
Use `head_limit` on every Grep call to protect context from overload.
### Step 1: Setup
1. **Log time range:** Read the first and last lines with dates (format `DD.MM`)
2. **Ask the user** (report time range, then ask):
- Date range to focus on (or "all" for the full file)
- Focus area: `Wallet`, `WalletConnect`, `Express (Onramp/Buy, Offramp/Sell, Swap/Exchange)`, `TangemPay`, `Feed`, `Markets`, `Settings`, `Referral`, `Staking`, `Onboarding`, `Send/Transactions`, `NFT`, or `all`
- Remember the chosen area as **FOCUS_AREA**
3. **Determine line range** (skip if "all"):
- Parse input into `DD.MM` patterns (`10-13.03` → start `10.03`, end `13.03`; `last day` → last date; `last 3 days` → 3 days before last)
- Find **START_LINE**: Grep `^START_DATE` (head_limit: 1, -n: true)
- Find **END_LINE**: Grep `^NEXT_DATE` (head_limit: 1, -n: true). If not found, END_LINE = end of file
- Report: "Focusing on lines START_LINEEND_LINE covering DD.MMDD.MM"
### Steps 2+3+4+5+6: Main Analysis (all in parallel)
Launch ALL Grep calls below in parallel. Steps 2+3 search the **full file** (device info may be before the date range). Steps 4+5+6 use `offset: START_LINE` to stay within the date range.
**Device Context (full file):**
- `PATCH.*user-wallets/applications` (head_limit: 5, -A 10) — Read JSON body to extract `systemVersion`, `version`, `language`, `timezone`
- `ip_address` (head_limit: 5, -A 20) — extract `alpha2`, `country`, `isBuyAllowed`, `isSellAllowed`
**Card Info (full file):**
- `CardSDK_Tlv.*TAG_Firmware` (head_limit: 20)
- `CardSDK_Tlv.*TAG_SettingsMask` (head_limit: 20)
- `CardSDK_Tlv.*TAG_IsActivated` (head_limit: 20)
- `CardSDK_Tlv.*TAG_ManufacturerName` (head_limit: 20)
**Navigation (offset: START_LINE):**
- `AppRouter` (head_limit: 200) — if FOCUS_AREA is `all` or navigation-heavy (Wallet, Onboarding, Send/Transactions), also Read `.claude/docs/navigation-graph.md` to cross-reference routes
**Errors (offset: START_LINE, head_limit: 50 each, -n: true):**
- HTTP errors: `<-- [45]\d{2}`
- Domain errors: `DomainError`
- App exceptions: `\bException\b`
- Biometric errors: `onAuthenticationError`
- Tangem Pay errors: `Failed checkCustomerWallet`
**Session timeline (offset: START_LINE, head_limit: 50 each):**
- `MainActivity.*onCreate` — app session start
- `MainActivity.*Splash screen` — splash screen installed/dismissed
- `MainActivity.*onNewIntent` — deep link or push notification
- `CardSDK_Session.*start card session` — NFC session starts
**Error filtering:** When processing error results, skip these noisy matches:
- `java.io.IOException: Canceled` — normal request cancellation
- `HttpException(code=304` — HTTP "Not Modified"
- Bare stacktrace lines starting with `\tat`
- `<-- HTTP FAILED: java.io.IOException: Canceled`
### Step 7: Deep Dive
For each significant error found above:
1. Note the error's line number from Grep output (`-n: true`)
2. Use Read with `offset: ERROR_LINE - 100, limit: 200` to get ~200 lines of context
3. In that context, look for navigation events, API calls, and redux actions
## Key Tags Reference
| Tag | Purpose |
|-----|---------|
| `MainActivity` | Activity lifecycle, splash screen, onNewIntent |
| `AppRouter` | Navigation: Push, Pop, Replace |
| `NetworkLogs` | HTTP requests/responses (OkHttp) |
| `BlockchainSDK_NETWORK` | Blockchain RPC calls |
| `CardSDK_Tlv` | NFC card data (firmware, settings) |
| `CardSDK_Session` | NFC session lifecycle |
| `CardSDK_Biometric` | Biometric authentication |
## Common Error Patterns
| Pattern | Meaning |
|---------|---------|
| `HttpException(code=4xx/5xx, errorBody={...})` | API error with structured body |
| `DomainError(description=...)` | App-level domain error |
| `<-- HTTP FAILED: java.io.IOException: Canceled` | Cancelled network request (noise) |
| `<-- 429` | Rate limiting |
| `onAuthenticationError` | Biometric auth failure |
## Output Template
Structure your report EXACTLY as follows:
```
# Log Analysis Report
## Device Context
| Parameter | Value |
|-----------|-------|
| App version | ... |
| Android version | ... |
| Phone model | ... (from logs.rtf if available) |
| Language | ... |
| Timezone | ... |
| Country | ... |
| Log time range | DD.MM HH:MM — DD.MM HH:MM |
## Card Info
| Parameter | Value |
|-----------|-------|
| Card ID | ... (from logs.rtf if available) |
| Firmware | ... |
| Manufacturer | ... |
| Is activated | ... |
| Linked cards | ... (from logs.rtf if available) |
| Has seed phrase | ... (from logs.rtf if available) |
## Transaction Context (from logs.rtf, if available)
| Parameter | Value |
|-----------|-------|
| Blockchain | ... |
| Token | ... |
| Source address | ... |
| Destination address | ... |
| Amount | ... |
| Fee | ... |
| Error | ... |
## Navigation Path
1. [HH:MM:SS] Screen (Push/Pop/Replace)
2. ...
**Summary:** Brief description of the user's journey.
## Errors Found
### HTTP Errors
| Time | URL | Status | Details |
|------|-----|--------|---------|
### Domain Errors
| Time | Component | Error |
|------|-----------|-------|
### Other Errors
| Time | Type | Details |
|------|------|---------|
## Key Events Timeline
| Time | Event | Details |
|------|-------|---------|
(chronological: app starts, card sessions, navigation, errors, notable API calls)
## Analysis Summary
(2-3 paragraphs: what the user was doing, what broke, probable cause, recommendations.
If FOCUS_AREA was specified, emphasize errors, navigation, and API calls related to that area.)
```
If a section has no data, write "None found" instead of omitting it.

View file

@ -0,0 +1,295 @@
---
name: fix-crashlytics
description: Auto-fix Crashlytics crashes from Jira — find [Crashlytics] tasks, analyze crash, fix code, create branches, comment on Jira. Runs on CI without prompts.
allowed-tools: Read, Grep, Glob, Bash, Edit, Write, Agent, mcp__atlassian__getAccessibleAtlassianResources, mcp__atlassian__searchJiraIssuesUsingJql, mcp__atlassian__getJiraIssue, mcp__atlassian__addCommentToJiraIssue, mcp__firebase__crashlytics_get_issue, mcp__firebase__crashlytics_list_events, mcp__firebase__firebase_get_environment
argument-hint: [--dry-run] [--since <JQL date expression>]
---
Auto-fix Crashlytics crashes reported in Jira.
**CRITICAL: This skill runs on CI. NEVER ask questions. If anything is ambiguous, make the safer choice or skip the task.**
## Constants
- **Jira cloudId**: `tangem.atlassian.net`
- **Firebase project**: `tangemapp`
- **Crashlytics appId (Release)**: `1:721920782444:android:2202a761840271413f2849`
- **Dry-run mode**: check if `$ARGUMENTS` contains `--dry-run`. In dry-run mode, do NOT push branches and do NOT comment on Jira.
- **Since**: check if `$ARGUMENTS` contains `--since <value>`. The value is any valid JQL date expression (e.g., `-3d`, `-1w`, `"2026-03-25"`). Default: `-1d`.
- **Tangem SDK packages** (crashes here cannot be fixed in app code):
- `com.tangem.blockchain` — Blockchain SDK
- `com.tangem.sdk` — Card SDK
- `com.tangem.hot.sdk` — Hot SDK
- `com.tangem.vico` — Vico charting
- `com.tangem.common.card` — Card SDK common
- `com.tangem.common.core` — Card SDK common core
## Phase 0: Preflight Checks
Before any work, verify that all required MCP servers and tools are available. **If any check fails, STOP immediately with an error message — do not proceed.**
### 0a. Verify Atlassian MCP
Call `mcp__atlassian__getAccessibleAtlassianResources` (no parameters).
- If the call succeeds and returns a list containing `tangem.atlassian.net` — Atlassian MCP is OK.
- If the call fails or the tool is not found — STOP with: `FATAL: Atlassian MCP server is not connected. Run 'claude mcp list' to check server status.`
### 0b. Verify Firebase MCP
Call `mcp__firebase__firebase_get_environment` (no parameters).
- If the call succeeds and the response contains `project_id: "tangemapp"` — Firebase MCP is OK and connected to the correct project (configured via `.firebaserc`).
- If the project is different or missing — STOP with: `FATAL: Firebase project mismatch. Expected 'tangemapp'. Check .firebaserc configuration.`
- If the call fails or the tool is not found — STOP with: `FATAL: Firebase MCP server is not connected. Run 'claude mcp list' to check server status.`
### 0c. Verify Git State
Run:
```bash
git status --porcelain 2>&1
```
- If output is empty (clean working tree) — OK.
- If there are uncommitted changes — STOP with: `FATAL: Working tree is not clean. Commit or stash changes before running this skill.`
### 0d. Sync with Remote
```bash
git fetch origin
git checkout develop
git pull origin develop
```
Initialize an internal results list to track each ticket's outcome.
## Phase 1: Find Crashlytics Tasks
Search Jira for Crashlytics tasks created in the past day:
- Tool: `mcp__atlassian__searchJiraIssuesUsingJql`
- `cloudId`: `tangem.atlassian.net`
- `jql`: `project = "AND" AND summary ~ "\\[Crashlytics\\]" AND created >= <since value> ORDER BY created DESC`
- Use the `--since` argument value, or `-1d` if not provided.
- `maxResults`: `50`
- `fields`: `["summary", "status"]`
Collect all returned issue keys (e.g., `[REDACTED_TASK_KEY]`).
If no tasks found, output "No Crashlytics tasks found since <since value>" and stop.
## Phase 2: Filter Out Already-Branched Tasks
For each ticket key, check if a branch already exists:
```bash
git branch -a | grep -F "<TICKET_KEY>"
```
- If a branch is found: record status `Skipped (branch exists)` and remove from the processing list.
### 2b. Filter by Existing Comment
For each remaining ticket, check if it was already processed by a previous run:
- Call `mcp__atlassian__getJiraIssue` with `issueIdOrKey` set to the ticket key and request comments.
- Check if any comment body starts with `**Claude Report**`.
- If such a comment exists: record status `Skipped (already commented)` and remove from the processing list.
Keep only tickets that passed both filters.
If no tickets remain after filtering, output the summary table and stop.
## Phase 3: Process Each Ticket
Process each remaining ticket sequentially. **Error handling rule**: if ANY step fails for a ticket, record the failure reason, run `git checkout develop && git checkout -- .` to clean up, and continue to the next ticket.
### Step 3a: Extract Crashlytics Issue ID
- Call `mcp__atlassian__getJiraIssue` with `responseContentFormat: "markdown"` to get the full description.
- Find the Crashlytics URL in the description. It looks like:
```
https://console.firebase.google.com/project/tangemapp/crashlytics/app/android:com.tangem.wallet/issues/<ISSUE_ID>
```
- Extract `<ISSUE_ID>` from the URL path (the segment after `/issues/` and before `?`).
- If no Crashlytics link found: skip with `Skipped (no Crashlytics link)`.
### Step 3b: Get Crash Details from Firebase
- Call `mcp__firebase__crashlytics_get_issue` with:
- `appId`: `1:721920782444:android:2202a761840271413f2849`
- `issueId`: the extracted issue ID
- Call `mcp__firebase__crashlytics_list_events` with:
- `appId`: `1:721920782444:android:2202a761840271413f2849`
- `filter`: `{"issueId": "<ISSUE_ID>"}`
- `pageSize`: `1`
Extract from the response:
- **Exception type and message** (from `subtitle` or `exceptions`)
- **Blame frame**: file name, line number, symbol (method name)
- **Full stacktrace** (from `exceptions` field in events)
Classify the crash by examining the blame frame and full stacktrace:
1. **App code**: blame frame is in `com.tangem.wallet` with `owner: DEVELOPER`, OR the first `com.tangem` frame in stacktrace is in app packages (`com.tangem.feature.*`, `com.tangem.core.*`, `com.tangem.data.*`, `com.tangem.domain.*`, `com.tangem.tap.*`, `com.tangem.datasource.*`). → Continue to Step 3c (fix the bug).
2. **Tangem SDK**: the first `com.tangem` frame in stacktrace belongs to a Tangem SDK package (see Constants). → Go to Step 3b-sdk (comment only, no fix).
3. **External dependency**: no `com.tangem` frames, or only third-party/Android framework code. → Go to Step 3b-ext (comment only, no fix).
### Step 3b-ext: Handle External Dependency Crash (comment only)
When the crash is in an external dependency (third-party library or Android framework), do NOT attempt to fix it. Instead, comment.
1. Identify the library/framework from the top frames of the stacktrace.
2. If NOT in `--dry-run` mode, call `mcp__atlassian__addCommentToJiraIssue`:
- `cloudId`: `tangem.atlassian.net`
- `issueIdOrKey`: the ticket key
- `contentFormat`: `markdown`
- `commentBody`:
```
**Claude Report**
**Crash location:** <library/framework name><fully.qualified.class.method>
**Exception:** <ExceptionType>: <message>
**Analysis:** This crash originates in an external dependency (<library/framework name>), not in app code.
```
3. Record status as `Commented (external dependency)`. Do NOT create a branch.
4. Continue to the next ticket.
### Step 3b-sdk: Handle Tangem SDK Crash (comment only)
When the crash is in a Tangem SDK package, do NOT attempt to fix it. Instead, analyze and comment.
1. Identify which SDK is affected from the package name:
- `com.tangem.blockchain` → Blockchain SDK
- `com.tangem.sdk` / `com.tangem.common.card` / `com.tangem.common.core` → Card SDK
- `com.tangem.hot.sdk` → Hot SDK
- `com.tangem.vico` → Vico
2. Walk the stacktrace to find the first app-code frame (caller context).
3. Analyze the crash: what exception, what method, what likely input caused it.
4. If NOT in `--dry-run` mode, call `mcp__atlassian__addCommentToJiraIssue`:
- `cloudId`: `tangem.atlassian.net`
- `issueIdOrKey`: the ticket key
- `contentFormat`: `markdown`
- `commentBody`:
```
**Claude Report**
**Crash location:** <SDK name><fully.qualified.class.method>
**Exception:** <ExceptionType>: <message>
**App context:** Called from <app_class.method> at <file:line>
**Analysis:** <what went wrong likely cause based on stacktrace and exception message>
**Recommendation:** This crash originates in Tangem <SDK name>. A fix requires an SDK update.
```
5. Record status as `Commented (SDK — <SDK name>)`. Do NOT create a branch.
6. Continue to the next ticket.
### Step 3c: Find and Read the Crashing File
1. Extract the simple class name from the blame frame's `symbol` (e.g., `com.tangem.feature.foo.BarClass.method` -> `BarClass`).
2. Use `Glob("**/<ClassName>.kt")` to find the file.
3. If multiple files match, use the full package path from the stacktrace to disambiguate.
4. `Read` the file. Focus on the method and line number from the blame frame.
5. Use `Grep` to understand related types, method signatures, or null-safety context if needed.
If the file cannot be found: skip with `Skipped (file not found)`.
### Step 3d: Fix the Bug
Apply a **minimal, defensive fix** based on the crash type. Do NOT refactor, add features, or clean up surrounding code.
**Fix patterns by exception type:**
| Exception | Fix Strategy |
|-----------|-------------|
| `NullPointerException` | Add null-checks. Use `?.` safe calls, `?: return`/`?: default` for fallback. For Moshi-deserialized models where Kotlin non-null types can be JVM-null, cast to nullable: `val x = obj.field as Type?` then null-check. Use `getOrNull()` instead of `[]` for collections. |
| `IndexOutOfBoundsException` | Add bounds checking. Use `getOrNull()`, `firstOrNull()`, `lastOrNull()`. Check `isEmpty()` before indexing. |
| `IllegalStateException` | Check state before access. For `lateinit` crashes: add `::property.isInitialized` check or make property nullable. For Decompose/lifecycle: guard with lifecycle state check. |
| `IllegalArgumentException` | Validate inputs. Use `coerceIn()`, `coerceAtLeast(0)`, `maxOf(0, value)`. For `BigDecimal` formatting issues: handle negative or zero values. |
| `ClassCastException` | Use `as?` safe cast with fallback. |
| `ConcurrentModificationException` | Copy collection before iteration: `.toList()`. |
**Rules:**
- Only change the file identified in the blame frame.
- Make the smallest possible change that prevents the crash.
- Use `Edit` tool for precise changes (not `Write` for the whole file).
- Follow existing code patterns in the file (logging, error handling style).
- Do NOT add comments explaining the fix — the commit message and Jira comment handle that.
### Step 3e: Build Verification
1. Determine the Gradle module from the file path:
- Take the path relative to the project root, up to (not including) `src/`.
- Replace `/` with `:` and prepend `:`.
- Example: `features/tokendetails/impl/src/...` -> `:features:tokendetails:impl`
- Special case: `app/src/...` -> `:app` (use `assembleGoogleDebug` instead of `assembleDebug`)
2. Run the build:
```bash
./gradlew :<module>:assembleDebug
# or for :app module:
./gradlew :app:assembleGoogleDebug
```
3. If build fails:
- Read the error, attempt to fix it (one retry only).
- If still fails: `git checkout -- .` and skip with `Failed (build failed)`.
### Step 3f: Create Branch, Commit, Push
```bash
git checkout develop
git checkout -b bugfix/<TICKET_KEY>
git add <changed_files_only>
git commit -m "<TICKET_KEY> Fix <ExceptionType> in <ClassName>"
```
If NOT in `--dry-run` mode:
```bash
git push -u origin bugfix/<TICKET_KEY>
```
Return to develop for the next ticket:
```bash
git checkout develop
```
### Step 3g: Comment on Jira
If NOT in `--dry-run` mode, call `mcp__atlassian__addCommentToJiraIssue`:
- `cloudId`: `tangem.atlassian.net`
- `issueIdOrKey`: the ticket key
- `contentFormat`: `markdown`
- `commentBody`:
```
**Claude Report**
**Root cause:** <description of what caused the crash>
**Fix:** <description of the code change>
**Branch:** bugfix/<TICKET_KEY>
**Affected file:** <relative path to the changed file>
```
Record status as `Fixed`.
## Phase 4: Output Summary
Output the results as a Markdown table:
```markdown
## Crashlytics Auto-Fix Summary
| Ticket | Crash | File | Status | Branch |
|--------|-------|------|--------|--------|
| AND-XXXXX | NPE in ClassName.method | ClassName.kt | Fixed | bugfix/AND-XXXXX |
| AND-YYYYY | IOOB in OtherClass.method | OtherClass.kt | Skipped (branch exists) | — |
| AND-ZZZZZ | ISE in ThirdClass.method | ThirdClass.kt | Failed (build failed) | — |
```
After the table, output totals:
```
**Total:** X tasks found, Y fixed, Z commented (SDK), W skipped, V failed
```

5
.firebaserc Normal file
View file

@ -0,0 +1,5 @@
{
"projects": {
"default": "tangemapp"
}
}

3
.gitignore vendored
View file

@ -45,3 +45,6 @@ app/src/external/google-services.json
# Kotlin Plugin
.kotlin/
find-latest-release-branch.output
# Claude
/.claude/worktrees/

14
.mcp.json Normal file
View file

@ -0,0 +1,14 @@
{
"mcpServers": {
"firebase": {
"type": "stdio",
"command": "npx",
"args": ["-y", "firebase-tools@latest", "mcp"]
},
"atlassian": {
"type": "stdio",
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"]
}
}
}

114
CLAUDE.md Normal file
View file

@ -0,0 +1,114 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Test Commands
```bash
# Build debug APK (Google flavor)
./gradlew :app:assembleGoogleDebug
# Run all unit tests (debug/googleDebug variants + JVM modules)
./gradlew unitTest
# Run tests for a single module
./gradlew :features:wallet:impl:testDebugUnitTest # Android library module
./gradlew :app:testGoogleDebugUnitTest # App module
./gradlew :domain:tokens:test # Pure JVM module
# Run a single test class
./gradlew :core:ui:testDebugUnitTest --tests "com.tangem.core.ui.format.BigDecimalCryptoFormatTest"
# Detekt (static analysis) — runs automatically via convention plugin on applicable modules
./gradlew detekt detektMain
# Build UI tests APKs (for Marathon)
./gradlew :app:assembleGoogleMocked :app:assembleGoogleMockedAndroidTest
# 1. :app:assembleGoogleMocked — main APK (mocked build)
# 2. :app:assembleGoogleMockedAndroidTest — test APK with instrumented tests
```
**Product flavors:** `google` and `huawei` (dimension: `service`). Default development flavor is `google`.
**Build types:** `debug`, `mocked`, `internal`, `external`, `release`.
## Architecture Overview
### Module Layers
The project is a heavily modularized Android app (~220 modules) organized in layers:
- **`app/`** — Application entry point, Hilt setup, navigation root
- **`domain/`** — Business logic and models. Each domain area (e.g., `tokens`, `wallets`, `card`) has a `models` submodule for pure data types and a core module for use cases
- **`data/`** — Repository implementations and data sources, mirrors domain structure
- **`features/`** — UI features using **API/Impl split pattern**: `features:foo:api` defines the public contract, `features:foo:impl` contains the implementation. This enforces clean dependency boundaries
- **`core/`** — Cross-cutting concerns: `ui`, `analytics`, `datasource`, `decompose`, `navigation`, `res`, `utils`, `security`, `pagination`
- **`common/`** — Shared models, routing, UI components, test utilities
- **`libs/`** — SDK wrappers: `blockchain-sdk`, `tangem-sdk-api`, `crypto`, `auth`, `visa`
### Component Architecture (Decompose)
The app uses [Decompose](https://github.com/arkivanov/Decompose) for lifecycle-aware components. Every feature screen follows this structure:
**API module** (`features/{name}/api/`):
- `{Name}Component` interface implementing `ComposableContentComponent`
- Inner `Params` data class for input parameters
- Inner `Factory` interface: `fun create(context: AppComponentContext, params: Params): {Name}Component`
**Impl module** (`features/{name}/impl/`):
- `Default{Name}Component` with `@AssistedInject` constructor taking `@Assisted appComponentContext: AppComponentContext` and `@Assisted params`
- Delegates `AppComponentContext by appComponentContext`
- Creates model via `getOrCreateModel(params)`
- `@Composable Content(modifier)` collects model state via `collectAsStateWithLifecycle()`
- Inner `@AssistedFactory` interface extending the public `Factory`
**Model** (`features/{name}/impl/.../model/`):
- `{Name}Model` extending `Model` base class, annotated `@ModelScoped`, uses `@Inject` constructor
- Receives params via `ParamsContainer.require<ParamsType>()`
- Exposes `StateFlow<{Name}UM>` (UM = UI Model, state class in `ui/state/` subpackage)
- Has `modelScope` (SupervisorJob + mainImmediate), auto-cancelled on destroy
**Child navigation within features:**
- `childStack()` — stacked screen navigation (back stack)
- `childSlot()` — optional overlays/bottom sheets (single or no child)
- `InnerRouter` — feature-internal navigation that delegates unknown routes to parent router
### Feature Package Conventions
- API package: `com.tangem.features.{name}.api` (plural `features`)
- Impl package: `com.tangem.feature.{name}.impl` (singular `feature` — legacy inconsistency, follow existing pattern per feature)
- Component: `{Name}Component` (api), `Default{Name}Component` (impl)
- Model: `{Name}Model` in `model/` subpackage
- UI State: `{Name}UM` in `ui/state/` subpackage
- UI Composable: in `ui/` subpackage
### Key Frameworks & Patterns
- **DI:** Hilt with `@SingletonComponent` scope and custom `@ModelScoped` scope for model-lifecycle dependencies
- **UI:** Jetpack Compose with Material3. Image loading via Coil
- **Navigation:** Custom `AppRouter` + `AppRoute` sealed classes with deep link support via `DeepLinkBuilder`
- **Networking:** Retrofit + Moshi for API communication
- **Local storage:** `AppPreferencesStore` for key-value pairs, `DataStore` for larger data
- **Async:** Kotlin Coroutines + Flow. Inject `CoroutineDispatcherProvider` (from `core/utils`) instead of using `Dispatchers.*` directly — provides `main`, `mainImmediate`, `io`, `default`, `single`
- **Error handling:** Arrow's `Either<Error, Success>` pattern throughout domain/data layers. `DataError` sealed hierarchy for domain errors. See `domain/core/CLAUDE.md` for the LCE pattern
- **Analytics:** `AnalyticsEvent(category, event, params)` in `core/analytics/models/`. Feature events are sealed class hierarchies extending `AnalyticsEvent`. Send via injected `AnalyticsEventHandler`
- **Feature toggles:** `FeatureTogglesManager` in `core/config-toggles/`. Toggles are defined in `core/config-toggles/src/main/assets/configs/feature_toggles_config.json` and auto-generated into a `FeatureToggles` enum by the convention plugin at build time. Each feature module exposes its own `XxxFeatureToggles` interface (in `api/`) with a `DefaultXxxFeatureToggles` implementation (in `impl/`) that delegates to `FeatureTogglesManager`
- **Supported languages:** `SupportedLanguages` in `core/utils/` defines the app's supported locales: en, ru, de, fr, it, ja, uk, zh, es. `getCurrentSupportedLanguageCode()` returns the device locale if supported, otherwise falls back to English. Used by API calls that accept a language parameter
### Build System
- **Gradle 8.14.1**, AGP 8.10.1, Kotlin 2.1.10
- **Version catalogs:** `gradle/dependencies.toml` (external/third-party dependencies) and `gradle/tangem_dependencies.toml` (in-house Tangem SDK dependencies)
- **Convention plugin:** `plugins/configuration/` — applies Detekt, configures test settings, generates environment configs and feature toggles
- **Custom Detekt rules:** `plugins/detekt-rules/`. Detekt configuration is in the `tangem-android-tools` git submodule. Key rule: `UnsafeStringResourceUsage` — prevents direct `stringResource()` / `pluralStringResource()` calls; use the `Safe`-suffixed variants instead
- **Localization:** Managed via [Lokalise](https://lokalise.com). Update strings by running `python3 lokalize.py`
- **GitHub Packages auth:** Requires `gpr.user` and `gpr.key` in `local.properties` for Tangem SDK dependencies
### Testing
- **JUnit 5** (Jupiter) for unit tests
- **MockK** for mocking
- **Turbine** for Flow testing
- **Truth** for assertions
- **Marathon** for UI tests (emulator-based, configured via `Marathonfile`)
- Shared test utilities in `common:test` and `test/core/`

View file

@ -161,6 +161,7 @@ dependencies {
implementation(projects.domain.news)
implementation(projects.domain.earn)
implementation(projects.domain.tokensync)
implementation(projects.domain.search)
implementation(projects.common)
implementation(projects.common.routing)
@ -216,6 +217,7 @@ dependencies {
implementation(projects.data.hotWallet)
implementation(projects.data.news)
implementation(projects.data.earn)
implementation(projects.data.search)
/** Features */
implementation(projects.features.referral.impl)
@ -369,9 +371,7 @@ dependencies {
implementation(deps.googlePlay.services)
implementation(deps.googlePlay.advertising)
coreLibraryDesugaring(deps.desugar)
implementation(deps.timber)
implementation(deps.kermit)
implementation(deps.reKotlin)
implementation(deps.zxing.qrCore)
implementation(deps.coil)
implementation(deps.coil.gif)
@ -390,6 +390,7 @@ dependencies {
implementation(deps.viewBindingDelegate)
implementation(deps.armadillo)
implementation(deps.kotlin.serialization)
implementation(deps.reKotlin)
implementation(deps.reownCore)
implementation(deps.reownWeb3)
implementation(deps.prettyLogger)

View file

@ -8,7 +8,7 @@ import dagger.hilt.android.testing.OnComponentReadyRunner
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import java.lang.reflect.Field
class ApplicationInjectionExecutionRule(
@ -46,7 +46,7 @@ class ApplicationInjectionExecutionRule(
try {
originalVersionValues = FeatureToggles.entries.associateWith { it.version }
} catch (e: Exception) {
Timber.e("Failed to save original toggles values: ${e.message}")
TangemLogger.e("Failed to save original toggles values: ${e.message}")
}
}
@ -60,9 +60,10 @@ class ApplicationInjectionExecutionRule(
versionField.set(toggle, newVersion)
}
Timber.i("FeatureToggles.values updated: $toggleStates")
TangemLogger.i("FeatureToggles.values updated: $toggleStates")
} catch (e: Exception) {
Timber.e("FeatureToggles.values didn't change with error: ${e.message}")
TangemLogger.e("FeatureToggles.values didn't change with error: ${e.message}")
}
}
@ -75,9 +76,9 @@ class ApplicationInjectionExecutionRule(
versionField.set(toggle, originalVersion)
}
Timber.i("FeatureToggles.values restored")
TangemLogger.i("FeatureToggles.values restored")
} catch (e: Exception) {
Timber.e("FeatureToggles.values didn't restored with error: ${e.message}")
TangemLogger.e("FeatureToggles.values didn't restored with error: ${e.message}")
}
}

View file

@ -167,6 +167,7 @@ abstract class BaseTestCase : TestCase(
"SWAP_REDESIGN_ENABLED" to false,
"ACCOUNTS_FEATURE_ENABLED" to true,
"GASLESS_APPROVAL_ENABLED" to true,
"MAIN_SCREEN_QR_SCANNING_ENABLED" to true,
)
)
}

View file

@ -1,5 +1,6 @@
package com.tangem.common.extensions
import android.os.SystemClock
import androidx.compose.ui.test.ComposeTimeoutException
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeTestRule
@ -61,9 +62,14 @@ fun KNode.clickAndWaitFor(
fun KNode.performTextInputInChunks(
text: String,
chunkSize: Int = 2
chunkSize: Int = 2,
delayBetweenChunksMs: Long = 100
) {
text.chunked(chunkSize).forEach { chunk ->
val chunks = text.chunked(chunkSize)
chunks.forEachIndexed { index, chunk ->
performTextInput(chunk)
if (index < chunks.lastIndex) {
SystemClock.sleep(delayBetweenChunksMs)
}
}
}

View file

@ -1,9 +1,17 @@
package com.tangem.common.extensions
import android.support.annotation.PluralsRes
import androidx.compose.ui.test.SemanticsMatcher
import androidx.test.platform.app.InstrumentationRegistry
import com.tangem.core.ui.utils.LazyListItemPositionSemantics
import io.github.kakaocup.compose.node.builder.ViewBuilder
fun ViewBuilder.hasLazyListItemPosition(position: Int) = apply {
addSemanticsMatcher(SemanticsMatcher.expectValue(LazyListItemPositionSemantics, position))
}
}
fun getQuantityString(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any): String =
InstrumentationRegistry.getInstrumentation()
.targetContext
.resources
.getQuantityString(resId, quantity, *formatArgs)

View file

@ -11,7 +11,7 @@ import kotlinx.coroutines.runBlocking
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
/**
* A JUnit rule that sets up the API environment for tests based on annotations or instrumentation arguments.
@ -82,11 +82,11 @@ class ApiEnvironmentRule : TestRule {
val environment = ApiEnvironment.valueOf(parts[1])
apiConfigId to environment
} catch (e: IllegalArgumentException) {
Timber.w("Invalid config or environment: $configPair")
TangemLogger.w("Invalid config or environment: $configPair")
null
}
} else {
Timber.w("Invalid config format: $configPair. Expected format: 'ConfigId=Environment'")
TangemLogger.w("Invalid config format: $configPair. Expected format: 'ConfigId=Environment'")
null
}
}
@ -94,7 +94,7 @@ class ApiEnvironmentRule : TestRule {
DEFAULT_API_CONFIGS.associateWith { ApiEnvironment.MOCK } + parsedConfigs
} catch (e: Exception) {
Timber.w("Failed to parse environment configs: $envConfigArg")
TangemLogger.w("Failed to parse environment configs: $envConfigArg")
DEFAULT_API_CONFIGS.associateWith { ApiEnvironment.MOCK }
}
}
@ -115,7 +115,7 @@ class ApiEnvironmentRule : TestRule {
runBlocking {
targetEnvironments.forEach { (apiConfigId, environment) ->
changeEnvironment(apiConfigId.name, environment)
Timber.i("$apiConfigId environment set to: ${environment.name}")
TangemLogger.i("$apiConfigId environment set to: ${environment.name}")
}
}
}

View file

@ -3,7 +3,7 @@ package com.tangem.common.utils
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import java.util.concurrent.TimeUnit
/**
@ -13,7 +13,7 @@ fun getWcUri(
network: String = "ethereum",
baseUrl: String = "[REDACTED_ENV_URL]"
): String? {
Timber.i("Getting WC URI for network: $network")
TangemLogger.i("Getting WC URI for network: $network")
val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) // Таймаут подключения
@ -29,31 +29,31 @@ fun getWcUri(
return try {
client.newCall(request).execute().use { response ->
Timber.i("Response code: ${response.code}")
TangemLogger.i("Response code: ${response.code}")
if (response.isSuccessful) {
val body = response.body?.string() ?: ""
Timber.i("Response body: $body")
TangemLogger.i("Response body: $body")
val jsonObject = JSONObject(body)
if (jsonObject.getBoolean("success")) {
val wcUri = jsonObject.getString("wcUri")
Timber.i("Got WC URI successfully: $wcUri")
TangemLogger.i("Got WC URI successfully: $wcUri")
wcUri
} else {
Timber.e("API returned error: ${jsonObject.optString("error", "Unknown")}")
TangemLogger.e("API returned error: ${jsonObject.optString("error", "Unknown")}")
null
}
} else {
val errorBody = response.body?.string() ?: "No error body"
Timber.e("Request failed: ${response.code}, body: $errorBody")
TangemLogger.e("Request failed: ${response.code}, body: $errorBody")
null
}
}
} catch (e: Exception) {
Timber.e(e, "Error getting WC URI")
TangemLogger.e("Error getting WC URI", e)
null
}
}
@ -61,7 +61,7 @@ fun getWcUri(
fun checkServiceHealth(
baseUrl: String = "[REDACTED_ENV_URL]"
): String? {
Timber.i("Checking service health")
TangemLogger.i("Checking service health")
val client = OkHttpClient()
val request = Request.Builder()
@ -71,14 +71,14 @@ fun checkServiceHealth(
return try {
client.newCall(request).execute().use { response ->
Timber.i("Response code: ${response.code}")
TangemLogger.i("Response code: ${response.code}")
if (response.isSuccessful) {
val body = response.body?.string() ?: ""
Timber.i("Response body: $body")
TangemLogger.i("Response body: $body")
if (body.isEmpty()) {
Timber.e("Response body is empty")
TangemLogger.e("Response body is empty")
return null
}
@ -86,20 +86,20 @@ fun checkServiceHealth(
val status = jsonObject.optString("status", "")
if (status.isNotEmpty()) {
Timber.i("Got status successfully: $status")
TangemLogger.i("Got status successfully: $status")
status
} else {
Timber.e("Status field is missing or empty")
TangemLogger.e("Status field is missing or empty")
null
}
} else {
val errorBody = response.body?.string() ?: "No error body"
Timber.e("Request failed: ${response.code}, body: $errorBody")
TangemLogger.e("Request failed: ${response.code}, body: $errorBody")
null
}
}
} catch (e: Exception) {
Timber.e(e, "Error checking health")
TangemLogger.e("Error checking health", e)
null
}
}

View file

@ -4,7 +4,7 @@ import com.tangem.datasource.utils.WireMockRedirectInterceptor
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import java.io.IOException
private const val DEFAULT_WIREMOCK_URL = "[REDACTED_ENV_URL]"
@ -27,8 +27,8 @@ fun setWireMockScenarioState(
state: String,
baseUrl: String = getWireMockBaseUrl()
): Boolean {
Timber.i("=== WireMock Scenario Set ===")
Timber.i("Setting scenario '$scenarioName' to state: $state")
TangemLogger.i("=== WireMock Scenario Set ===")
TangemLogger.i("Setting scenario '$scenarioName' to state: $state")
val client = OkHttpClient()
val json = """{"state": "$state"}"""
val mediaType = "application/json".toMediaType()
@ -41,14 +41,14 @@ fun setWireMockScenarioState(
return try {
client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: ""
Timber.d("WireMock scenario request URL: ${request.url}")
Timber.d("WireMock scenario request body: $json")
Timber.d("WireMock scenario response: ${response.code} - ${response.message}")
Timber.d("WireMock scenario response body: $body")
TangemLogger.d("WireMock scenario request URL: ${request.url}")
TangemLogger.d("WireMock scenario request body: $json")
TangemLogger.d("WireMock scenario response: ${response.code} - ${response.message}")
TangemLogger.d("WireMock scenario response body: $body")
response.isSuccessful
}
} catch (e: IOException) {
Timber.e(e, "WireMock scenario error")
TangemLogger.e("WireMock scenario error", e)
false
}
}
@ -67,12 +67,12 @@ fun checkWireMockStatus(baseUrl: String = getWireMockBaseUrl()): Boolean {
return try {
client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: ""
Timber.d("WireMock status check: ${response.code}")
Timber.d("Available scenarios: $body")
TangemLogger.d("WireMock status check: ${response.code}")
TangemLogger.d("Available scenarios: $body")
response.isSuccessful
}
} catch (e: IOException) {
Timber.e(e, "WireMock not accessible")
TangemLogger.e("WireMock not accessible", e)
false
}
}
@ -82,12 +82,12 @@ fun checkWireMockStatus(baseUrl: String = getWireMockBaseUrl()): Boolean {
* @param baseUrl WireMock base URL (defaults to local override if set, otherwise remote)
*/
fun resetWireMockScenarios(baseUrl: String = getWireMockBaseUrl()): Boolean {
Timber.i("=== WireMock Scenarios Reset ===")
Timber.i("Base URL: $baseUrl")
TangemLogger.i("=== WireMock Scenarios Reset ===")
TangemLogger.i("Base URL: $baseUrl")
val client = OkHttpClient()
val url = "$baseUrl/__admin/scenarios/reset"
Timber.i("Request URL: $url")
TangemLogger.i("Request URL: $url")
val request = Request.Builder()
.url(url)
@ -95,19 +95,19 @@ fun resetWireMockScenarios(baseUrl: String = getWireMockBaseUrl()): Boolean {
.build()
return try {
Timber.d("Sending reset request...")
TangemLogger.d("Sending reset request...")
client.newCall(request).execute().use { response ->
Timber.d("Response code: ${response.code}")
Timber.d("Response message: ${response.message}")
TangemLogger.d("Response code: ${response.code}")
TangemLogger.d("Response message: ${response.message}")
val responseBody = response.body?.string() ?: ""
Timber.d("Response body: $responseBody")
TangemLogger.d("Response body: $responseBody")
val isSuccessful = response.isSuccessful
Timber.d("Is successful: $isSuccessful")
TangemLogger.d("Is successful: $isSuccessful")
isSuccessful
}
} catch (e: IOException) {
Timber.e(e, "Exception during reset")
TangemLogger.e("Exception during reset", e)
false
}
}
@ -124,7 +124,7 @@ fun resetWireMockScenarioState(
initialState: String = "Started",
baseUrl: String = getWireMockBaseUrl()
): Boolean {
Timber.i("=== WireMock Scenario Reset ===")
Timber.i("Resetting scenario '$scenarioName' to initial state: $initialState")
TangemLogger.i("=== WireMock Scenario Reset ===")
TangemLogger.i("Resetting scenario '$scenarioName' to initial state: $initialState")
return setWireMockScenarioState(scenarioName, initialState, baseUrl)
}

View file

@ -89,7 +89,7 @@ fun BaseTestCase.synchronizeAddresses(
fun BaseTestCase.openDeviceSettingsScreen() {
step("Open wallet details") {
waitForIdle()
onTopBar { moreButton.clickWithAssertion() }
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings' screen") {
onDetailsScreen { walletNameButton.performClick() }
@ -101,7 +101,7 @@ fun BaseTestCase.openDeviceSettingsScreen() {
fun BaseTestCase.openWalletConnectScreen() {
step("Click 'More' button on TopBar") {
onTopBar { moreButton.clickWithAssertion() }
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Click on 'Wallet Connect' button") {
onDetailsScreen { walletConnectButton.clickWithAssertion() }

View file

@ -7,10 +7,10 @@ import com.tangem.screens.AlreadyUsedWalletDialogPageObject.message
import com.tangem.screens.AlreadyUsedWalletDialogPageObject.requestSupportButton
import com.tangem.screens.AlreadyUsedWalletDialogPageObject.thisIsMyWalletButton
import com.tangem.screens.AlreadyUsedWalletDialogPageObject.title
import com.tangem.screens.ScanWarningDialogPageObject
import com.tangem.screens.onActionIsUnavailableDialog
import com.tangem.screens.onDataNotLoadedDialog
import com.tangem.screens.onFailedTransactionDialog
import com.tangem.screens.onScanWarningDialog
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.checkFailedTransactionDialog() {
@ -31,21 +31,21 @@ fun BaseTestCase.checkFailedTransactionDialog() {
}
}
fun checkScanWarningDialog() {
fun BaseTestCase.checkScanWarningDialog() {
step("Assert 'Scan warning' dialog title is displayed") {
ScanWarningDialogPageObject { warningTitle.isDisplayed() }
onScanWarningDialog { warningTitle.assertIsDisplayed() }
}
step("Assert warning dialog message is displayed") {
ScanWarningDialogPageObject { warningMessage.isDisplayed() }
onScanWarningDialog { warningMessage.assertIsDisplayed() }
}
step("Assert 'Cancel' button is displayed") {
ScanWarningDialogPageObject { cancelButton.isDisplayed() }
onScanWarningDialog { cancelButton.assertIsDisplayed() }
}
step("Assert 'How to scan' button is displayed") {
ScanWarningDialogPageObject { howToScanButton.isDisplayed() }
onScanWarningDialog { howToScanButton.assertIsDisplayed() }
}
step("Assert 'Request support' button is displayed") {
ScanWarningDialogPageObject { requestSupportButton.isDisplayed() }
onScanWarningDialog { requestSupportButton.assertIsDisplayed() }
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.AccountDetailsScreenTestTags
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
class AccountDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AccountDetailsPageObject>(semanticsProvider = semanticsProvider) {
val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
val manageTokensButton: KNode = child {
hasTestTag(AccountDetailsScreenTestTags.MANAGE_TOKENS_BUTTON)
}
}
internal fun BaseTestCase.onAccountDetails(function: AccountDetailsPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -3,6 +3,7 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.DetailsScreenTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
@ -10,10 +11,11 @@ import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DetailsPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(DetailsScreenTestTags.SCREEN_CONTAINER) }
) {
ComposeScreen<DetailsPageObject>(semanticsProvider = semanticsProvider) {
val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
val walletConnectButton: KNode = child {
hasTestTag(DetailsScreenTestTags.SCREEN_ITEM)

View file

@ -6,6 +6,7 @@ import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasAnyAncestor
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.getQuantityString
import com.tangem.common.extensions.hasLazyListItemPosition
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.test.*
@ -154,21 +155,27 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true
}
val seedPhraseNotificationIcon: KNode = child {
hasAnySibling(withText(getResourceString(R.string.warning_seedphrase_issue_title)))
val missingAddressNotificationIcon: KNode = child {
hasAnySibling(withText(getResourceString(R.string.warning_missing_derivation_title)))
hasTestTag(NotificationTestTags.ICON)
useUnmergedTree = true
}
val seedPhraseNotificationTitle: KNode = child {
val missingAddressNotificationTitle: KNode = child {
hasTestTag(NotificationTestTags.TITLE)
hasText(getResourceString(R.string.warning_seedphrase_issue_title))
hasText(getResourceString(R.string.warning_missing_derivation_title))
useUnmergedTree = true
}
val seedPhraseNotificationMessage: KNode = child {
fun missingAddressNotificationMessage(networkCount: Int): KNode = child {
hasTestTag(NotificationTestTags.MESSAGE)
hasText(getResourceString(R.string.warning_seedphrase_issue_message))
hasText(
getQuantityString(
R.plurals.warning_missing_derivation_message,
networkCount,
networkCount
)
)
useUnmergedTree = true
}
@ -207,6 +214,11 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasText(getResourceString(CoreUiR.string.wallet_notification_address_copied))
}
val organizeTokensButtonNode: KNode = child {
hasTestTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON)
useUnmergedTree = true
}
/**
* Find token list item with title and address
*/
@ -221,6 +233,17 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
}
}
@OptIn(ExperimentalTestApi::class)
fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode {
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)
}.child<KNode> {
hasTestTag(TokenElementsTestTags.TOKEN_CUSTOM_DERIVATION_ICON)
useUnmergedTree = true
}
}
@OptIn(ExperimentalTestApi::class)
fun organizeTokensButton(): KNode {
return lazyList.childWith<LazyListItemNode> {

View file

@ -7,16 +7,18 @@ 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
class TopBarPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TopBarPageObject>(
class MainScreenTopBarPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainScreenTopBarPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(MainScreenTestTags.TOP_BAR) }
) {
val moreButton: KNode = child {
hasTestTag(MainScreenTestTags.MORE_BUTTON)
hasPosition(1)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTopBar(function: TopBarPageObject.() -> Unit) =
internal fun BaseTestCase.onMainScreenTopBar(function: MainScreenTopBarPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,55 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.ManageTokensScreenTestTags
import com.tangem.core.ui.test.SwitchTestTags
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
import androidx.compose.ui.test.hasAnySibling as withAnySibling
import androidx.compose.ui.test.hasAnyDescendant as withAnyDescendant
import androidx.compose.ui.test.hasAnyAncestor as withAnyAncestor
class ManageTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ManageTokensPageObject>(semanticsProvider = semanticsProvider) {
val searchField: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
}
fun tokenItem(tokenName: String): KNode = child {
hasTestTag(ManageTokensScreenTestTags.TOKEN_ITEM)
hasText(tokenName)
}
fun networkSwitch(networkName: String): KNode = child {
useUnmergedTree = true
addSemanticsMatcher(
withTestTag(SwitchTestTags.SWITCH)
.and(
withAnyAncestor(
withAnySibling(
withTestTag(ManageTokensScreenTestTags.NETWORK_NAME)
.and(withAnyDescendant(withText(networkName)))
)
)
)
)
}
val saveButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_save))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onManageTokensScreen(function: ManageTokensPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -1,36 +1,42 @@
package com.tangem.screens
import com.kaspersky.kaspresso.screens.KScreen
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.tap.features.scanfails.ui.ScanFailsDialogTestTags
import com.tangem.wallet.R
import io.github.kakaocup.kakao.text.KTextView
import io.github.kakaocup.kakao.text.KButton
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
object ScanWarningDialogPageObject : KScreen<ScanWarningDialogPageObject>() {
class ScanWarningDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ScanWarningDialogPageObject>(semanticsProvider = semanticsProvider) {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
val warningTitle = KTextView {
withText(R.string.common_warning)
val warningTitle: KNode = child {
hasText(getResourceString(R.string.common_warning))
useUnmergedTree = true
}
val warningMessage = KTextView {
withText(R.string.alert_troubleshooting_scan_card_title)
val warningMessage: KNode = child {
hasText(getResourceString(R.string.alert_troubleshooting_scan_card_title))
useUnmergedTree = true
}
val tryAgainButton = KButton {
withId(R.id.try_again_button)
val howToScanButton: KNode = child {
hasTestTag(ScanFailsDialogTestTags.HOW_TO_SCAN_BUTTON)
useUnmergedTree = true
}
val howToScanButton = KButton {
withId(R.id.how_to_scan_button)
val requestSupportButton: KNode = child {
hasTestTag(ScanFailsDialogTestTags.REQUEST_SUPPORT_BUTTON)
useUnmergedTree = true
}
val requestSupportButton = KButton {
withId(R.id.request_support_button)
val cancelButton: KNode = child {
hasTestTag(ScanFailsDialogTestTags.CANCEL_BUTTON)
useUnmergedTree = true
}
}
val cancelButton = KButton {
withId(R.id.cancel_button)
}
}
internal fun BaseTestCase.onScanWarningDialog(function: ScanWarningDialogPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -165,10 +165,6 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasTestTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT)
}
val receiveFiatAmountWithPriceImpactWarning: KNode = child {
hasTestTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_WITH_PRICE_IMPACT_WARNING)
}
val receiveFiatAmountInformationIcon: KNode = child {
hasTestTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_INFORMATION_ICON)
useUnmergedTree = true

View file

@ -0,0 +1,42 @@
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.BaseButtonTestTags
import com.tangem.core.ui.test.BaseDialogTestTags
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
class UnableToHideDialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<UnableToHideDialogPageObject>(semanticsProvider = semanticsProvider) {
fun unableToHideTokenTitle(tokenName: String): KNode = child {
hasTestTag(BaseDialogTestTags.TITLE)
hasText(getResourceString(
R.string.token_details_unable_hide_alert_title,
tokenName))
}
fun unableToHideTokenMessage(tokenName: String, tokenSymbol: String, networkName: String): KNode = child {
hasTestTag(BaseDialogTestTags.TEXT)
hasText(
getResourceString(
R.string.token_details_unable_hide_alert_message,
tokenName,
tokenSymbol,
networkName
)
)
}
val okButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_ok))
}
}
internal fun BaseTestCase.onUnableToHideDialog(function: UnableToHideDialogPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -2,18 +2,22 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.core.ui.test.WalletSettingsScreenTestTags
import com.tangem.wallet.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.hasText as withText
class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<WalletSettingsPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(WalletSettingsScreenTestTags.SCREEN_CONTAINER) }
) {
ComposeScreen<WalletSettingsPageObject>(semanticsProvider = semanticsProvider) {
val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
private val walletSettingsItem: KNode = child {
hasTestTag(WalletSettingsScreenTestTags.SCREEN_ITEM)
}
@ -21,15 +25,24 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
val linkMoreCardsButton: KNode = walletSettingsItem.child {
hasText(getResourceString(R.string.details_row_title_create_backup))
}
val deviceSettingsButton: KNode = walletSettingsItem.child {
hasText(getResourceString(R.string.card_settings_title))
}
val referralProgramButton: KNode = walletSettingsItem.child {
hasText(getResourceString(R.string.details_referral_title))
}
val forgetWalletButton: KNode = walletSettingsItem.child {
hasText(getResourceString(R.string.settings_forget_wallet))
}
fun accountItem(accountName: String): KNode = walletSettingsItem.child {
hasTestTag(WalletSettingsScreenTestTags.USER_ACCOUNT_ITEM)
hasAnyDescendant(withText(accountName))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onWalletSettingsScreen(function: WalletSettingsPageObject.() -> Unit) =

View file

@ -19,7 +19,7 @@ class DetailsTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen()
}
onTopBar {
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
@ -66,7 +66,7 @@ class DetailsTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen(productType = ProductType.Wallet2)
}
onTopBar {
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
@ -116,7 +116,7 @@ class DetailsTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen(ProductType.Note)
}
onTopBar {
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
@ -163,7 +163,7 @@ class DetailsTest : BaseTestCase() {
openMainScreen()
}
step("Open wallet details") {
onTopBar { moreButton.clickWithAssertion() }
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings' screen") {
onDetailsScreen { walletNameButton.clickWithAssertion() }

View file

@ -7,13 +7,26 @@ import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.redux.StateDialog
import com.tangem.core.analytics.models.AnalyticsParam
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import com.tangem.scenarios.checkFailedTransactionDialog
import com.tangem.scenarios.checkScanWarningDialog
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.*
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.screens.ThirdPartyAppPageObject
import com.tangem.screens.onCreateWalletStartScreen
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onDisclaimerScreen
import com.tangem.screens.onFailedTransactionDialog
import com.tangem.screens.onMainScreen
import com.tangem.screens.onScanWarningDialog
import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendConfirmScreen
import com.tangem.screens.onSendScreen
import com.tangem.screens.onStoriesScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onMainScreenTopBar
import com.tangem.tap.domain.sdk.mocks.MockProvider
import com.tangem.tap.store
import dagger.hilt.android.testing.HiltAndroidTest
@ -44,7 +57,7 @@ class FeedbackTest : BaseTestCase() {
}
step("Click 'More' button on TopBar") {
waitForIdle()
onTopBar { moreButton.clickWithAssertion() }
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Click 'Contact support' button") {
waitForIdle()
@ -164,8 +177,10 @@ class FeedbackTest : BaseTestCase() {
}
step("Force show 'Scan warning' dialog"){
runOnUiThread {
val scanFailsState = StateDialog.ScanFailsDialog(source = StateDialog.ScanFailsSource.MAIN)
store.dispatch(GlobalAction.ShowDialog(scanFailsState))
val requester = store.state.daggerGraphState.scanFailsRequester!!
MainScope().launch {
requester.show(AnalyticsParam.ScreensSources.Main)
}
}
}
step("Check 'Scan warning' dialog") {
@ -173,7 +188,7 @@ class FeedbackTest : BaseTestCase() {
checkScanWarningDialog()
}
step("Click on 'Request support' button") {
ScanWarningDialogPageObject { requestSupportButton.click() }
onScanWarningDialog { requestSupportButton.performClick() }
}
step("Assert 'Gmail' app is open") {
ThirdPartyAppPageObject { assertElementWithTextExists(gmailText) }

View file

@ -1,20 +0,0 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.scenarios.openMainScreen
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Test
@HiltAndroidTest
class MainScreenTest : BaseTestCase() {
@Test
fun goToMain() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
}
}
}

View file

@ -14,7 +14,6 @@ import com.tangem.tap.domain.sdk.mocks.content.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -86,7 +85,6 @@ class ScanCardTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("870")
@DisplayName("Scan: Card with Ed25519 curve")
@Test

View file

@ -55,43 +55,4 @@ class WarningTest : BaseTestCase() {
}
}
}
@AllureId("227")
@DisplayName("Seed notify: check warning for wallet with seed phrase")
@Test
fun checkWarningForWalletWithSeedPhraseTest() {
val scenarioName = "seedphrase_notification"
val scenarioState = "Notified"
setupHooks(
additionalBeforeSection = {
step("Setup WireMock scenario '$scenarioName' for '$scenarioState' state") {
setWireMockScenarioState(scenarioName, scenarioState)
}
},
additionalAfterSection = {
step("Reset WireMock scenario '$scenarioName' state") {
resetWireMockScenarioState(scenarioName)
}
}
).run {
step("Open 'Main' screen") {
openMainScreen(mockContent = Wallet2WithSeedPhraseMockContent)
}
step("Assert 'Seed phrase' notification icon is displayed") {
onMainScreen { seedPhraseNotificationIcon.assertIsDisplayed() }
}
step("Assert 'Seed phrase' notification title is displayed") {
onMainScreen { seedPhraseNotificationTitle.assertIsDisplayed() }
}
step("Assert 'Seed phrase' notification message is displayed") {
onMainScreen { seedPhraseNotificationMessage.assertIsDisplayed() }
}
step("Assert notification 'Yes' button is displayed") {
onMainScreen { notificationYesButton.assertIsDisplayed() }
}
step("Assert notification 'No' button is displayed") {
onMainScreen { notificationNoButton.assertIsDisplayed() }
}
}
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.common.annotations.ApiEnv
import com.tangem.common.annotations.ApiEnvConfig
import com.tangem.common.constants.TestConstants.BITCOIN_ADDRESS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.*
import com.tangem.common.utils.assertClipboardTextEquals
import com.tangem.common.utils.clearClipboard
@ -599,22 +600,22 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Reset Wiremock scenario: '$scenarioName'") {
resetWireMockScenarioState(scenarioName)
}
step("Perform pull to refresh") {
pullToRefresh(steps = 10)
waitForIdle()
}
step("Assert action buttons is enabled") {
assertActionButtonsForMultiCurrencyWallet(isEnabled = true)
step("Pull to refresh and wait for buttons to become enabled") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG, intervalMs = 2_000) {
pullToRefresh(10)
waitForIdle()
assertActionButtonsForMultiCurrencyWallet(isEnabled = true)
}
}
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
}
step("Perform pull to refresh") {
pullToRefresh(steps = 10)
waitForIdle()
}
step("Assert action buttons is not enabled") {
assertActionButtonsForMultiCurrencyWallet(isEnabled = false)
step("Pull to refresh and wait for buttons to become disabled") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG, intervalMs = 2_000) {
pullToRefresh(10)
waitForIdle()
assertActionButtonsForMultiCurrencyWallet(isEnabled = false)
}
}
}
}

View file

@ -0,0 +1,312 @@
package com.tangem.tests.main
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
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 HideTokenTest : BaseTestCase() {
@AllureId("3638")
@DisplayName("Main: hide token by long tap")
@Test
fun hideTokenByLongTapTest() {
val tokenTitle = "Polygon"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Long click on token with name: '$tokenTitle'") {
waitForIdle()
onMainScreen {
tokenWithTitleAndAddress(tokenTitle).performTouchInput {
longClick(
position = center,
durationMillis = 1000L
)
}
}
}
step("Click on 'Hide token' button") {
onTokenActionsBottomSheet { hideTokenButton.performClick() }
}
step("Click 'Hide' button in dialog") {
onDialog {
dialogContainer.assertIsDisplayed()
okButton.clickWithAssertion()
}
}
step("Assert token: '$tokenTitle' is not displayed") {
onMainScreen { assertTokenDoesNotExist(tokenTitle) }
}
}
}
@AllureId("3627")
@DisplayName("Main: hide token via Manage tokens")
@Test
fun hideTokenViaManageTokensTest() {
val tokenTitle = "Tether"
val networkTitle = "ETHEREUM"
val scenarioState = "USDT"
val accountName = "Main account"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") {
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open wallet details") {
waitForIdle()
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings' screen") {
onDetailsScreen { walletNameButton.performClick() }
}
step("Click on account: '$accountName'") {
onWalletSettingsScreen { accountItem(accountName).performClick() }
}
step("Click on 'Manage tokens' button") {
onAccountDetails { manageTokensButton.performClick() }
}
step("Click on token: '$tokenTitle'") {
onManageTokensScreen { tokenItem(tokenTitle).performClick() }
}
step("Assert switch is on") {
onManageTokensScreen { networkSwitch(networkTitle).assertIsOn() }
}
step("Click on '$networkTitle' switch") {
onManageTokensScreen { networkSwitch(networkTitle).performClick() }
}
step("Click 'Hide' button in dialog") {
onDialog {
dialogContainer.assertIsDisplayed()
hideButton.clickWithAssertion()
}
}
step("Assert switch is off") {
onManageTokensScreen { networkSwitch(networkTitle).assertIsOff() }
}
step("Click on 'Save' button") {
onManageTokensScreen { saveButton.performClick() }
}
step("Click on 'Account details' screen 'Back' button") {
waitForIdle()
onAccountDetails { topAppBarBackButton.performClick() }
}
step("Click on 'Wallet settings' screen 'Back' button") {
waitForIdle()
onWalletSettingsScreen { topAppBarBackButton.performClick() }
}
step("Click on 'Details' screen 'Back' button") {
waitForIdle()
onDetailsScreen { topAppBarBackButton.performClick() }
}
step("Assert token: '$tokenTitle' is not displayed") {
onMainScreen { assertTokenDoesNotExist(tokenTitle) }
}
}
}
@AllureId("3626")
@DisplayName("Main: hide main coin via manage tokens")
@Test
fun hideMainCoinViaManageTokensTest() {
val tokenTitle = "POL (ex-MATIC)"
val networkTitle = "POLYGON"
val polygonTitle = "Polygon"
val accountName = "Main account"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open wallet details") {
waitForIdle()
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings' screen") {
onDetailsScreen { walletNameButton.performClick() }
}
step("Click on account: '$accountName'") {
onWalletSettingsScreen { accountItem(accountName).performClick() }
}
step("Click on 'Manage tokens' button") {
onAccountDetails { manageTokensButton.performClick() }
}
step("Click on token: '$tokenTitle'") {
onManageTokensScreen { tokenItem(tokenTitle).performClick() }
}
step("Assert switch is on") {
onManageTokensScreen { networkSwitch(networkTitle).assertIsOn() }
}
step("Click on '$networkTitle' switch") {
onManageTokensScreen { networkSwitch(networkTitle).performClick() }
}
step("Click 'Hide' button in dialog") {
onDialog {
dialogContainer.assertIsDisplayed()
hideButton.clickWithAssertion()
}
}
step("Assert switch is off") {
onManageTokensScreen { networkSwitch(networkTitle).assertIsOff() }
}
step("Click on 'Save' button") {
onManageTokensScreen { saveButton.performClick() }
}
step("Click on 'Account details' screen 'Back' button") {
waitForIdle()
onAccountDetails { topAppBarBackButton.performClick() }
}
step("Click on 'Wallet settings' screen 'Back' button") {
waitForIdle()
onWalletSettingsScreen { topAppBarBackButton.performClick() }
}
step("Click on 'Details' screen 'Back' button") {
waitForIdle()
onDetailsScreen { topAppBarBackButton.performClick() }
}
step("Assert token: '$polygonTitle' is not displayed") {
onMainScreen { assertTokenDoesNotExist(polygonTitle) }
}
}
}
@AllureId("3610")
@DisplayName("Main: check 'Unable to hide token' warning")
@Test
fun checkUnableToHideTokenWarningTest() {
val tokenTitle = "Ethereum"
val tokenSymbol = "ETH"
val scenarioState = "USDT"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") {
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Long click on token with name: '$tokenTitle'") {
waitForIdle()
onMainScreen {
tokenWithTitleAndAddress(tokenTitle).performTouchInput {
longClick(
position = center,
durationMillis = 1000L
)
}
}
}
step("Click on 'Hide token' button") {
onTokenActionsBottomSheet { hideTokenButton.performClick() }
}
step("Assert 'Unable to hide $tokenTitle' alert title is displayed") {
onUnableToHideDialog {
unableToHideTokenTitle(tokenName = tokenTitle).assertIsDisplayed()
}
}
step("Assert 'Unable to hide $tokenTitle' alert message is displayed") {
onUnableToHideDialog {
unableToHideTokenMessage(
tokenName = tokenTitle,
tokenSymbol = tokenSymbol,
networkName = tokenTitle
).assertIsDisplayed()
}
}
step("Click on 'Ok' button") {
onUnableToHideDialog {
okButton.performClick()
}
}
step("Press 'Back' button") {
device.uiDevice.pressBack()
}
step("Assert token: '$tokenTitle' is displayed") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).assertIsDisplayed() }
}
step("Click on token: '$tokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Assert 'Token details screen' open") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Click 'More' button") {
onTokenDetailsTopBar { moreButton.clickWithAssertion() }
}
step("Click 'Hide token' button") {
onPopUpMenu {
popUpContainer.assertIsDisplayed()
hideTokenButton.clickWithAssertion()
}
}
step("Assert 'Unable to hide $tokenSymbol' alert title is displayed") {
onUnableToHideDialog {
unableToHideTokenTitle(tokenName = tokenSymbol).assertIsDisplayed()
}
}
step("Assert 'Unable to hide $tokenTitle' alert message is displayed") {
onUnableToHideDialog {
unableToHideTokenMessage(
tokenName = tokenTitle,
tokenSymbol = tokenSymbol,
networkName = tokenTitle
).assertIsDisplayed()
}
}
step("Click on 'Ok' button") {
onUnableToHideDialog {
okButton.performClick()
}
}
step("Click 'Back' button") {
onTokenDetailsTopBar { backButton.clickWithAssertion() }
}
step("Assert token: '$tokenTitle' is displayed") {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).assertIsDisplayed() }
}
}
}
}

View file

@ -0,0 +1,114 @@
package com.tangem.tests.main
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
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 MainScreenTest : BaseTestCase() {
@AllureId("66")
@DisplayName("Main: check 'Organize tokens' button with multiple tokens no accounts")
@Test
fun checkOrganizeTokensButtonWithMultipleTokensNoAccountsTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Organize tokens' button is displayed") {
onMainScreen { organizeTokensButton().assertIsDisplayed() }
}
}
}
@AllureId("8748")
@DisplayName("Main: check 'Organize tokens' button with single token no accounts")
@Test
fun checkOrganizeTokensButtonWithSingleTokenNoAccountsTest() {
val scenarioState = "Cardano"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") {
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Organize tokens' button is not displayed") {
onMainScreen { organizeTokensButtonNode.assertIsNotDisplayed()}
}
}
}
@AllureId("8749")
@DisplayName("Main: check 'Organize tokens' button with single token two accounts")
@Test
fun checkOrganizeTokensButtonWithSingleTokenMultiAccountsTest() {
val scenarioState = "TwoAccountsSingleTokenEach"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") {
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Assert 'Organize tokens' button is not displayed") {
onMainScreen { organizeTokensButtonNode.assertIsNotDisplayed()}
}
}
}
@AllureId("8750")
@DisplayName("Main: check 'Organize tokens' button with multiple tokens two accounts")
@Test
fun checkOrganizeTokensButtonWithMultipleTokensMultiAccountsTest() {
val scenarioState = "TwoAccountsMixed"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") {
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Assert 'Organize tokens' button is not displayed") {
onMainScreen { organizeTokensButtonNode.assertIsDisplayed()}
}
}
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.tests.main
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
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 TokenListTest : BaseTestCase() {
@AllureId("180")
@DisplayName("Token list: hide token by long tap")
@Test
fun checkCustomDerivationIconOnTokenAndNetworkTest() {
val networkTitle = "Ethereum"
val customTokenTitle = "Myria"
val scenarioState = "CustomDerivation"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") {
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert token: '$networkTitle' is displayed") {
onMainScreen { tokenWithTitleAndAddress(networkTitle).assertIsDisplayed() }
}
step("Assert token with custom derivation icon: '$customTokenTitle' is displayed") {
onMainScreen { tokenWithCustomDerivationIcon(customTokenTitle).assertIsDisplayed() }
}
}
}
}

View file

@ -0,0 +1,52 @@
package com.tangem.tests.main
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
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 WarningsTest : BaseTestCase() {
@AllureId("184")
@DisplayName("Token list: hide token by long tap")
@Test
fun checkUnavailableNetworksWarningTest() {
val scenarioState = "MissingDerivation"
val networkCount = 1
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") {
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, scenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses(isBalanceAvailable = false)
}
step("Assert 'Missing addresses' notification icon is displayed") {
onMainScreen { missingAddressNotificationIcon.assertIsDisplayed() }
}
step("Assert 'Missing addresses' notification title is displayed") {
onMainScreen { missingAddressNotificationTitle.assertIsDisplayed() }
}
step("Assert 'Missing addresses' notification message is displayed") {
onMainScreen { missingAddressNotificationMessage(networkCount).assertIsDisplayed() }
}
}
}
}

View file

@ -104,6 +104,7 @@ class RecentBlockTest : BaseTestCase() {
@Test
fun recentBlockTransactionHistoryDoesNotSupportedTest() {
val tokenName = "Polkadot"
val fullTokenName = "Polkadot Asset Hub"
val sendAmount = "1"
setupHooks(
@ -113,7 +114,7 @@ class RecentBlockTest : BaseTestCase() {
}
).run {
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
openSendScreen(tokenName = fullTokenName, mockState = tokenName)
}
step("Type '$sendAmount' in input text field") {
onSendScreen {

View file

@ -27,6 +27,7 @@ import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -98,6 +99,7 @@ class SendAddressScreenTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4543")
@DisplayName("Send (address screen): check address field")
@Test

View file

@ -23,7 +23,7 @@ class SendAmountScreenTest : BaseTestCase() {
val manualSendAmount = "1"
val clipboardSendAmount = "0.5"
val invalidAmount = "2"
val errorText = getResourceString(R.string.send_validation_amount_exceeds_balance)
val errorText = getResourceString(R.string.common_insufficient_balance)
val context = device.context
setupHooks().run {

View file

@ -17,6 +17,7 @@ import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -152,7 +153,11 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) }
}
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}
step("Click on 'Next' button") {
waitForIdle()
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert primary amount = '$tokenAmount'") {
@ -237,11 +242,13 @@ class SendConfirmScreenTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("554")
@DisplayName("Send (Confirm screen): check fee warning")
@Test
fun checkFeeWarningTest() {
val tokenName = "Polkadot"
val fullTokenName = "Polkadot Asset Hub"
val tokenAmount = "0.1"
val warningTitle = getResourceString(R.string.send_fee_unreachable_error_title)
val warningMessageResId = R.string.send_fee_unreachable_error_text
@ -256,7 +263,7 @@ class SendConfirmScreenTest : BaseTestCase() {
}
).run {
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
openSendScreen(tokenName = fullTokenName, mockState = tokenName)
}
step("Type '$tokenAmount' in input text field") {
onSendScreen {
@ -274,7 +281,11 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) }
}
step("Assert 'Addresses shimmer' is not displayed") {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}
step("Click on 'Next' button") {
waitForIdle()
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Network fee info unreachable' warning title is displayed") {

View file

@ -7,6 +7,7 @@ import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.TERRA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
@ -68,6 +69,7 @@ class SendFeeScreenTest : BaseTestCase() {
@Test
fun checkFeeBlockForFixedFeeTest() {
val tokenName = "Polkadot"
val fullTokenName = "Polkadot Asset Hub"
val tokenAmount = "1"
val feeAmount = "$0.05"
@ -78,7 +80,7 @@ class SendFeeScreenTest : BaseTestCase() {
}
).run {
step("Open 'Send' screen") {
openSendScreen(tokenName)
openSendScreen(tokenName = fullTokenName, mockState = tokenName)
}
step("Type '$tokenAmount' in input text field") {
onSendScreen {
@ -93,10 +95,13 @@ class SendFeeScreenTest : BaseTestCase() {
onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
waitForIdle()
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed without fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = false)
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = false)
}
}
step("Click on 'Fee selector' block") {
onSendConfirmScreen { feeSelectorBlock.performClick() }
@ -161,7 +166,7 @@ class SendFeeScreenTest : BaseTestCase() {
onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed with fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = true)

View file

@ -21,6 +21,7 @@ import org.junit.Test
@HiltAndroidTest
class PolkadotWarningsTest : BaseTestCase() {
private val tokenName = "Polkadot"
private val fullTokenName = "Polkadot Asset Hub"
private val amountToLeaveLessThanDeposit = "1.299"
private val amountToLeaveGreaterThanDeposit = "0.2"
private val depositAmount = "DOT 0.01"
@ -41,7 +42,7 @@ class PolkadotWarningsTest : BaseTestCase() {
}
).run {
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
openSendScreen(tokenName = fullTokenName, mockState = tokenName)
}
step("Type '$amountToLeaveLessThanDeposit' in input text field") {
onSendScreen {

View file

@ -17,7 +17,6 @@ import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
@ -33,7 +32,6 @@ class StellarWarningsTest : BaseTestCase() {
getResourceString(R.string.send_notification_invalid_reserve_amount_title, reserveAmount)
private val warningMessage = getResourceString(R.string.send_notification_invalid_reserve_amount_text)
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4287")
@DisplayName("Warnings: check warning, when sending less than reserve")
@Test
@ -87,7 +85,6 @@ class StellarWarningsTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4286")
@DisplayName("Warnings: check warning when sending amount equal to reserve")
@Test
@ -142,7 +139,6 @@ class StellarWarningsTest : BaseTestCase() {
}
}
@Ignore("TODO: [REDACTED_JIRA]")
@AllureId("4288")
@DisplayName("Warnings: check warning when sending greater than reserve")
@Test

View file

@ -16,11 +16,13 @@ import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
class SearchAndSwapTest : BaseTestCase() {
@Ignore("ToDo: [REDACTED_JIRA]")
@AllureId("8520")
@DisplayName("Search and Swap: add token without derivation")
@Test
@ -68,6 +70,7 @@ class SearchAndSwapTest : BaseTestCase() {
}
}
@Ignore("ToDo: [REDACTED_JIRA]")
@AllureId("8519")
@DisplayName("Search and Swap: add token with derivation")
@Test
@ -115,6 +118,7 @@ class SearchAndSwapTest : BaseTestCase() {
}
}
@Ignore("ToDo: [REDACTED_JIRA]")
@AllureId("8523")
@DisplayName("Search and Swap: Markets error")
@Test
@ -155,6 +159,7 @@ class SearchAndSwapTest : BaseTestCase() {
}
}
@Ignore("ToDo: [REDACTED_JIRA]")
@AllureId("8522")
@DisplayName("Search and Swap: check 'Unsupported token pair' warning")
@Test
@ -205,6 +210,7 @@ class SearchAndSwapTest : BaseTestCase() {
}
}
@Ignore("ToDo: [REDACTED_JIRA]")
@AllureId("8521")
@DisplayName("Swap: search token on Swap token screen")
@Test

View file

@ -206,7 +206,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen {
waitForIdle()
receiveFiatAmountWithPriceImpactWarning.assertTextContains("%", substring = true)
receiveFiatAmount.assertTextContains("%", substring = true)
}
}
}
@ -288,7 +288,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
}
}
step("Assert fiat amount with warning is displayed") {
onSwapTokenScreen { receiveFiatAmountWithPriceImpactWarning.assertTextContains("%", substring = true) }
onSwapTokenScreen { receiveFiatAmount.assertTextContains("%", substring = true) }
}
step("Assert receive amount information icon is displayed") {
onSwapTokenScreen { receiveFiatAmountInformationIcon.assertIsDisplayed() }

View file

@ -1,9 +1,9 @@
package com.tangem.tap
import com.google.firebase.messaging.FirebaseMessaging
import com.tangem.utils.logging.TangemLogger
import com.tangem.utils.notifications.PushNotificationsTokenProvider
import kotlinx.coroutines.tasks.await
import timber.log.Timber
import javax.inject.Inject
internal class FirebasePushNotificationsTokenProvider @Inject constructor() : PushNotificationsTokenProvider {
@ -11,7 +11,7 @@ internal class FirebasePushNotificationsTokenProvider @Inject constructor() : Pu
return try {
FirebaseMessaging.getInstance().token.await()
} catch (ex: Exception) {
Timber.e(ex)
TangemLogger.e("Error", ex)
""
}
}

View file

@ -5,7 +5,7 @@ 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.tangem.utils.logging.TangemLogger
import com.google.android.play.core.review.ReviewManager as GReviewManager
/**
@ -26,7 +26,7 @@ internal class GoogleReviewManager : ReviewManager {
onDismissClick = onDismissClick,
)
}
.addOnFailureListener(Timber::e)
.addOnFailureListener { TangemLogger.e("Error", it) }
}
}
@ -42,9 +42,9 @@ internal class GoogleReviewManager : ReviewManager {
.addOnCompleteListener { resultReviewTask ->
if (!resultReviewTask.isSuccessful) onDismissClick()
}
.addOnFailureListener(Timber::e)
.addOnFailureListener { TangemLogger.e("Error", it) }
} else {
Timber.e(task.exception)
TangemLogger.e("Error", task.exception)
}
}
}

View file

@ -11,7 +11,7 @@ import com.tangem.utils.notifications.PushNotificationsTokenProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
internal class HuaweiPushNotificationsTokenProvider @Inject constructor(
@ -25,7 +25,7 @@ internal class HuaweiPushNotificationsTokenProvider @Inject constructor(
try {
FirebaseMessaging.getInstance().token.await()
} catch (ex: Exception) {
Timber.e(ex)
TangemLogger.e("Error", ex)
""
}
} else {
@ -33,10 +33,10 @@ internal class HuaweiPushNotificationsTokenProvider @Inject constructor(
try {
val appId = AGConnectOptionsBuilder().build(context).getString(APP_ID_KEY)
val token = HmsInstanceId.getInstance(context).getToken(appId, TOKEN_REQUEST_MODE)
Timber.i("Requested token from HuaweiService: $token")
TangemLogger.i("Requested token from HuaweiService: $token")
token
} catch (e: ApiException) {
Timber.i("Fetching token from HuaweiService failed cause: ${e.message}")
TangemLogger.i("Fetching token from HuaweiService failed cause: ${e.message}")
""
}
}

View file

@ -5,7 +5,7 @@ import com.huawei.hms.push.HmsMessageService
import com.huawei.hms.push.RemoteMessage
import com.tangem.google.GoogleServicesHelper
import com.tangem.tap.common.pushes.PushNotificationDelegate
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
class HuaweiPushService : HmsMessageService() {
@ -15,12 +15,12 @@ class HuaweiPushService : HmsMessageService() {
override fun onNewToken(token: String?, bundle: Bundle?) {
super.onNewToken(token, bundle)
Timber.i("HuaweiPushService: On new token from HuaweiService: $token")
TangemLogger.i("HuaweiPushService: On new token from HuaweiService: $token")
}
override fun onTokenError(e: Exception?, bundle: Bundle?) {
super.onTokenError(e, bundle)
Timber.i("HuaweiPushService: Fetching token from HuaweiService failed cause: ${e?.message}")
TangemLogger.i("HuaweiPushService: Fetching token from HuaweiService failed cause: ${e?.message}")
}
override fun onMessageReceived(message: RemoteMessage?) {

@ -1 +1 @@
Subproject commit 009cf6332a72cf0893167221abf7010d033906c2
Subproject commit 4bfa9f04b8c4e81e31f9a924aeb45a1b024e31ce

View file

@ -28,6 +28,7 @@ import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
@ -151,4 +152,6 @@ interface ApplicationEntryPoint {
fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory
fun getCustomerIoFeatureToggles(): CustomerIoFeatureToggles
fun getScanFailsRequester(): ScanFailsRequester
}

View file

@ -4,7 +4,7 @@ import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import kotlin.reflect.KClass
object ForegroundActivityObserver {
@ -14,7 +14,7 @@ object ForegroundActivityObserver {
val foregroundActivity: AppCompatActivity?
get() = activities.entries
.firstOrNull { entry ->
Timber.i("foregroundActivity: ${entry.key} | ${entry.value.isDestroyed}")
TangemLogger.i("foregroundActivity: ${entry.key} | ${entry.value.isDestroyed}")
entry.value.isDestroyed == false
}
?.value
@ -27,15 +27,15 @@ object ForegroundActivityObserver {
}
override fun onActivityResumed(activity: Activity) {
Timber.i("onActivityResumed ${activity::class}")
TangemLogger.i("onActivityResumed ${activity::class}")
if (activity is AppCompatActivity) {
Timber.i("onActivityResumed store activity")
TangemLogger.i("onActivityResumed store activity")
activities[activity::class] = activity
}
}
override fun onActivityDestroyed(activity: Activity) {
Timber.i("onActivityDestroyed")
TangemLogger.i("onActivityDestroyed")
activities.remove(activity::class)
}

View file

@ -6,9 +6,9 @@ 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.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import timber.log.Timber
@HiltWorker
class LockTimerWorker @AssistedInject constructor(
@ -19,11 +19,11 @@ class LockTimerWorker @AssistedInject constructor(
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
Timber.i("onStart job")
TangemLogger.i("onStart job")
userWalletsListRepository.lockAllWallets().onRight {
settingsRepository.setShouldOpenWelcomeScreenOnResume(value = true)
}
Timber.i("onStart job complete")
TangemLogger.i("onStart job complete")
return Result.success()
}

View file

@ -13,11 +13,11 @@ import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase
import com.tangem.tap.LockTimerWorker.Companion.TAG
import com.tangem.tap.common.extensions.dispatchNavigationAction
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
@ -47,7 +47,7 @@ internal class LockUserWalletsTimer(
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
coroutineScope.launch {
val shouldOpenWelcomeScreenOnResume = settingsRepository.shouldOpenWelcomeScreenOnResume()
Timber.i(
TangemLogger.i(
"""
Owner resumed
|- Need to open welcome screen: $shouldOpenWelcomeScreenOnResume
@ -68,7 +68,7 @@ internal class LockUserWalletsTimer(
}
override fun onStop(owner: LifecycleOwner) {
Timber.i("Owner stopped")
TangemLogger.i("Owner stopped")
delayJob = null
startTimerWorker()
@ -76,7 +76,7 @@ internal class LockUserWalletsTimer(
fun restart() {
if (delayJob == null) return
Timber.i(
TangemLogger.i(
"""
Timer restart
|- Duration millis: ${duration.inWholeMilliseconds}
@ -96,7 +96,7 @@ internal class LockUserWalletsTimer(
private fun start(log: Boolean = true) {
if (log) {
Timber.i(
TangemLogger.i(
"""
Timer start
|- Duration millis: ${duration.inWholeMilliseconds}

View file

@ -54,7 +54,6 @@ import com.tangem.operations.backup.BackupService
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.common.ActivityResultCallbackHolder
import com.tangem.tap.common.DialogManager
import com.tangem.tap.common.OnActivityResultCallback
import com.tangem.tap.common.analytics.events.Push
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
@ -67,11 +66,11 @@ import com.tangem.tap.routing.utils.DeepLinkFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler
import com.tangem.utils.extensions.uriValidate
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.BuildConfig
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlin.time.Duration.Companion.seconds
@ -171,16 +170,15 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
private lateinit var appThemeModeFlow: SharedFlow<AppThemeMode>
private val dialogManager = DialogManager()
private val onActivityResultCallbacks = mutableListOf<OnActivityResultCallback>()
override fun onCreate(savedInstanceState: Bundle?) {
Timber.i("onCreate")
TangemLogger.i("onCreate")
// We need to call it before onCreate to prevent unnecessary activity recreation
installAppTheme()
val splashScreen = installSplashScreen()
TangemLogger.i("Splash screen installed")
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.auto(
@ -322,18 +320,16 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
override fun onStart() {
super.onStart()
Timber.i("onStart")
dialogManager.onStart(this)
TangemLogger.i("onStart")
}
override fun onStop() {
dialogManager.onStop()
super.onStop()
Timber.i("onStop")
TangemLogger.i("onStop")
}
override fun onDestroy() {
Timber.i("onDestroy")
TangemLogger.i("onDestroy")
// workaround: kill process when activity destroy to avoid state when lock() wallets
// and navigation to unlock screen was skipped because system kills activity but not process
if (BuildConfig.BUILD_TYPE != MOCKED_BUILD_TYPE) {
@ -366,6 +362,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
TangemLogger.i("onNewIntent: data=${intent.data}, extras=${intent.extras?.keySet()}")
val isFromPush = intent.extras?.containsKey(OPENED_FROM_GCM_PUSH) == true
if (isFromPush) {
@ -432,8 +429,8 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
private fun sendStakingUnsubmittedHashes() {
lifecycleScope.launch {
sendUnsubmittedHashesUseCase.invoke()
.onLeft { Timber.e(it.toString()) }
.onRight { Timber.d("Submitting hashes succeeded") }
.onLeft { TangemLogger.e(it.toString()) }
.onRight { TangemLogger.d("Submitting hashes succeeded") }
}
}

View file

@ -75,13 +75,13 @@ import com.tangem.tap.common.redux.appReducer
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.BuildConfig
import dagger.hilt.EntryPoints
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import org.rekotlin.Store
import timber.log.Timber
lateinit var store: Store<AppState>
@ -239,6 +239,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val customerIoFeatureToggles: CustomerIoFeatureToggles
get() = entryPoint.getCustomerIoFeatureToggles()
private val scanFailsRequester
get() = entryPoint.getScanFailsRequester()
// endregion
private val appScope = MainScope()
@ -270,22 +273,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
}
}
private fun updateLogFiles() {
appLogsStore.deleteOldLogsFile()
if (!BuildConfig.TESTER_MENU_ENABLED) {
appLogsStore.deleteLastLogFile()
}
// Temporally logs are not saved
// scope.launch {
// if (!appPreferencesStore.getSyncOrDefault(WAS_LOG_FILE_CLEARED, false)) {
// appLogsStore.deleteLastLogFile()
// appPreferencesStore.store(WAS_LOG_FILE_CLEARED, true)
// }
// }
}
/**
* Initialize components that need to be initialized before [super.onCreate] is called
*/
@ -299,10 +286,10 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
store = createReduxStore()
Timber.i("APP STARTED")
TangemLogger.i("APP STARTED")
if (BuildConfig.TESTER_MENU_ENABLED) {
Timber.i(featureTogglesManager.toString())
Timber.i(excludedBlockchainsManager.toString())
TangemLogger.i(featureTogglesManager.toString())
TangemLogger.i(excludedBlockchainsManager.toString())
}
initWithConfigDependency(environmentConfig = environmentConfig)
@ -384,11 +371,28 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
userWalletsListRepository = userWalletsListRepository,
tangemHotSdk = tangemHotSdk,
trackingContextProxy = trackingContextProxy,
scanFailsRequester = scanFailsRequester,
),
),
)
}
private fun updateLogFiles() {
appLogsStore.deleteOldLogsFile()
if (!BuildConfig.TESTER_MENU_ENABLED) {
appLogsStore.deleteLastLogFile()
}
// Temporarily logs are not saved
// scope.launch {
// if (!appPreferencesStore.getSyncOrDefault(WAS_LOG_FILE_CLEARED, false)) {
// appLogsStore.deleteLastLogFile()
// appPreferencesStore.store(WAS_LOG_FILE_CLEARED, true)
// }
// }
}
override fun newImageLoader(): ImageLoader {
return createCoilImageLoader(
context = this,

View file

@ -7,7 +7,7 @@ import androidx.lifecycle.LifecycleOwner
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.TechAnalyticsEvent
import com.tangem.core.analytics.models.event.TechAnalyticsEvent.WindowObscured.ObscuredState
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
internal object WindowObscurationObserver : DefaultLifecycleObserver {
@ -36,7 +36,7 @@ internal object WindowObscurationObserver : DefaultLifecycleObserver {
}
if (isPartiallyObscured) {
Timber.d("Window is partially obscured")
TangemLogger.d("Window is partially obscured")
if (!isWindowPartiallyObscuredAlreadySent) {
analyticsEventHandler.send(
@ -50,7 +50,7 @@ internal object WindowObscurationObserver : DefaultLifecycleObserver {
val isFullyObscured = event.flags and MotionEvent.FLAG_WINDOW_IS_OBSCURED != 0
if (isFullyObscured) {
Timber.d("Window is partially or fully obscured")
TangemLogger.d("Window is partially or fully obscured")
if (!isWindowFullyObscuredAlreadySent) {
analyticsEventHandler.send(

View file

@ -1,98 +0,0 @@
package com.tangem.tap.common
import android.app.Dialog
import android.content.Context
import com.tangem.domain.redux.StateDialog
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.common.redux.global.GlobalState
import com.tangem.tap.common.ui.ScanFailsDialog
import com.tangem.tap.common.ui.SimpleAlertDialog
import com.tangem.tap.common.ui.SimpleCancelableAlertDialog
import com.tangem.tap.common.ui.SimpleOkDialog
import com.tangem.tap.features.onboarding.OnboardingDialog
import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog
import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.ConfirmDiscardingBackupDialog
import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.UnfinishedBackupFoundDialog
import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.WalletActivationErrorDialog
import com.tangem.tap.features.onboarding.products.wallet.ui.dialogs.WalletAlreadyWasUsedDialog
import com.tangem.tap.store
import com.tangem.wallet.R
import org.rekotlin.StoreSubscriber
class DialogManager : StoreSubscriber<GlobalState> {
var context: Context? = null
private var dialog: Dialog? = null
fun onStart(context: Context) {
this.context = context
store.subscribe(this) { state ->
state.skipRepeats { oldState, newState ->
oldState.globalState == newState.globalState
}.select { it.globalState }
}
}
fun onStop() {
this.context = null
store.unsubscribe(this)
}
@Suppress("LongMethod", "ComplexMethod")
override fun newState(state: GlobalState) {
if (state.dialog == null) {
dialog?.dismiss()
dialog = null
return
}
val context = context ?: return
if (dialog != null) return
dialog = when (state.dialog) {
is AppDialog.SimpleOkDialogRes -> SimpleOkDialog.create(state.dialog, context)
is StateDialog.ScanFailsDialog -> ScanFailsDialog.create(
context = context,
source = state.dialog.source,
onTryAgain = state.dialog.onTryAgain,
)
is StateDialog.NfcFeatureIsUnavailable -> SimpleAlertDialog.create(
titleRes = R.string.common_error,
messageRes = R.string.nfc_error_unavailable,
context = context,
)
is OnboardingDialog.WalletActivationError -> WalletActivationErrorDialog.create(context, state.dialog)
is BackupDialog.UnfinishedBackupFound -> UnfinishedBackupFoundDialog.create(
context = context,
scanResponse = state.dialog.scanResponse,
)
is BackupDialog.ConfirmDiscardingBackup -> ConfirmDiscardingBackupDialog.create(
context = context,
unfinishedBackupScanResponse = state.dialog.scanResponse,
)
is AppDialog.TokensAreLinkedDialog -> SimpleAlertDialog.create(
title = context.getString(state.dialog.titleRes, state.dialog.currencySymbol),
message = context.getString(
state.dialog.messageRes,
state.dialog.currencyTitle,
state.dialog.currencySymbol,
state.dialog.networkName,
),
context = context,
)
is AppDialog.WalletAlreadyWasUsedDialog -> WalletAlreadyWasUsedDialog.create(
context = context,
onOk = state.dialog.onOk,
onSupport = state.dialog.onSupportClick,
onCancel = state.dialog.onCancel,
)
is AppDialog.RemoveWalletDialog -> SimpleCancelableAlertDialog.create(
title = context.getString(state.dialog.titleRes, state.dialog.currencyTitle),
messageRes = state.dialog.messageRes,
context = context,
primaryButtonRes = state.dialog.primaryButtonRes,
primaryButtonAction = state.dialog.onOk,
)
else -> null
}
dialog?.show()
}
}

View file

@ -1,16 +0,0 @@
package com.tangem.tap.common
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
private val uiHandler = Handler(Looper.getMainLooper())
private val backgroundHandler = Handler(HandlerThread("AppMainHandlerThread").apply { start() }.looper)
fun postUi(ms: Long = 0, func: Runnable) {
if (ms == 0L) uiHandler.post { func.run() } else uiHandler.postDelayed(func, ms)
}
fun postUiDelayBg(ms: Long, func: Runnable) {
backgroundHandler.postDelayed({ uiHandler.post(func) }, ms)
}

View file

@ -1,9 +1,9 @@
package com.tangem.tap.common.analytics
import com.tangem.common.json.MoshiJsonConverter
import com.tangem.core.analytics.api.ExceptionLogger
import com.tangem.core.analytics.api.EventLogger
import timber.log.Timber
import com.tangem.core.analytics.api.ExceptionLogger
import com.tangem.utils.logging.TangemLogger
class AnalyticsEventsLogger(
private val name: String,
@ -11,11 +11,11 @@ class AnalyticsEventsLogger(
) : EventLogger, ExceptionLogger {
override fun logEvent(event: String, params: Map<String, String>) {
Timber.d(jsonConverter.prettyPrint(PrintEventModel(name, event, params)))
TangemLogger.d(jsonConverter.prettyPrint(PrintEventModel(name, event, params)))
}
override fun logException(error: Throwable, params: Map<String, String>) {
Timber.e(error, jsonConverter.prettyPrint(PrintEventModel(name, "error", params)))
TangemLogger.e(jsonConverter.prettyPrint(PrintEventModel(name, "error", params)), error)
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLinkListener
import com.appsflyer.deeplink.DeepLinkResult
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
import javax.inject.Singleton
@ -17,10 +17,10 @@ class AppsFlyerDeepLinkListener @Inject constructor(
referralParamsHandler.handle(deepLink = p0.deepLink)
}
DeepLinkResult.Status.NOT_FOUND -> {
Timber.i("No deep link found")
TangemLogger.i("No deep link found")
}
DeepLinkResult.Status.ERROR -> {
Timber.e("Deep link error: ${p0.error}")
TangemLogger.e("Deep link error: ${p0.error}")
}
}
}

View file

@ -2,13 +2,13 @@ package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.contracts.ExperimentalContracts
@ -41,15 +41,15 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) {
if (deepLinkValue != REFERRAL_DEEP_LINK_VALUE) {
Timber.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}")
TangemLogger.i("Ignoring deep link with value: ${deepLinkValue ?: "null"}")
return
}
@Suppress("NullableToStringCall")
Timber.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2")
TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2")
if (!isValidParam(deepLinkSub1)) {
Timber.e("Deeplink conversion data is invalid")
TangemLogger.e("Deeplink conversion data is invalid")
return
}
@ -69,7 +69,7 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
coroutineScope.launch {
mutex.withLock {
setShouldShowMobileWalletPromoUseCase(true)
.onLeft { Timber.e(it) }
.onLeft { TangemLogger.e("Error", it) }
appsFlyerStore.storeIfAbsent(
value = AppsFlyerConversionData(refcode = refcode, campaign = campaign),
)

View file

@ -1,7 +1,7 @@
package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.AppsFlyerConversionListener
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
import javax.inject.Singleton
@ -11,7 +11,7 @@ class TangemAFConversionListener @Inject constructor(
) : AppsFlyerConversionListener {
override fun onConversionDataSuccess(p0: Map<String?, Any?>?) {
Timber.i("AppsFlyer conversion data success: ${p0.orEmpty()}")
TangemLogger.i("AppsFlyer conversion data success: ${p0.orEmpty()}")
if (p0 == null) return
@ -19,14 +19,14 @@ class TangemAFConversionListener @Inject constructor(
}
override fun onConversionDataFail(p0: String?) {
Timber.e("AppsFlyer conversion data failure: ${p0.orEmpty()}")
TangemLogger.e("AppsFlyer conversion data failure: ${p0.orEmpty()}")
}
override fun onAppOpenAttribution(p0: Map<String?, String?>?) {
Timber.i("AppsFlyer app open attribution: ${p0.orEmpty()}")
TangemLogger.i("AppsFlyer app open attribution: ${p0.orEmpty()}")
}
override fun onAttributionFailure(p0: String?) {
Timber.e("AppsFlyer attribution failure: ${p0.orEmpty()}")
TangemLogger.e("AppsFlyer attribution failure: ${p0.orEmpty()}")
}
}

View file

@ -11,7 +11,7 @@ class ScanFailsDialogAnalytics(button: Buttons, source: AnalyticsParam.ScreensSo
),
) {
enum class Buttons(val event: String) {
TRY_AGAIN("Try again button"),
HOW_TO_SCAN("Button blog"),
TRY_AGAIN("Button try again"),
}
}

View file

@ -6,16 +6,16 @@ import com.appsflyer.attribution.AppsFlyerRequestListener
import com.tangem.core.analytics.api.EventLogger
import com.tangem.core.analytics.api.UserIdHolder
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerDeepLinkListener
import com.tangem.tap.common.analytics.appsflyer.TangemAFConversionListener
import com.tangem.tap.common.analytics.handlers.firebase.UnderscoreAnalyticsEventConverter
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
import timber.log.Timber
interface AppsFlyerAnalyticsClient : EventLogger, UserIdHolder
@ -40,9 +40,9 @@ class AppsFlyerClient @AssistedInject constructor(
init(apiKey, tangemAFConversionListener, context)
Timber.i("Starting AppsFlyer SDK")
TangemLogger.i("Starting AppsFlyer SDK")
start(context, apiKey, InitializationListener)
Timber.i("AppsFlyer SDK started")
TangemLogger.i("AppsFlyer SDK started")
saveUID()
}
@ -57,7 +57,7 @@ class AppsFlyerClient @AssistedInject constructor(
}
override fun logEvent(event: String, params: Map<String, String>) {
Timber.tag("AppsFlyer").i("Logging event: $event with params: $params")
TangemLogger.withTag("AppsFlyer").i("Logging event: $event with params: $params")
appsFlyerLib.logEvent(
context,
event,
@ -78,21 +78,21 @@ class AppsFlyerClient @AssistedInject constructor(
private object InitializationListener : AppsFlyerRequestListener {
override fun onSuccess() {
Timber.d("AppsFlyer initialized successfully")
TangemLogger.d("AppsFlyer initialized successfully")
}
override fun onError(p0: Int, p1: String) {
Timber.e("AppsFlyer initialization error: $p0, $p1")
TangemLogger.e("AppsFlyer initialization error: $p0, $p1")
}
}
private object LogEventListener : AppsFlyerRequestListener {
override fun onSuccess() {
Timber.tag("AppsFlyerClient").i("AppsFlyerRequestListener send")
TangemLogger.withTag("AppsFlyerClient").i("AppsFlyerRequestListener send")
}
override fun onError(p0: Int, p1: String) {
Timber.tag("AppsFlyerClient").e("AppsFlyerRequestListener onError: $p0, $p1")
TangemLogger.withTag("AppsFlyerClient").e("AppsFlyerRequestListener onError: $p0, $p1")
}
}

View file

@ -6,14 +6,14 @@ import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.AppsFlyerOnlyEvent
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
class AppsFlyerAnalyticsHandler(
private val client: AppsFlyerAnalyticsClient,
) : AnalyticsHandler, AnalyticsUserIdHandler {
init {
Timber.tag("AppsFlyer").i("AppsFlyer Analytics Handler created")
TangemLogger.withTag("AppsFlyer").i("AppsFlyer Analytics Handler created")
}
override fun id(): String = ID
@ -21,11 +21,15 @@ class AppsFlyerAnalyticsHandler(
override fun send(event: AnalyticsEvent) {
when (event) {
is AppsFlyerOnlyEvent -> {
Timber.tag("AppsFlyer").i("Sending event to AppsFlyer: ${event.id} with params: ${event.params}")
TangemLogger.withTag(
"AppsFlyer",
).i("Sending event to AppsFlyer: ${event.id} with params: ${event.params}")
client.logEvent(event.id, event.params)
}
is AppsFlyerIncludedEvent -> {
Timber.tag("AppsFlyer").i("Sending event to AppsFlyer: ${event.id} with params: ${event.params}")
TangemLogger.withTag(
"AppsFlyer",
).i("Sending event to AppsFlyer: ${event.id} with params: ${event.params}")
val replacedEvent = event.appsFlyerReplacedEvent ?: event.event
client.logEvent(
event = AnalyticsEvent(category = event.category, event = replacedEvent).id,
@ -52,15 +56,15 @@ class AppsFlyerAnalyticsHandler(
) : AnalyticsHandlerBuilder {
init {
Timber.tag("AppsFlyer").i("AppsFlyer Analytics Handler Builder created")
TangemLogger.withTag("AppsFlyer").i("AppsFlyer Analytics Handler Builder created")
}
override fun build(data: AnalyticsHandlerBuilder.Data): AnalyticsHandler = AppsFlyerAnalyticsHandler(
client = if (data.logConfig.isAppsflyerLogEnabled) {
Timber.tag("AppsFlyer").i("AppsFlyer log enabled, mock client created")
TangemLogger.withTag("AppsFlyer").i("AppsFlyer log enabled, mock client created")
AppsFlyerLogClient(data.jsonConverter)
} else {
Timber.tag("AppsFlyer").i("AppsFlyer log disabled, real client created")
TangemLogger.withTag("AppsFlyer").i("AppsFlyer log disabled, real client created")
appsFlyerClientFactory.create(apiKey = data.config.appsFlyerApiKey)
},
)

View file

@ -1,11 +1,11 @@
package com.tangem.tap.common.analytics.handlers.customerio
import android.app.Application
import com.tangem.utils.logging.TangemLogger
import io.customer.messagingpush.ModuleMessagingPushFCM
import io.customer.sdk.CustomerIO
import io.customer.sdk.CustomerIOBuilder
import io.customer.sdk.data.model.Region
import timber.log.Timber
/**
* Real Customer.io SDK client.
@ -32,7 +32,7 @@ internal class CustomerIoClient(
.addCustomerIOModule(ModuleMessagingPushFCM())
.build()
Timber.d("CustomerIO SDK initialized")
TangemLogger.d("CustomerIO SDK initialized")
}
override fun setUserId(userId: String) {

View file

@ -1,6 +1,6 @@
package com.tangem.tap.common.analytics.handlers.customerio
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
/**
* Log client for Customer.io (used in debug mode).
@ -13,11 +13,11 @@ internal class CustomerIoLogClient : CustomerIoAnalyticsClient {
override fun setUserId(userId: String) {
this.userId = userId
Timber.tag(CustomerIoAnalyticsHandler.ID).d("identify: userId=$userId")
TangemLogger.withTag(CustomerIoAnalyticsHandler.ID).d("identify: userId=$userId")
}
override fun clearUserId() {
Timber.tag(CustomerIoAnalyticsHandler.ID).d("clearIdentify: previous userId=$userId")
TangemLogger.withTag(CustomerIoAnalyticsHandler.ID).d("clearIdentify: previous userId=$userId")
this.userId = null
}
}

View file

@ -3,8 +3,8 @@ package com.tangem.tap.common.analytics.handlers.firebase
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.ktx.Firebase
import com.tangem.core.analytics.AppInstanceIdProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import kotlin.coroutines.resume
internal class FirebaseAppInstanceIdProvider : AppInstanceIdProvider {
@ -13,7 +13,7 @@ internal class FirebaseAppInstanceIdProvider : AppInstanceIdProvider {
Firebase.analytics.appInstanceId
.addOnSuccessListener { continuation.resume(it) }
.addOnFailureListener {
Timber.w("Fail to get appInstanceId")
TangemLogger.w("Fail to get appInstanceId")
continuation.resume(null)
}
}
@ -22,7 +22,7 @@ internal class FirebaseAppInstanceIdProvider : AppInstanceIdProvider {
return try {
Firebase.analytics.appInstanceId.result
} catch (e: IllegalStateException) {
Timber.e(e, "getAppInstanceIdSync")
TangemLogger.e("getAppInstanceIdSync", e)
null
}
}

View file

@ -2,11 +2,12 @@ package com.tangem.tap.common.clipboard
import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipDescription.MIMETYPE_TEXT_HTML
import android.content.ClipDescription.MIMETYPE_TEXT_PLAIN
import android.os.Build
import android.os.PersistableBundle
import com.tangem.core.ui.clipboard.ClipboardManager
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import android.content.ClipboardManager as AndroidClipboardManager
internal class DefaultClipboardManager(private val clipboardManager: AndroidClipboardManager) : ClipboardManager {
@ -24,13 +25,15 @@ internal class DefaultClipboardManager(private val clipboardManager: AndroidClip
val clip = clipboardManager.primaryClip
if (clip == null || clip.itemCount == 0) {
Timber.d("Clipboard is empty")
TangemLogger.d("Clipboard is empty")
return default
}
val clipDescription = clipboardManager.primaryClipDescription
if (clipDescription?.hasMimeType(MIMETYPE_TEXT_PLAIN) == false) {
Timber.d("Clipboard doesn't contain text")
if (clipDescription?.hasMimeType(MIMETYPE_TEXT_PLAIN) == false &&
!clipDescription.hasMimeType(MIMETYPE_TEXT_HTML)
) {
TangemLogger.d("Clipboard doesn't contain text")
return default
}

View file

@ -1,12 +1,12 @@
package com.tangem.tap.common.clipboard
import com.tangem.core.ui.clipboard.ClipboardManager
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
internal object MockClipboardManager : ClipboardManager {
override fun setText(text: String, isSensitive: Boolean, label: String) {
Timber.w("Clipboard Manager not available")
TangemLogger.w("Clipboard Manager not available")
}
override fun getText(default: String?): String? = null

View file

@ -0,0 +1,56 @@
package com.tangem.tap.common.deeplink
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.utils.logging.TangemLogger
/**
* [DeeplinkLauncher] implementation that launches deep links as intents to the current Activity
* and opens web URLs in the browser via [UrlOpener].
*/
internal class DefaultDeeplinkLauncher(
private val context: Context,
private val urlOpener: UrlOpener,
) : DeeplinkLauncher {
override fun launch(link: String) {
val deeplinkUri = link.toUri()
when (deeplinkUri.scheme) {
DeepLinkScheme.Tangem.scheme,
DeepLinkScheme.WalletConnect.scheme,
-> launchDeepLink(deeplinkUri)
DeepLinkScheme.Https.scheme -> launchDeeplinkOrOpenBrowser(deeplinkUri, link)
else -> {
TangemLogger.i(
"""
No match found for deep link
|- Received URI: $deeplinkUri
""".trimIndent(),
)
}
}
}
private fun launchDeeplinkOrOpenBrowser(uri: Uri, link: String) {
val intent = createDeepLinkIntent(uri)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
urlOpener.openUrl(link)
}
}
private fun launchDeepLink(uri: Uri) {
context.startActivity(createDeepLinkIntent(uri))
}
private fun createDeepLinkIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply {
setPackage(context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}

View file

@ -1,5 +0,0 @@
package com.tangem.tap.common.entities
import com.tangem.tap.common.toggleWidget.WidgetState
enum class ProgressState : WidgetState { Loading, Done, Error }

View file

@ -1,14 +1,9 @@
package com.tangem.tap.common.extensions
import com.tangem.common.routing.AppRouter
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.redux.StateDialog
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.TapError
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
@ -45,26 +40,6 @@ suspend fun Store<AppState>.onUserWalletSelected(userWallet: UserWallet) {
state.globalState.tapWalletManager.onWalletSelected(userWallet)
}
/**
* @param fatal used to indicate errors that should not normally occur
*/
fun Store<AppState>.dispatchDebugErrorNotification(message: String, fatal: Boolean = false) {
val prefix = if (fatal) "FATAL ERROR: " else "DEBUG ERROR: "
dispatchDebugErrorNotification(TapError.CustomError("$prefix $message"))
}
fun Store<AppState>.dispatchDebugErrorNotification(error: TapError) {
inject(DaggerGraphState::uiMessageSender).send(SnackbarMessage(stringReference(error.message ?: "debug error")))
}
fun Store<*>.dispatchDialogShow(dialog: StateDialog) {
dispatchOnMain(GlobalAction.ShowDialog(dialog))
}
fun Store<*>.dispatchDialogHide() {
dispatchOnMain(GlobalAction.HideDialog)
}
/**
* Dispatch action inside a coroutine with the Main dispatcher
*/
@ -76,14 +51,6 @@ suspend fun dispatchOnMain(vararg actions: Action) {
withMainContext { actions.forEach { store.dispatch(it) } }
}
fun Store<AppState>.dispatchOpenUrl(url: String) {
inject(DaggerGraphState::urlOpener).openUrl(url)
}
fun Store<AppState>.dispatchShare(url: String) {
inject(DaggerGraphState::shareManager).shareText(url)
}
fun Store<AppState>.dispatchNavigationAction(action: AppRouter.() -> Unit) {
inject(DaggerGraphState::appRouter).action()
}

View file

@ -1,19 +0,0 @@
package com.tangem.tap.common.extensions
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.transition.AutoTransition
import androidx.transition.Transition
import androidx.transition.TransitionManager
/**
[REDACTED_AUTHOR]
*/
fun ViewGroup.inflate(viewToInflate: Int, attachToRoot: Boolean = false): View {
return LayoutInflater.from(context).inflate(viewToInflate, this, attachToRoot)
}
fun ViewGroup.beginDelayedTransition(transition: Transition = AutoTransition()) {
TransitionManager.beginDelayedTransition(this, transition)
}

View file

@ -10,8 +10,8 @@ import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.getFirstToken
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.store
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.delay
import timber.log.Timber
/**
[REDACTED_AUTHOR]
@ -31,7 +31,7 @@ suspend fun WalletManager.safeUpdate(isDemoCard: Boolean): Result<Wallet> = try
Result.Success(wallet)
}
} catch (exception: Exception) {
Timber.e(exception)
TangemLogger.e("Error", exception)
val networkConnectionManager = store.inject(DaggerGraphState::networkConnectionManager)
if (!networkConnectionManager.isOnline) {

View file

@ -9,10 +9,10 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.memory.MemoryCache
import coil.request.CachePolicy
import coil.util.Logger
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
import com.tangem.utils.logging.TangemLogger
import okhttp3.OkHttpClient
import timber.log.Timber
import coil.util.Logger as CoilLogger
private const val COIL_LOG_TAG = "COIL"
private const val COIL_MEMORY_CACHE_SIZE = 0.25
@ -22,7 +22,7 @@ fun createCoilImageLoader(context: Context, logEnabled: Boolean = false): ImageL
.apply {
if (!logEnabled) return@apply
logger(CoilTimberLogger())
logger(CoilKermitLogger())
okHttpClient {
OkHttpClient.Builder()
.addNetworkInterceptor(createNetworkLoggingInterceptor())
@ -48,14 +48,16 @@ fun createCoilImageLoader(context: Context, logEnabled: Boolean = false): ImageL
.build()
}
private class CoilTimberLogger : Logger {
private class CoilKermitLogger : CoilLogger {
override var level: Int = Log.DEBUG
private val logger = TangemLogger.withTag(COIL_LOG_TAG)
override fun log(tag: String, priority: Int, message: String?, throwable: Throwable?) {
with(Timber.tag(COIL_LOG_TAG)) {
if (throwable != null) e(throwable, message)
if (message != null) d(message)
if (throwable != null) {
logger.e(message ?: "<EMPTY>", throwable)
} else if (message != null) {
logger.d(message)
}
}
}

View file

@ -8,8 +8,8 @@ import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.orhanobut.logger.AndroidLogAdapter
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.BuildConfig
import timber.log.Timber
import java.util.regex.Pattern
import com.orhanobut.logger.Logger as PrettyLogger
@ -30,18 +30,9 @@ class TangemAppLoggerInitializer(
PrettyLogger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy()))
}
Timber.plant(tree = createTimberTree())
Logger.setLogWriters(KermitLogWriter(::finalLogOutput))
}
private fun createTimberTree(): Timber.Tree {
return object : Timber.DebugTree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
finalLogOutput(priority = priority, tag = tag, message = message, t = t)
}
}
}
private fun finalLogOutput(priority: Int, tag: String?, message: String, t: Throwable?) {
if (IS_LOG_ENABLED) {
PrettyLogger.log(priority, tag, message, t)
@ -71,6 +62,8 @@ private class KermitLogWriter(
KermitLogWriter::class.java.name,
BaseLogger::class.java.name,
Logger::class.java.name,
TangemLogger::class.java.name,
TangemLogger.TaggedLogger::class.java.name,
)
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
@ -87,7 +80,7 @@ private class KermitLogWriter(
tag
} else {
/**
* like in [Timber.DebugTree.tag]
* like in [Logger.debugTree.tag]
*/
@Suppress("UnnecessaryLet", "ThrowingExceptionsWithoutMessageOrCause")
Throwable().stackTrace
@ -99,7 +92,7 @@ private class KermitLogWriter(
}
/**
* copy from [Timber.DebugTree.createStackElementTag]
* copy from [Logger.debugTree.createStackElementTag]
*/
@Suppress("MagicNumber")
private fun createStackElementTag(element: StackTraceElement): String? {
@ -120,7 +113,7 @@ private class KermitLogWriter(
private const val KERMIT_LOGGER_DEFAULT_TAG = ""
/**
* copy from [Timber.DebugTree.Companion]
* copy from [Logger.debugTree.Companion]
*/
private const val MAX_TAG_LENGTH = 23
private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$")

View file

@ -4,9 +4,9 @@ import android.annotation.SuppressLint
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.tangem.tap.common.analytics.CustomerIoFeatureToggles
import com.tangem.utils.logging.TangemLogger
import dagger.hilt.android.AndroidEntryPoint
import io.customer.messagingpush.CustomerIOFirebaseMessagingService
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
@ -22,7 +22,7 @@ internal class TangemPushNotificationService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
Timber.d("New FCM token received: $token")
TangemLogger.d("New FCM token received: $token")
if (customerIoFeatureToggles.isFeatureEnabled) {
CustomerIOFirebaseMessagingService.onNewToken(applicationContext, token)

View file

@ -1,41 +0,0 @@
package com.tangem.tap.common.redux
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.redux.StateDialog
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
sealed class AppDialog : StateDialog {
data class SimpleOkDialogRes(
val headerId: Int,
val messageId: Int,
val args: List<String> = emptyList(),
val onOk: VoidCallback? = null,
) : AppDialog()
data class RemoveWalletDialog(
val currencyTitle: String,
val onOk: () -> Unit,
) : AppDialog() {
val messageRes: Int = R.string.token_details_hide_alert_message
val titleRes: Int = R.string.token_details_hide_alert_title
val primaryButtonRes: Int = R.string.token_details_hide_alert_hide
}
data class TokensAreLinkedDialog(
val currencyTitle: String,
val currencySymbol: String,
val networkName: String,
) : AppDialog() {
val messageRes: Int = R.string.token_details_unable_hide_alert_message
val titleRes: Int = R.string.token_details_unable_hide_alert_title
}
data class WalletAlreadyWasUsedDialog(
val onOk: () -> Unit,
val onSupportClick: () -> Unit,
val onCancel: () -> Unit,
) : AppDialog()
}

View file

@ -5,7 +5,6 @@ import com.tangem.tap.common.redux.global.GlobalState
import com.tangem.tap.common.redux.legacy.LegacyMiddleware
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.proxy.redux.DaggerGraphMiddleware
import com.tangem.tap.proxy.redux.DaggerGraphState
import org.rekotlin.Middleware
@ -23,7 +22,6 @@ data class AppState(
logMiddleware,
GlobalMiddleware.handler,
DetailsMiddleware().detailsMiddleware,
BackupMiddleware().backupMiddleware,
LockUserWalletsTimerMiddleware().middleware,
AccessCodeRequestPolicyMiddleware().middleware,
DaggerGraphMiddleware.daggerGraphMiddleware,

View file

@ -1,7 +1,7 @@
package com.tangem.tap.common.redux
import com.tangem.utils.logging.TangemLogger
import org.rekotlin.Middleware
import timber.log.Timber
/**
[REDACTED_AUTHOR]
@ -9,7 +9,7 @@ import timber.log.Timber
val logMiddleware: Middleware<AppState> = { _, _ ->
{ nextDispatch ->
{ action ->
Timber.i("Dispatch action: ${action::class.java.simpleName}")
TangemLogger.i("Dispatch action: ${action::class.java.simpleName}")
nextDispatch(action)
}
}

View file

@ -1,28 +1,11 @@
package com.tangem.tap.common.redux.global
import com.tangem.common.CompletionResult
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.redux.StateDialog
import org.rekotlin.Action
sealed class GlobalAction : Action {
// dialogs
data class ShowDialog(val stateDialog: StateDialog) : GlobalAction()
object HideDialog : GlobalAction()
object ScanFailsCounter {
data class ChooseBehavior(
val result: CompletionResult<ScanResponse>,
val analyticsSource: AnalyticsParam.ScreensSources,
) : GlobalAction()
object Reset : GlobalAction()
object Increment : GlobalAction()
}
data class SaveScanResponse(val scanResponse: ScanResponse) : GlobalAction()
data class ChangeAppCurrency(val appCurrency: AppCurrency) : GlobalAction()

View file

@ -1,12 +1,6 @@
package com.tangem.tap.common.redux.global
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.redux.StateDialog
import com.tangem.tap.common.extensions.dispatchDialogShow
import com.tangem.tap.common.extensions.dispatchWithMain
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.redux.AppState
@ -31,41 +25,12 @@ private val globalMiddlewareHandler: Middleware<AppState> = { _, _ ->
}
}
@Suppress("LongMethod", "ComplexMethod")
private fun handleAction(action: Action) {
when (action) {
is GlobalAction.ScanFailsCounter.ChooseBehavior -> {
when (action.result) {
is CompletionResult.Success -> store.dispatch(GlobalAction.ScanFailsCounter.Reset)
is CompletionResult.Failure -> {
handleFailureChooseBehaviour(action.result, action.analyticsSource)
}
}
}
is GlobalAction.RestoreAppCurrency -> restoreAppCurrency()
}
}
private fun handleFailureChooseBehaviour(
result: CompletionResult.Failure<ScanResponse>,
analyticsSource: AnalyticsParam.ScreensSources,
) {
if (result.error is TangemSdkError.UserCancelled) {
store.dispatch(GlobalAction.ScanFailsCounter.Increment)
if (store.state.globalState.scanCardFailsCounter >= 2) {
val scanFailsSource = when (analyticsSource) {
is AnalyticsParam.ScreensSources.SignIn -> StateDialog.ScanFailsSource.SIGN_IN
is AnalyticsParam.ScreensSources.Settings -> StateDialog.ScanFailsSource.SETTINGS
is AnalyticsParam.ScreensSources.Intro -> StateDialog.ScanFailsSource.INTRO
else -> StateDialog.ScanFailsSource.MAIN
}
store.dispatchDialogShow(StateDialog.ScanFailsDialog(scanFailsSource))
}
} else {
store.dispatch(GlobalAction.ScanFailsCounter.Reset)
}
}
private fun restoreAppCurrency() {
scope.launch {
val currency = store.inject(DaggerGraphState::appCurrencyRepository)

View file

@ -10,12 +10,6 @@ fun globalReducer(action: Action, state: AppState): GlobalState {
val globalState = state.globalState
return when (action) {
is GlobalAction.ScanFailsCounter.Increment -> {
globalState.copy(scanCardFailsCounter = globalState.scanCardFailsCounter + 1)
}
is GlobalAction.ScanFailsCounter.Reset -> {
globalState.copy(scanCardFailsCounter = 0)
}
is GlobalAction.SaveScanResponse -> {
globalState.copy(scanResponse = action.scanResponse)
}
@ -26,12 +20,6 @@ fun globalReducer(action: Action, state: AppState): GlobalState {
globalState.copy(appCurrency = action.appCurrency)
}
is GlobalAction.IsSignWithRing -> globalState.copy(isLastSignWithRing = action.isSignWithRing)
is GlobalAction.ShowDialog -> {
globalState.copy(dialog = action.stateDialog)
}
is GlobalAction.HideDialog -> {
globalState.copy(dialog = null)
}
else -> globalState
}
}

View file

@ -2,24 +2,15 @@ package com.tangem.tap.common.redux.global
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.redux.StateDialog
import com.tangem.tap.domain.TapWalletManager
import org.rekotlin.StateType
data class GlobalState(
@Deprecated("Use scan response from selected user wallet")
val scanResponse: ScanResponse? = null,
val onboardingState: OnboardingState = OnboardingState(),
val tapWalletManager: TapWalletManager = TapWalletManager(),
val appCurrency: AppCurrency = AppCurrency.Default,
val scanCardFailsCounter: Int = 0,
val dialog: StateDialog? = null,
val isLastSignWithRing: Boolean = false,
) : StateType
typealias CryptoCurrencyName = String
data class OnboardingState(
val isOnboardingStarted: Boolean = false,
val shouldResetOnCreate: Boolean = false,
)
typealias CryptoCurrencyName = String

View file

@ -1,13 +0,0 @@
package com.tangem.tap.common.toggleWidget
import android.view.View
/**
[REDACTED_AUTHOR]
*/
interface WidgetState
interface ViewStateWidget {
val mainView: View
fun changeState(state: WidgetState)
}

View file

@ -1,24 +0,0 @@
package com.tangem.tap.common.transitions
import androidx.transition.ChangeBounds
import androidx.transition.ChangeTransform
import androidx.transition.Fade
import androidx.transition.TransitionSet
class HomeToOnboardingTransition : TransitionSet() {
init {
ordering = ORDERING_TOGETHER
addTransition(Fade())
addTransition(ChangeTransform())
addTransition(ChangeBounds())
}
}
class InternalNoteLayoutTransition : TransitionSet() {
init {
ordering = ORDERING_TOGETHER
addTransition(ChangeTransform())
addTransition(ChangeBounds())
addTransition(Fade())
}
}

View file

@ -1,84 +0,0 @@
package com.tangem.tap.common.ui
import android.content.Context
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.compose.ui.text.intl.Locale
import androidx.core.view.isVisible
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.redux.StateDialog
import com.tangem.tap.common.analytics.events.ScanFailsDialogAnalytics
import com.tangem.tap.common.extensions.dispatchDialogHide
import com.tangem.tap.common.extensions.dispatchOpenUrl
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.proxy.redux.DaggerGraphState
import com.tangem.tap.scope
import com.tangem.tap.store
import com.tangem.wallet.R
import kotlinx.coroutines.launch
/**
[REDACTED_AUTHOR]
*/
internal object ScanFailsDialog {
private const val HOW_TO_SCAN_RU_LINK = "https://tangem.com/ru/blog/post/scan-tangem-card/"
private const val HOW_TO_SCAN_LINK = "https://tangem.com/en/blog/post/scan-tangem-card/"
private const val RUSSIA_LOCALE = "ru"
fun create(context: Context, source: StateDialog.ScanFailsSource, onTryAgain: (() -> Unit)? = null): AlertDialog {
return AlertDialog.Builder(context, R.style.CustomMaterialDialog).apply {
val customView = View.inflate(context, R.layout.dialog_scan_fails, null)
val sourceAnalytics = when (source) {
StateDialog.ScanFailsSource.MAIN -> AnalyticsParam.ScreensSources.Main
StateDialog.ScanFailsSource.SIGN_IN -> AnalyticsParam.ScreensSources.SignIn
StateDialog.ScanFailsSource.SETTINGS -> AnalyticsParam.ScreensSources.Settings
StateDialog.ScanFailsSource.INTRO -> AnalyticsParam.ScreensSources.Intro
}
val tryAgainBtn: TextView? = customView.findViewById(R.id.try_again_button)
if (onTryAgain != null) {
tryAgainBtn?.isVisible = true
tryAgainBtn?.setOnClickListener {
store.dispatchDialogHide()
Analytics.send(
ScanFailsDialogAnalytics(
button = ScanFailsDialogAnalytics.Buttons.TRY_AGAIN,
source = sourceAnalytics,
),
)
onTryAgain()
}
} else {
tryAgainBtn?.isVisible = false
}
customView.findViewById<TextView>(R.id.how_to_scan_button)?.setOnClickListener {
Analytics.send(
ScanFailsDialogAnalytics(
button = ScanFailsDialogAnalytics.Buttons.HOW_TO_SCAN,
source = sourceAnalytics,
),
)
val locale = Locale.current.region
val link = if (locale.lowercase() == RUSSIA_LOCALE) HOW_TO_SCAN_RU_LINK else HOW_TO_SCAN_LINK
store.dispatchOpenUrl(link)
}
customView.findViewById<TextView>(R.id.request_support_button)?.setOnClickListener {
Analytics.send(Basic.ButtonSupport(sourceAnalytics))
scope.launch {
store.inject(DaggerGraphState::sendFeedbackEmailUseCase)
.invoke(type = FeedbackEmailType.ScanningProblem)
}
}
customView.findViewById<TextView>(R.id.cancel_button)?.setOnClickListener {
store.dispatchDialogHide()
}
setView(customView)
setOnDismissListener { store.dispatchDialogHide() }
}.create()
}
}

View file

@ -1,55 +0,0 @@
package com.tangem.tap.common.ui
import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.store
import com.tangem.wallet.R
object SimpleAlertDialog {
fun create(
titleRes: Int? = null,
messageRes: Int? = null,
title: String? = null,
message: String? = null,
primaryButtonRes: Int = R.string.common_ok,
context: Context,
): AlertDialog {
return SimpleCancelableAlertDialog.create(
titleRes = titleRes,
messageRes = messageRes,
title = title,
message = message,
primaryButtonRes = primaryButtonRes,
secondaryButtonRes = null,
context = context,
)
}
}
object SimpleCancelableAlertDialog {
fun create(
titleRes: Int? = null,
messageRes: Int? = null,
title: String? = null,
message: String? = null,
primaryButtonRes: Int = R.string.common_ok,
secondaryButtonRes: Int? = R.string.common_cancel,
primaryButtonAction: () -> Unit = {},
secondaryButtonAction: () -> Unit = {},
context: Context,
): AlertDialog {
return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply {
setTitle(if (titleRes != null) context.getString(titleRes) else title)
setMessage(if (messageRes != null) context.getString(messageRes) else message)
setPositiveButton(context.getText(primaryButtonRes)) { _, _ -> primaryButtonAction() }
if (secondaryButtonRes != null) {
setNegativeButton(context.getText(secondaryButtonRes)) { _, _ -> secondaryButtonAction() }
}
setOnDismissListener {
store.dispatch(GlobalAction.HideDialog)
}
}.create()
}
}

View file

@ -1,31 +0,0 @@
package com.tangem.tap.common.ui
import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.tangem.tap.common.extensions.dispatchDialogHide
import com.tangem.tap.common.redux.AppDialog
import com.tangem.tap.store
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
object SimpleOkDialog {
fun create(dialog: AppDialog.SimpleOkDialogRes, context: Context): AlertDialog {
val message = if (dialog.args.isEmpty()) {
context.getString(dialog.messageId)
} else {
context.getString(dialog.messageId, *dialog.args.toTypedArray())
}
return AlertDialog.Builder(context).apply {
setTitle(context.getString(dialog.headerId))
setMessage(message)
setPositiveButton(R.string.common_ok) { _, _ -> }
setOnDismissListener {
store.dispatchDialogHide()
dialog.onOk?.invoke()
}
}.create()
}
}

View file

@ -14,8 +14,8 @@ import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.common.extensions.getColorCompat
import com.tangem.tap.foregroundActivityObserver
import com.tangem.tap.withForegroundActivity
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.R
import timber.log.Timber
internal class CustomTabsUrlOpener : UrlOpener {
@ -55,7 +55,7 @@ internal class CustomTabsUrlOpener : UrlOpener {
customTabsIntent.launchUrl(context, url.toUri())
}
}.onFailure {
Timber.e(it)
TangemLogger.e("Error", it)
}
}

View file

@ -1,10 +1,10 @@
package com.tangem.tap.core
import co.touchlab.kermit.Logger
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.SupervisorJob
@ -28,7 +28,7 @@ internal class DefaultAppCoroutineScope @Inject constructor(
}
private fun logError(throwable: Throwable, coroutineName: String) {
Logger.withTag(tag).e(
TangemLogger.withTag(tag).e(
messageString = "CoroutineName $coroutineName",
throwable = throwable,
)

View file

@ -8,7 +8,7 @@ import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.tangem.core.navigation.email.EmailSender
import com.tangem.tap.foregroundActivityObserver
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
/**
* Implementation of email sender for Android
@ -21,7 +21,7 @@ internal class AndroidEmailSender : EmailSender {
val activity = foregroundActivityObserver.foregroundActivity
if (activity == null) {
Timber.e("Foreground activity not found")
TangemLogger.e("Foreground activity not found")
return
}
@ -50,7 +50,7 @@ internal class AndroidEmailSender : EmailSender {
ContextCompat.startActivity(activity, chooserIntent, null)
} catch (ex: Exception) {
Timber.e("Failed to send email: $ex")
TangemLogger.e("Failed to send email: $ex")
}
}

View file

@ -1,8 +1,9 @@
package com.tangem.tap.core.security
import android.os.Build
import com.dexprotector.rtc.RtcStatus
import com.tangem.security.DeviceSecurityInfoProvider
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
internal class DefaultDeviceSecurityInfoProvider : DeviceSecurityInfoProvider {
override val isRooted: Boolean
@ -12,12 +13,86 @@ internal class DefaultDeviceSecurityInfoProvider : DeviceSecurityInfoProvider {
override val isXposed: Boolean
get() = getRtcStatusSafely()?.xposed == true
override val isVulnerableToMediaTekExploit: Boolean by lazy {
val isAffected by lazy { isAffectedMediaTekDevice() }
val isPatched by lazy { hasSecurityPatch() }
val isVulnerable = isAffected && !isPatched
TangemLogger.i(
"CVE-2026-20435 check: isAffectedMediaTek=$isAffected, " +
"isPatched=$isPatched, isVulnerable=$isVulnerable",
)
isVulnerable
}
private fun isAffectedMediaTekDevice(): Boolean {
val socModel = resolveMediaTekSocModel()
val isAffected = socModel != null && socModel in AFFECTED_MEDIATEK_SOCS
TangemLogger.i("CVE-2026-20435 SoC result: model=$socModel, isAffected=$isAffected")
return isAffected
}
private fun resolveMediaTekSocModel(): String? {
// Layer 1: API 31+ provides direct SoC info (public API, most reliable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val manufacturer = Build.SOC_MANUFACTURER
val model = Build.SOC_MODEL
TangemLogger.i("CVE-2026-20435 Layer 1: SOC_MANUFACTURER=$manufacturer, SOC_MODEL=$model")
if (manufacturer.equals("MediaTek", ignoreCase = true)) {
extractSocModel(model)?.let { return it }
}
}
// Layer 2: Build.HARDWARE often contains "mtXXXX" on MediaTek devices (public API)
val hardware = Build.HARDWARE
TangemLogger.i("CVE-2026-20435 Layer 2: HARDWARE=$hardware")
extractSocModel(hardware)?.let { return it }
return null
}
private fun extractSocModel(value: String): String? {
val match = MEDIATEK_SOC_PATTERN.find(value.uppercase()) ?: return null
return match.value
}
private fun hasSecurityPatch(): Boolean {
val patch = Build.VERSION.SECURITY_PATCH
val isPatched = try {
patch >= MEDIATEK_CVE_FIX_PATCH_LEVEL
} catch (e: Exception) {
TangemLogger.w("CVE-2026-20435 patch check: failed to parse SECURITY_PATCH=$patch", e)
false // fail-safe: treat unknown patch level as unpatched
}
TangemLogger.i(
"CVE-2026-20435 patch check: SECURITY_PATCH=$patch, " +
"required=$MEDIATEK_CVE_FIX_PATCH_LEVEL, isPatched=$isPatched",
)
return isPatched
}
private fun getRtcStatusSafely(): RtcStatus? {
return try {
RtcStatus.getRtcStatus()
} catch (e: Throwable) {
Timber.e(e)
TangemLogger.e("Error", e)
null
}
}
private companion object {
/** Android security patch level that includes the fix for CVE-2026-20435 */
const val MEDIATEK_CVE_FIX_PATCH_LEVEL = "2026-03-05"
/** Regex to extract MediaTek SoC model number (e.g., MT6789) */
val MEDIATEK_SOC_PATTERN = Regex("MT\\d{4}")
/** Affected MediaTek SoC models per Ledger Donjon disclosure */
val AFFECTED_MEDIATEK_SOCS = setOf(
"MT6739", "MT6761", "MT6765", "MT6768", "MT6781",
"MT6789", "MT6813", "MT6833", "MT6853", "MT6855",
"MT6877", "MT6878", "MT6879", "MT6880", "MT6885",
"MT6886", "MT6890", "MT6893", "MT6895", "MT6897",
"MT6983", "MT6985", "MT6989", "MT6990", "MT6993",
)
}
}

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