From 18f33fb11fcf4db9bb6e5e57bc679498eacedb4f Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Mon, 29 Jun 2026 09:50:09 +0400 Subject: [PATCH 1/3] feat: tracing markers, faster TONHex, id-based demo wallets --- AndroidDemo/app/build.gradle.kts | 1 + .../demo/data/storage/DemoAppStorage.kt | 2 +- .../demo/data/storage/SecureDemoAppStorage.kt | 6 +- .../presentation/actions/WalletActions.kt | 14 +- .../presentation/actions/WalletActionsImpl.kt | 14 +- .../demo/presentation/model/WalletSummary.kt | 1 + .../demo/presentation/state/WalletUiState.kt | 2 +- .../ui/components/WalletSwitcher.kt | 30 +- .../presentation/ui/preview/PreviewData.kt | 1 + .../ui/screen/LegacyWalletScreen.kt | 6 +- .../ui/screen/NFTDetailsScreen.kt | 8 +- .../ui/screen/SendTransactionScreen.kt | 8 +- .../presentation/ui/screen/WalletScreen.kt | 18 +- .../ui/sections/WalletsSection.kt | 6 +- .../presentation/ui/sheet/StakingSheet.kt | 8 +- .../ui/sheet/WalletsBottomSheet.kt | 4 +- .../viewmodel/TonConnectViewModel.kt | 12 +- .../viewmodel/WalletKitViewModel.kt | 319 ++++++++++-------- .../viewmodel/WalletLifecycleManager.kt | 29 +- .../viewmodel/WalletOperationsViewModel.kt | 14 +- .../viewmodel/WalletUiStateCoordinator.kt | 4 +- AndroidDemo/gradle/libs.versions.toml | 2 + README.md | 23 ++ Scripts/profiling/analyze-trace.sh | 71 ++++ Scripts/profiling/capture-perfetto.sh | 93 +++++ Scripts/profiling/captures/.gitignore | 2 + Scripts/profiling/record-simpleperf.sh | 64 ++++ .../java/io/ton/walletkit/model/TONHex.kt | 23 +- .../gradle/libs.versions.toml | 5 + TONWalletKit-Android/impl/build.gradle.kts | 1 + .../engine/infrastructure/BridgeRpcClient.kt | 36 +- .../infrastructure/MessageDispatcher.kt | 10 + .../engine/infrastructure/WebViewManager.kt | 25 +- .../microbenchmark/build.gradle.kts | 44 +++ .../src/androidTest/AndroidManifest.xml | 10 + .../microbenchmark/ModelBenchmark.kt | 102 ++++++ TONWalletKit-Android/settings.gradle.kts | 1 + 37 files changed, 770 insertions(+), 249 deletions(-) create mode 100755 Scripts/profiling/analyze-trace.sh create mode 100755 Scripts/profiling/capture-perfetto.sh create mode 100644 Scripts/profiling/captures/.gitignore create mode 100755 Scripts/profiling/record-simpleperf.sh create mode 100644 TONWalletKit-Android/microbenchmark/build.gradle.kts create mode 100644 TONWalletKit-Android/microbenchmark/src/androidTest/AndroidManifest.xml create mode 100644 TONWalletKit-Android/microbenchmark/src/androidTest/java/io/ton/walletkit/microbenchmark/ModelBenchmark.kt diff --git a/AndroidDemo/app/build.gradle.kts b/AndroidDemo/app/build.gradle.kts index 499b884a..142b25a0 100644 --- a/AndroidDemo/app/build.gradle.kts +++ b/AndroidDemo/app/build.gradle.kts @@ -179,6 +179,7 @@ dependencies { // Required transitive dependencies when using AAR: implementation(libs.androidxWebkit) implementation(libs.androidxDatastorePreferences) + implementation(libs.androidxTracing) implementation(libs.kotlinxSerializationJson) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/data/storage/DemoAppStorage.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/data/storage/DemoAppStorage.kt index 1266832c..ae850394 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/data/storage/DemoAppStorage.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/data/storage/DemoAppStorage.kt @@ -115,5 +115,5 @@ data class WalletRecord( * User preferences for the demo app. */ data class UserPreferences( - val activeWalletAddress: String? = null, + val activeWalletId: String? = null, ) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/data/storage/SecureDemoAppStorage.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/data/storage/SecureDemoAppStorage.kt index 6f2725de..fa732dd8 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/data/storage/SecureDemoAppStorage.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/data/storage/SecureDemoAppStorage.kt @@ -133,7 +133,7 @@ class SecureDemoAppStorage(context: Context) : DemoAppStorage { override suspend fun saveUserPreferences(preferences: UserPreferences): Unit = withContext(Dispatchers.IO) { userPrefs.edit().apply { - preferences.activeWalletAddress?.let { + preferences.activeWalletId?.let { putString(PREF_ACTIVE_WALLET, it) } ?: remove(PREF_ACTIVE_WALLET) }.apply() @@ -143,7 +143,7 @@ class SecureDemoAppStorage(context: Context) : DemoAppStorage { override suspend fun loadUserPreferences(): UserPreferences? = withContext(Dispatchers.IO) { val activeWallet = userPrefs.getString(PREF_ACTIVE_WALLET, null) if (activeWallet != null) { - UserPreferences(activeWalletAddress = activeWallet) + UserPreferences(activeWalletId = activeWallet) } else { null } @@ -201,7 +201,7 @@ class SecureDemoAppStorage(context: Context) : DemoAppStorage { private const val WALLET_PREFS_NAME = "walletkit_demo_wallets" private const val USER_PREFS_NAME = "walletkit_demo_prefs" private const val WALLET_PREFIX = "wallet:" - private const val PREF_ACTIVE_WALLET = "active_wallet_address" + private const val PREF_ACTIVE_WALLET = "active_wallet_id" private const val PREF_PASSWORD_HASH = "password_hash" private const val KEY_MNEMONIC = "mnemonic" private const val KEY_NAME = "name" diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/actions/WalletActions.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/actions/WalletActions.kt index facff57a..2fcf4aef 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/actions/WalletActions.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/actions/WalletActions.kt @@ -42,14 +42,14 @@ interface WalletActions { fun onOpenBrowser(url: String, injectTonConnect: Boolean = true) fun onRefresh() fun onDismissSheet() - fun onWalletDetails(address: String) - fun onSendFromWallet(address: String) - fun onStakeFromWallet(address: String) + fun onWalletDetails(walletId: String) + fun onSendFromWallet(walletId: String) + fun onStakeFromWallet(walletId: String) fun onDisconnectSession(sessionId: String) fun onToggleWalletSwitcher() - fun onSwitchWallet(address: String) - fun onRemoveWallet(address: String) - fun onRenameWallet(address: String, newName: String) + fun onSwitchWallet(walletId: String) + fun onRemoveWallet(walletId: String) + fun onRenameWallet(walletId: String, newName: String) fun onImportWallet( name: String, network: TONNetwork, @@ -69,7 +69,7 @@ interface WalletActions { fun onRejectSignMessage(request: SignMessageRequestUi) fun onConfirmSignerApproval() fun onCancelSignerApproval() - fun onRefreshTransactions(address: String) + fun onRefreshTransactions(walletId: String) fun onTransactionClick(transactionHash: String, walletAddress: String) fun onHandleUrl(url: String) fun onDismissUrlPrompt() diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/actions/WalletActionsImpl.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/actions/WalletActionsImpl.kt index e46b32d2..0b94eec4 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/actions/WalletActionsImpl.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/actions/WalletActionsImpl.kt @@ -54,21 +54,21 @@ class WalletActionsImpl @Inject constructor( override fun onDismissSheet() = viewModel.dismissSheet() - override fun onWalletDetails(address: String) = viewModel.showWalletDetails(address) + override fun onWalletDetails(walletId: String) = viewModel.showWalletDetails(walletId) - override fun onSendFromWallet(address: String) = viewModel.openSendTransactionSheet(address) + override fun onSendFromWallet(walletId: String) = viewModel.openSendTransactionSheet(walletId) - override fun onStakeFromWallet(address: String) = viewModel.openStakingSheet(address) + override fun onStakeFromWallet(walletId: String) = viewModel.openStakingSheet(walletId) override fun onDisconnectSession(sessionId: String) = viewModel.disconnectSession(sessionId) override fun onToggleWalletSwitcher() = viewModel.toggleWalletSwitcher() - override fun onSwitchWallet(address: String) = viewModel.switchWallet(address) + override fun onSwitchWallet(walletId: String) = viewModel.switchWallet(walletId) - override fun onRemoveWallet(address: String) = viewModel.removeWallet(address) + override fun onRemoveWallet(walletId: String) = viewModel.removeWallet(walletId) - override fun onRenameWallet(address: String, newName: String) = viewModel.renameWallet(address, newName) + override fun onRenameWallet(walletId: String, newName: String) = viewModel.renameWallet(walletId, newName) override fun onImportWallet( name: String, @@ -106,7 +106,7 @@ class WalletActionsImpl @Inject constructor( override fun onCancelSignerApproval() = viewModel.cancelSignerApproval() - override fun onRefreshTransactions(address: String) = viewModel.refreshTransactions(address) + override fun onRefreshTransactions(walletId: String) = viewModel.refreshTransactions(walletId) override fun onTransactionClick(transactionHash: String, walletAddress: String) = viewModel.showTransactionDetail(transactionHash, walletAddress) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/model/WalletSummary.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/model/WalletSummary.kt index 2b08b71b..44a73b8e 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/model/WalletSummary.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/model/WalletSummary.kt @@ -26,6 +26,7 @@ import io.ton.walletkit.api.generated.TONTransaction import io.ton.walletkit.demo.domain.model.WalletInterfaceType data class WalletSummary( + val walletId: String, val address: String, val name: String, val network: TONNetwork, diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/state/WalletUiState.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/state/WalletUiState.kt index 0d1a6380..916571ff 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/state/WalletUiState.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/state/WalletUiState.kt @@ -30,7 +30,7 @@ data class WalletUiState( val initialized: Boolean = false, val status: String = "", val wallets: List = emptyList(), - val activeWalletAddress: String? = null, + val activeWalletId: String? = null, val sessions: List = emptyList(), val sheetState: SheetState = SheetState.None, val previousSheet: SheetState? = null, // Used to restore sheet after modal interactions diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletSwitcher.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletSwitcher.kt index 76b6182d..ad55d5a2 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletSwitcher.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletSwitcher.kt @@ -76,7 +76,7 @@ import io.ton.walletkit.demo.presentation.ui.preview.PreviewData @Composable fun WalletSwitcher( wallets: List, - activeWalletAddress: String?, + activeWalletId: String?, isExpanded: Boolean, onToggle: () -> Unit, onSwitchWallet: (String) -> Unit, @@ -84,29 +84,29 @@ fun WalletSwitcher( onRenameWallet: (String, String) -> Unit, modifier: Modifier = Modifier, ) { - var editingWalletAddress by rememberSaveable { mutableStateOf(null) } + var editingWalletId by rememberSaveable { mutableStateOf(null) } var editingName by rememberSaveable { mutableStateOf("") } var walletToDelete by remember { mutableStateOf(null) } - val activeWallet = wallets.firstOrNull { it.address == activeWalletAddress } + val activeWallet = wallets.firstOrNull { it.walletId == activeWalletId } fun startEdit(wallet: WalletSummary) { - editingWalletAddress = wallet.address + editingWalletId = wallet.walletId editingName = wallet.name } fun saveEdit() { - editingWalletAddress?.let { address -> + editingWalletId?.let { walletId -> if (editingName.isNotBlank()) { - onRenameWallet(address, editingName.trim()) + onRenameWallet(walletId, editingName.trim()) } - editingWalletAddress = null + editingWalletId = null editingName = "" } } fun cancelEdit() { - editingWalletAddress = null + editingWalletId = null editingName = "" } @@ -188,8 +188,8 @@ fun WalletSwitcher( verticalArrangement = Arrangement.spacedBy(WALLET_LIST_ITEM_SPACING), ) { wallets.forEach { wallet -> - val isActive = wallet.address == activeWalletAddress - val isEditing = editingWalletAddress == wallet.address + val isActive = wallet.walletId == activeWalletId + val isEditing = editingWalletId == wallet.walletId OutlinedCard( modifier = Modifier.fillMaxWidth(), @@ -277,7 +277,7 @@ fun WalletSwitcher( Row { if (!isActive) { - IconButton(onClick = { onSwitchWallet(wallet.address) }) { + IconButton(onClick = { onSwitchWallet(wallet.walletId) }) { Icon( imageVector = Icons.Default.SwapHoriz, contentDescription = stringResource(R.string.action_switch), @@ -327,7 +327,7 @@ fun WalletSwitcher( style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, ) - if (wallet.address == activeWalletAddress && wallets.size > 1) { + if (wallet.walletId == activeWalletId && wallets.size > 1) { Spacer(modifier = Modifier.height(DIALOG_SPACING)) Text( text = stringResource(R.string.wallet_switcher_remove_warning), @@ -340,7 +340,7 @@ fun WalletSwitcher( confirmButton = { TextButton( onClick = { - onRemoveWallet(wallet.address) + onRemoveWallet(wallet.walletId) walletToDelete = null }, ) { @@ -380,11 +380,13 @@ private fun WalletSwitcherPreview() { val wallets = listOf( PreviewData.wallet, PreviewData.wallet.copy( + walletId = "wallet-2", address = "EQD9876543210", name = "Wallet 2", balance = "123.45", ), PreviewData.wallet.copy( + walletId = "wallet-3", address = "EQD1111111111", name = "Wallet 3", balance = "0.50", @@ -393,7 +395,7 @@ private fun WalletSwitcherPreview() { WalletSwitcher( wallets = wallets, - activeWalletAddress = wallets.first().address, + activeWalletId = wallets.first().walletId, isExpanded = true, onToggle = {}, onSwitchWallet = {}, diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/preview/PreviewData.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/preview/PreviewData.kt index df2a9272..64358429 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/preview/PreviewData.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/preview/PreviewData.kt @@ -43,6 +43,7 @@ import org.json.JSONObject object PreviewData { val wallet: WalletSummary = WalletSummary( + walletId = "preview-wallet-id", address = "EQpreviewaddressExampleToShowWalletKit123", name = "Preview Wallet", network = TONNetwork.MAINNET, diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt index 41d01384..16a12bfc 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt @@ -159,7 +159,7 @@ fun LegacyWalletScreen( val session = if (browserSheet.injectTonConnect) injectedSession else plainSession if (session.tabs.isEmpty()) session.openTab(browserSheet.url) } - val activeWallet = state.wallets.firstOrNull { it.address == state.activeWalletAddress } + val activeWallet = state.wallets.firstOrNull { it.walletId == state.activeWalletId } ?: state.wallets.firstOrNull() if (showSheet) { ModalBottomSheet( @@ -339,7 +339,7 @@ fun LegacyWalletScreen( if (state.wallets.size > 1) { WalletSwitcher( wallets = state.wallets, - activeWalletAddress = state.activeWalletAddress, + activeWalletId = state.activeWalletId, isExpanded = state.isWalletSwitcherExpanded, onToggle = actions::onToggleWalletSwitcher, onSwitchWallet = actions::onSwitchWallet, @@ -425,7 +425,7 @@ fun LegacyWalletScreen( activeWallet?.let { wallet -> val nftDetails = NFTDetails.from(nft) NFTDetailsScreen( - walletAddress = wallet.address, + walletId = wallet.walletId, walletKit = walletKit, nftDetails = nftDetails, onClose = { selectedNFT = null }, diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/NFTDetailsScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/NFTDetailsScreen.kt index 2532d49b..f368c1ca 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/NFTDetailsScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/NFTDetailsScreen.kt @@ -77,7 +77,7 @@ import io.ton.walletkit.demo.presentation.viewmodel.NFTDetailsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun NFTDetailsScreen( - walletAddress: String, + walletId: String, walletKit: ITONWalletKit, nftDetails: NFTDetails, onClose: () -> Unit, @@ -87,9 +87,9 @@ fun NFTDetailsScreen( var wallet by remember { mutableStateOf(null) } var isLoadingWallet by remember { mutableStateOf(true) } - LaunchedEffect(walletAddress) { + LaunchedEffect(walletId) { isLoadingWallet = true - wallet = walletKit.getWallets().firstOrNull { it.address().value == walletAddress } + wallet = walletKit.getWallet(walletId) isLoadingWallet = false } @@ -124,7 +124,7 @@ fun NFTDetailsScreen( } val viewModel: NFTDetailsViewModel = viewModel( - key = walletAddress + nftDetails.contractAddress, + key = walletId + nftDetails.contractAddress, factory = NFTDetailsViewModel.factory(wallet!!, nftDetails), ) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt index 0aa9cd6f..b444e9b6 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt @@ -73,12 +73,12 @@ fun SendTransactionScreen( walletKit: ITONWalletKit, onBack: () -> Unit, ) { - var tonWallet by remember(wallet.address) { mutableStateOf(null) } - var resolving by remember(wallet.address) { mutableStateOf(true) } + var tonWallet by remember(wallet.walletId) { mutableStateOf(null) } + var resolving by remember(wallet.walletId) { mutableStateOf(true) } - LaunchedEffect(wallet.address) { + LaunchedEffect(wallet.walletId) { resolving = true - tonWallet = walletKit.getWallets().firstOrNull { it.address().value == wallet.address } + tonWallet = walletKit.getWallet(wallet.walletId) resolving = false } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt index af732e99..dc90d2b1 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt @@ -269,7 +269,7 @@ fun WalletScreen( val session = if (browserSheet.injectTonConnect) injectedSession else plainSession if (session.tabs.isEmpty()) session.openTab(browserSheet.url) } - val activeWallet = state.wallets.firstOrNull { it.address == state.activeWalletAddress } + val activeWallet = state.wallets.firstOrNull { it.walletId == state.activeWalletId } ?: state.wallets.firstOrNull() if (showSheet) { ModalBottomSheet( @@ -392,7 +392,7 @@ fun WalletScreen( ) } - val activeIndex = state.wallets.indexOfFirst { it.address == state.activeWalletAddress } + val activeIndex = state.wallets.indexOfFirst { it.walletId == state.activeWalletId } val homeTitle = if (activeIndex >= 0) "Wallet ${activeIndex + 1}" else "Wallet" val homeAddress = activeWallet?.address.orEmpty() val nftsList by (nftsViewModel?.nfts?.collectAsState() ?: remember { mutableStateOf(emptyList()) }) @@ -418,7 +418,7 @@ fun WalletScreen( // Reset the sub-screen on wallet switch so the user lands on home for the // new wallet (mirrors iOS NavigationStack popping on `.id(active.id)`). - LaunchedEffect(state.activeWalletAddress) { + LaunchedEffect(state.activeWalletId) { subScreen = HomeSubScreen.None } if (subScreen != HomeSubScreen.None) { @@ -550,9 +550,9 @@ fun WalletScreen( }, assets = assetItems, nfts = nftPreviews, - onSend = { activeWallet?.let { actions.onSendFromWallet(it.address) } }, + onSend = { activeWallet?.let { actions.onSendFromWallet(it.walletId) } }, onSwap = { actions.onSwapClick() }, - onStake = { activeWallet?.let { actions.onStakeFromWallet(it.address) } }, + onStake = { activeWallet?.let { actions.onStakeFromWallet(it.walletId) } }, onShowAllAssets = { subScreen = HomeSubScreen.AllAssets }, onShowAllNFTs = { subScreen = HomeSubScreen.AllNFTs }, onNFTTap = { preview -> @@ -579,9 +579,9 @@ fun WalletScreen( ) { WalletsBottomSheet( wallets = state.wallets, - activeWalletAddress = state.activeWalletAddress, + activeWalletId = state.activeWalletId, onSelect = { wallet -> - actions.onSwitchWallet(wallet.address) + actions.onSwitchWallet(wallet.walletId) showWalletsSheet = false }, onCopyAddress = { address -> @@ -594,7 +594,7 @@ fun WalletScreen( }, onDelete = { wallet -> showWalletsSheet = false - actions.onRemoveWallet(wallet.address) + actions.onRemoveWallet(wallet.walletId) }, onClose = { showWalletsSheet = false }, ) @@ -613,7 +613,7 @@ fun WalletScreen( activeWallet?.let { wallet -> val nftDetails = NFTDetails.from(nft) NFTDetailsScreen( - walletAddress = wallet.address, + walletId = wallet.walletId, walletKit = walletKit, nftDetails = nftDetails, onClose = { selectedNFT = null }, diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt index 9d99d8b9..21c3945e 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt @@ -73,9 +73,9 @@ fun WalletsSection( } else { WalletCard( wallet = activeWallet, - onDetails = { onWalletSelected(activeWallet.address) }, - onSend = { onSendFromWallet(activeWallet.address) }, - onStake = { onStakeFromWallet(activeWallet.address) }, + onDetails = { onWalletSelected(activeWallet.walletId) }, + onSend = { onSendFromWallet(activeWallet.walletId) }, + onStake = { onStakeFromWallet(activeWallet.walletId) }, isStreamingConnected = isStreamingConnected, onRefresh = onRefresh, ) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sheet/StakingSheet.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sheet/StakingSheet.kt index 37dc8abe..4f5735d0 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sheet/StakingSheet.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sheet/StakingSheet.kt @@ -78,12 +78,12 @@ fun StakingSheet( sheetKey: Long, onDismiss: () -> Unit, ) { - var tonWallet by remember(wallet.address) { mutableStateOf(null) } - var isLoadingWallet by remember(wallet.address) { mutableStateOf(true) } + var tonWallet by remember(wallet.walletId) { mutableStateOf(null) } + var isLoadingWallet by remember(wallet.walletId) { mutableStateOf(true) } - LaunchedEffect(wallet.address) { + LaunchedEffect(wallet.walletId) { isLoadingWallet = true - tonWallet = walletKit.getWallets().firstOrNull { it.address().value == wallet.address } + tonWallet = walletKit.getWallet(wallet.walletId) isLoadingWallet = false } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sheet/WalletsBottomSheet.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sheet/WalletsBottomSheet.kt index 021cd839..35ed8bb5 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sheet/WalletsBottomSheet.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sheet/WalletsBottomSheet.kt @@ -55,7 +55,7 @@ import io.ton.walletkit.demo.presentation.model.WalletSummary @Composable fun WalletsBottomSheet( wallets: List, - activeWalletAddress: String?, + activeWalletId: String?, onSelect: (WalletSummary) -> Unit, onCopyAddress: (String) -> Unit, onAddWallet: () -> Unit, @@ -105,7 +105,7 @@ fun WalletsBottomSheet( WalletsBottomSheetRow( title = walletTitle(wallet, index), truncatedAddress = shortAddress(wallet.address), - isActive = wallet.address == activeWalletAddress, + isActive = wallet.walletId == activeWalletId, onSelect = { onSelect(wallet) }, onCopyAddress = { onCopyAddress(wallet.address) }, onMore = { actionSheetWallet = wallet }, diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/TonConnectViewModel.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/TonConnectViewModel.kt index 0d14e31d..ea17adf0 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/TonConnectViewModel.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/TonConnectViewModel.kt @@ -43,7 +43,7 @@ import kotlinx.coroutines.launch */ class TonConnectViewModel( private val walletKit: () -> ITONWalletKit, - private val getWalletByAddress: (String) -> ITONWallet?, + private val getWalletById: (String) -> ITONWallet?, private val onRequestApproved: () -> Unit = {}, private val onRequestRejected: () -> Unit = {}, private val onRequestFailed: (String) -> Unit = {}, @@ -63,11 +63,11 @@ class TonConnectViewModel( /** * Handle a TON Connect URL (universal link or QR code). */ - fun handleTonConnectUrl(url: String, walletAddress: String) { + fun handleTonConnectUrl(url: String, walletId: String) { viewModelScope.launch { _state.value = _state.value.copy(isProcessing = true, error = null) - val wallet = getWalletByAddress(walletAddress) + val wallet = getWalletById(walletId) if (wallet == null) { _state.value = _state.value.copy( isProcessing = false, @@ -124,13 +124,13 @@ class TonConnectViewModel( /** * Approve a connection request from a dApp. */ - fun approveConnect(request: ConnectRequestUi, walletAddress: String) { + fun approveConnect(request: ConnectRequestUi, walletId: String) { viewModelScope.launch { _state.value = _state.value.copy(isProcessing = true, error = null) runCatching { - val wallet = getWalletByAddress(walletAddress) - ?: error("Wallet not found for address: $walletAddress") + val wallet = getWalletById(walletId) + ?: error("Wallet not found for id: $walletId") val connectRequest = request.connectRequest ?: error("Connect request not available") connectRequest.approve(wallet) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt index 2b64d369..e8e3934a 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt @@ -111,7 +111,7 @@ class WalletKitViewModel @Inject constructor( private var streamingTransactionsJob: Job? = null private var streamingConnectionJob: Job? = null private var streamingJettonsJob: Job? = null - private var currentStreamingWalletAddress: String? = null + private var currentStreamingWalletId: String? = null private var currentStreamingNetwork: TONNetwork? = null private var walletKit: ITONWalletKit? = null @@ -148,7 +148,7 @@ class WalletKitViewModel @Inject constructor( private val tonConnectViewModel = TonConnectViewModel( walletKit = { walletKit ?: error("ITONWalletKit not initialized") }, - getWalletByAddress = { address -> lifecycleManager.tonWallets[address] }, + getWalletById = { walletId -> lifecycleManager.tonWallets[walletId] }, onRequestApproved = { onTonConnectRequestApproved() }, onRequestRejected = { onTonConnectRequestRejected() }, onRequestFailed = { message -> onTonConnectRequestFailed(message) }, @@ -157,8 +157,8 @@ class WalletKitViewModel @Inject constructor( ) private val walletOperationsViewModel = WalletOperationsViewModel( - getWalletByAddress = { address -> lifecycleManager.tonWallets[address] }, - onWalletSwitched = { address -> handleWalletSwitched(address) }, + getWalletById = { walletId -> lifecycleManager.tonWallets[walletId] }, + onWalletSwitched = { walletId -> handleWalletSwitched(walletId) }, ) // NFTs ViewModel for active wallet @@ -174,9 +174,9 @@ class WalletKitViewModel @Inject constructor( private var jettonsCollectors: List = emptyList() private var transactionsCollectors: List = emptyList() - private var currentTransactionsWalletAddress: String? = null - private var currentJettonsWalletAddress: String? = null - private var currentNftsWalletAddress: String? = null + private var currentTransactionsWalletId: String? = null + private var currentJettonsWalletId: String? = null + private var currentNftsWalletId: String? = null private sealed interface TonConnectAction { data class Connect(val request: ConnectRequestUi, val wallet: WalletSummary?) : TonConnectAction @@ -199,6 +199,27 @@ class WalletKitViewModel @Inject constructor( return kit } + private suspend fun refreshWalletBalance(walletId: String) { + val balance = lifecycleManager.refreshBalance(walletId) ?: return + _state.update { st -> + st.copy( + wallets = st.wallets.map { + if (it.walletId == walletId) { + it.copy(balanceNano = balance.nano, balance = balance.formatted, lastUpdated = System.currentTimeMillis()) + } else { + it + } + }, + ) + } + } + + private fun addressForWalletId(walletId: String?): String? { + if (walletId == null) return null + return state.value.wallets.firstOrNull { it.walletId == walletId }?.address + ?: lifecycleManager.tonWallets[walletId]?.address()?.value + } + init { // Wait for SDK initialization before bootstrapping viewModelScope.launch { @@ -264,7 +285,7 @@ class WalletKitViewModel @Inject constructor( if (!savedActiveWallet.isNullOrBlank() && tonWallets.containsKey(savedActiveWallet)) { Log.d(LOG_TAG, "Restored active wallet selection: $savedActiveWallet") applyWalletSwitch( - address = savedActiveWallet, + walletId = savedActiveWallet, persistPreference = false, logSwitch = false, refreshOnSwitch = false, @@ -273,21 +294,21 @@ class WalletKitViewModel @Inject constructor( if (!savedActiveWallet.isNullOrBlank()) { Log.w(LOG_TAG, "Saved active wallet '$savedActiveWallet' not found in loaded wallets, using first wallet instead") } - state.value.activeWalletAddress?.let { address -> - if (tonWallets.containsKey(address)) { + state.value.activeWalletId?.let { walletId -> + if (tonWallets.containsKey(walletId)) { applyWalletSwitch( - address = address, + walletId = walletId, persistPreference = false, logSwitch = false, refreshOnSwitch = false, ) } else { - Log.w(LOG_TAG, "Active wallet address '$address' not found in loaded wallets") + Log.w(LOG_TAG, "Active wallet id '$walletId' not found in loaded wallets") } } } - syncStreamingObservers(_state.value.activeWalletAddress) + syncStreamingObservers(_state.value.activeWalletId) startBalancePolling() } @@ -384,14 +405,14 @@ class WalletKitViewModel @Inject constructor( } private fun handleWalletSwitched( - address: String, + walletId: String, persistPreference: Boolean = true, logSwitch: Boolean = true, refreshOnSwitch: Boolean = true, ) { viewModelScope.launch { applyWalletSwitch( - address = address, + walletId = walletId, persistPreference = persistPreference, logSwitch = logSwitch, refreshOnSwitch = refreshOnSwitch, @@ -400,34 +421,35 @@ class WalletKitViewModel @Inject constructor( } private suspend fun applyWalletSwitch( - address: String, + walletId: String, persistPreference: Boolean, logSwitch: Boolean, refreshOnSwitch: Boolean, ) { - val wallet = lifecycleManager.tonWallets[address] + val wallet = lifecycleManager.tonWallets[walletId] if (wallet == null) { _state.update { it.copy(error = uiString(R.string.wallet_error_wallet_not_found)) } return } - uiCoordinator.setActiveWallet(address) - syncStreamingObservers(address) + uiCoordinator.setActiveWallet(walletId) + syncStreamingObservers(walletId) if (persistPreference) { - lifecycleManager.persistActiveWalletPreference(address) + lifecycleManager.persistActiveWalletPreference(walletId) } - updateNftsViewModel(address) - attachTransactionHistoryViewModel(address) - attachJettonsViewModel(address) + updateNftsViewModel(walletId) + attachTransactionHistoryViewModel(walletId) + attachJettonsViewModel(walletId) if (refreshOnSwitch) { - refreshWallets() + refreshWalletBalance(walletId) } if (logSwitch) { - val walletName = lifecycleManager.walletMetadata[address]?.name ?: wallet.address().value ?: address + val address = wallet.address().value + val walletName = address?.let { lifecycleManager.walletMetadata[it]?.name } ?: address ?: walletId eventLogger.log(R.string.wallet_event_switched_wallet, walletName) } } @@ -457,7 +479,7 @@ class WalletKitViewModel @Inject constructor( eventLogger.showTemporaryStatus(uiString(R.string.wallet_status_signed_success)) if (action.viaSigner) { viewModelScope.launch { - if (state.value.activeWalletAddress == action.request.walletAddress) { + if (addressForWalletId(state.value.activeWalletId) == action.request.walletAddress) { activeTransactionHistoryViewModel.value?.refresh() } } @@ -532,44 +554,44 @@ class WalletKitViewModel @Inject constructor( } Log.d( LOG_TAG, - "refreshWallets: start active=${state.value.activeWalletAddress} cached=${lifecycleManager.tonWallets.keys}", + "refreshWallets: start active=${state.value.activeWalletId} cached=${lifecycleManager.tonWallets.keys}", ) val summaries = runCatching { lifecycleManager.loadWalletSummaries(state.value.sessions) } summaries.onSuccess { wallets -> val now = System.currentTimeMillis() - Log.d(LOG_TAG, "refreshWallets: loaded ${wallets.size} summaries -> ${wallets.map { it.address }}") + Log.d(LOG_TAG, "refreshWallets: loaded ${wallets.size} summaries -> ${wallets.map { it.walletId }}") // Set active wallet based on saved preference or default to first - val activeAddress = state.value.activeWalletAddress - val newActiveAddress = when { + val activeId = state.value.activeWalletId + val newActiveId = when { wallets.isEmpty() -> null // Keep current active wallet if it still exists - activeAddress != null && wallets.any { it.address == activeAddress } -> activeAddress + activeId != null && wallets.any { it.walletId == activeId } -> activeId // Otherwise use first wallet - else -> wallets.firstOrNull()?.address + else -> wallets.firstOrNull()?.walletId } _state.update { it.copy( wallets = wallets, - activeWalletAddress = newActiveAddress, + activeWalletId = newActiveId, lastUpdated = now, error = null, ) } - if (activeAddress != newActiveAddress || lifecycleManager.lastPersistedActiveWallet != newActiveAddress) { - lifecycleManager.persistActiveWalletPreference(newActiveAddress) + if (activeId != newActiveId || lifecycleManager.lastPersistedActiveWallet != newActiveId) { + lifecycleManager.persistActiveWalletPreference(newActiveId) } - if (currentTransactionsWalletAddress != newActiveAddress) { - attachTransactionHistoryViewModel(newActiveAddress) + if (currentTransactionsWalletId != newActiveId) { + attachTransactionHistoryViewModel(newActiveId) } - if (currentJettonsWalletAddress != newActiveAddress) { - attachJettonsViewModel(newActiveAddress) + if (currentJettonsWalletId != newActiveId) { + attachJettonsViewModel(newActiveId) } - updateNftsViewModel(newActiveAddress) - syncStreamingObservers(newActiveAddress) + updateNftsViewModel(newActiveId) + syncStreamingObservers(newActiveId) }.onFailure { error -> Log.e(LOG_TAG, "refreshWallets: loadWalletSummaries failed", error) val fallback = uiString(R.string.wallet_error_load_default) @@ -580,7 +602,7 @@ class WalletKitViewModel @Inject constructor( } Log.d( LOG_TAG, - "refreshWallets: done active=${_state.value.activeWalletAddress} wallets=${_state.value.wallets.map { it.address }}", + "refreshWallets: done active=${_state.value.activeWalletId} wallets=${_state.value.wallets.map { it.walletId }}", ) } @@ -592,8 +614,8 @@ class WalletKitViewModel @Inject constructor( uiCoordinator.openAddWalletSheet() } - fun showWalletDetails(address: String) { - val target = state.value.wallets.firstOrNull { it.address == address } + fun showWalletDetails(walletId: String) { + val target = state.value.wallets.firstOrNull { it.walletId == walletId } if (target != null) { uiCoordinator.showWalletDetails(target) } @@ -713,12 +735,14 @@ class WalletKitViewModel @Inject constructor( if (result.isSuccess) { val newWallet = result.getOrNull() - var newAddress: String? = null - newWallet?.address()?.let { address -> - newAddress = address.value - lifecycleManager.tonWallets[address.value] = newWallet + var newWalletId: String? = null + if (newWallet != null) { + val walletId = newWallet.identifier() + newWalletId = walletId + lifecycleManager.tonWallets[walletId] = newWallet - lifecycleManager.walletMetadata[address.value] = pendingMetadata + val address = newWallet.address().value + lifecycleManager.walletMetadata[address] = pendingMetadata val record = WalletRecord( mnemonic = cleaned, name = pendingMetadata.name, @@ -726,17 +750,17 @@ class WalletKitViewModel @Inject constructor( version = version, interfaceType = interfaceType.value, ) - runCatching { storage.saveWallet(address.value, record) } - .onSuccess { Log.d(LOG_TAG, "importWallet: saved wallet record for ${address.value}") } - .onFailure { Log.e(LOG_TAG, "importWallet: failed to save wallet record for ${address.value}", it) } + runCatching { storage.saveWallet(address, record) } + .onSuccess { Log.d(LOG_TAG, "importWallet: saved wallet record for $address") } + .onFailure { Log.e(LOG_TAG, "importWallet: failed to save wallet record for $address", it) } } - newAddress?.let { address -> - _state.update { it.copy(activeWalletAddress = address) } - lifecycleManager.persistActiveWalletPreference(address) - updateNftsViewModel(address) + newWalletId?.let { walletId -> + _state.update { it.copy(activeWalletId = walletId) } + lifecycleManager.persistActiveWalletPreference(walletId) + updateNftsViewModel(walletId) loadJettons() - Log.d(LOG_TAG, "Auto-switched to newly imported wallet: $address") + Log.d(LOG_TAG, "Auto-switched to newly imported wallet: $walletId") } refreshWallets() dismissSheet() @@ -798,8 +822,9 @@ class WalletKitViewModel @Inject constructor( if (result.isSuccess) { val (newWallet, generatedMnemonic) = result.getOrThrow() + val newWalletId = newWallet.identifier() val newAddress = newWallet.address().value - lifecycleManager.tonWallets[newAddress] = newWallet + lifecycleManager.tonWallets[newWalletId] = newWallet lifecycleManager.walletMetadata[newAddress] = pendingMetadata // Always save the generated mnemonic so the wallet can be restored on restart. @@ -816,11 +841,11 @@ class WalletKitViewModel @Inject constructor( runCatching { storage.saveWallet(newAddress, record) } .onSuccess { Log.d(LOG_TAG, "generateWallet: saved wallet record for $newAddress") } .onFailure { Log.e(LOG_TAG, "generateWallet: failed to save wallet record for $newAddress", it) } - _state.update { it.copy(activeWalletAddress = newAddress) } - lifecycleManager.persistActiveWalletPreference(newAddress) - updateNftsViewModel(newAddress) + _state.update { it.copy(activeWalletId = newWalletId) } + lifecycleManager.persistActiveWalletPreference(newWalletId) + updateNftsViewModel(newWalletId) loadJettons() - Log.d(LOG_TAG, "Auto-switched to newly generated wallet: $newAddress") + Log.d(LOG_TAG, "Auto-switched to newly generated wallet: $newWalletId") refreshWallets() dismissSheet() @@ -838,17 +863,17 @@ class WalletKitViewModel @Inject constructor( } fun handleTonConnectUrl(url: String) { - val activeAddress = state.value.activeWalletAddress - if (activeAddress == null) { + val activeWalletId = state.value.activeWalletId + if (activeWalletId == null) { _state.update { it.copy(error = uiString(R.string.wallet_error_no_wallet_selected)) } return } - tonConnectViewModel.handleTonConnectUrl(url.trim(), activeAddress) + tonConnectViewModel.handleTonConnectUrl(url.trim(), activeWalletId) } fun approveConnect(request: ConnectRequestUi, wallet: WalletSummary) { pendingTonConnectAction = TonConnectAction.Connect(request, wallet) - tonConnectViewModel.approveConnect(request, wallet.address) + tonConnectViewModel.approveConnect(request, wallet.walletId) } fun rejectConnect(request: ConnectRequestUi, reason: String = DEFAULT_REJECTION_REASON) { @@ -920,24 +945,24 @@ class WalletKitViewModel @Inject constructor( sessionsViewModel.disconnectSession(sessionId) } - fun openSendTransactionSheet(walletAddress: String) { - val wallet = state.value.wallets.firstOrNull { it.address == walletAddress } + fun openSendTransactionSheet(walletId: String) { + val wallet = state.value.wallets.firstOrNull { it.walletId == walletId } if (wallet != null) { uiCoordinator.openSendTransactionSheet(wallet) } } fun openSwapSheet() { - val activeAddress = state.value.activeWalletAddress ?: state.value.wallets.firstOrNull()?.address ?: return - val walletSummary = state.value.wallets.firstOrNull { it.address == activeAddress } ?: return - val tonWallet = lifecycleManager.tonWallets[activeAddress] ?: return + val activeId = state.value.activeWalletId ?: state.value.wallets.firstOrNull()?.walletId ?: return + val walletSummary = state.value.wallets.firstOrNull { it.walletId == activeId } ?: return + val tonWallet = lifecycleManager.tonWallets[activeId] ?: return val kit = walletKit ?: return _swapViewModel.value = SwapViewModel(wallet = tonWallet, kit = kit) uiCoordinator.openSwapSheet(walletSummary) } - fun openStakingSheet(walletAddress: String) { - val wallet = state.value.wallets.firstOrNull { it.address == walletAddress } + fun openStakingSheet(walletId: String) { + val wallet = state.value.wallets.firstOrNull { it.walletId == walletId } if (wallet != null) { uiCoordinator.openStakingSheet(wallet) } @@ -947,43 +972,43 @@ class WalletKitViewModel @Inject constructor( uiCoordinator.toggleWalletSwitcher() } - fun switchWallet(address: String) { - walletOperationsViewModel.switchWallet(address) + fun switchWallet(walletId: String) { + walletOperationsViewModel.switchWallet(walletId) } /** * Update the NFTs ViewModel for the given wallet address. */ - private fun updateNftsViewModel(address: String?) { - if (address == null) { + private fun updateNftsViewModel(walletId: String?) { + if (walletId == null) { _nftsViewModel.value = null - currentNftsWalletAddress = null + currentNftsWalletId = null return } - if (currentNftsWalletAddress == address && _nftsViewModel.value != null) { + if (currentNftsWalletId == walletId && _nftsViewModel.value != null) { return } - val wallet = lifecycleManager.tonWallets[address] + val wallet = lifecycleManager.tonWallets[walletId] if (wallet == null) { - Log.w(LOG_TAG, "updateNftsViewModel: wallet not found for address $address") + Log.w(LOG_TAG, "updateNftsViewModel: wallet not found for id $walletId") _nftsViewModel.value = null - currentNftsWalletAddress = null + currentNftsWalletId = null return } _nftsViewModel.value = NFTsListViewModel(wallet) - currentNftsWalletAddress = address - Log.d(LOG_TAG, "updateNftsViewModel: created NFTsListViewModel for $address") + currentNftsWalletId = walletId + Log.d(LOG_TAG, "updateNftsViewModel: created NFTsListViewModel for $walletId") } - private fun attachTransactionHistoryViewModel(address: String?) { + private fun attachTransactionHistoryViewModel(walletId: String?) { transactionsCollectors.forEach { it.cancel() } transactionsCollectors = emptyList() - if (address == null) { + if (walletId == null) { activeTransactionHistoryViewModel.value = null - currentTransactionsWalletAddress = null + currentTransactionsWalletId = null _state.update { current -> current.copy( isLoadingTransactions = false, @@ -993,24 +1018,24 @@ class WalletKitViewModel @Inject constructor( return } - val wallet = lifecycleManager.tonWallets[address] + val wallet = lifecycleManager.tonWallets[walletId] if (wallet == null) { - Log.w(LOG_TAG, "attachTransactionHistoryViewModel: wallet not found for $address") + Log.w(LOG_TAG, "attachTransactionHistoryViewModel: wallet not found for $walletId") activeTransactionHistoryViewModel.value = null - currentTransactionsWalletAddress = null + currentTransactionsWalletId = null _state.update { it.copy(isLoadingTransactions = false) } return } val viewModel = TransactionHistoryViewModel(wallet, lifecycleManager.transactionCache) activeTransactionHistoryViewModel.value = viewModel - currentTransactionsWalletAddress = address + currentTransactionsWalletId = walletId val transactionsJob = viewModelScope.launch { viewModel.transactions.collect { transactions -> _state.update { current -> val updatedWallets = current.wallets.map { summary -> - if (summary.address == address) { + if (summary.walletId == walletId) { summary.copy(transactions = transactions) } else { summary @@ -1041,12 +1066,12 @@ class WalletKitViewModel @Inject constructor( viewModel.loadTransactions(limit = TRANSACTION_FETCH_LIMIT) } - private fun attachJettonsViewModel(address: String?) { + private fun attachJettonsViewModel(walletId: String?) { jettonsCollectors.forEach { it.cancel() } jettonsCollectors = emptyList() - if (address == null) { + if (walletId == null) { activeJettonsViewModel.value = null - currentJettonsWalletAddress = null + currentJettonsWalletId = null _state.update { it.copy( jettons = emptyList(), @@ -1058,11 +1083,11 @@ class WalletKitViewModel @Inject constructor( return } - val wallet = lifecycleManager.tonWallets[address] + val wallet = lifecycleManager.tonWallets[walletId] if (wallet == null) { - Log.w(LOG_TAG, "attachJettonsViewModel: wallet not found for $address") + Log.w(LOG_TAG, "attachJettonsViewModel: wallet not found for $walletId") activeJettonsViewModel.value = null - currentJettonsWalletAddress = null + currentJettonsWalletId = null _state.update { it.copy( jettons = emptyList(), @@ -1076,7 +1101,7 @@ class WalletKitViewModel @Inject constructor( val viewModel = JettonsListViewModel(wallet) activeJettonsViewModel.value = viewModel - currentJettonsWalletAddress = address + currentJettonsWalletId = walletId val dataJob = viewModelScope.launch { viewModel.jettons.collect { jettons -> @@ -1128,10 +1153,10 @@ class WalletKitViewModel @Inject constructor( viewModel.loadJettons() } - fun refreshTransactions(address: String? = state.value.activeWalletAddress, limit: Int = TRANSACTION_FETCH_LIMIT) { - val targetAddress = address ?: return - if (currentTransactionsWalletAddress != targetAddress) { - attachTransactionHistoryViewModel(targetAddress) + fun refreshTransactions(walletId: String? = state.value.activeWalletId, limit: Int = TRANSACTION_FETCH_LIMIT) { + val targetWalletId = walletId ?: return + if (currentTransactionsWalletId != targetWalletId) { + attachTransactionHistoryViewModel(targetWalletId) return } activeTransactionHistoryViewModel.value?.loadTransactions(limit = limit) @@ -1155,14 +1180,14 @@ class WalletKitViewModel @Inject constructor( uiCoordinator.showTransactionDetail(SheetState.TransactionDetail(detail)) } - fun removeWallet(address: String) { + fun removeWallet(walletId: String) { viewModelScope.launch { - // SDK keys wallets by walletId; our cache maps address → ITONWallet. - val walletId = lifecycleManager.tonWallets[address]?.identifier() - if (walletId == null) { + val wallet = lifecycleManager.tonWallets[walletId] + if (wallet == null) { _state.update { it.copy(error = uiString(R.string.wallet_error_wallet_not_found)) } return@launch } + val address = wallet.address().value val kit = getKit() val removeResult = runCatching { kit.removeWallet(walletId) } @@ -1181,9 +1206,8 @@ class WalletKitViewModel @Inject constructor( } // Remove from local cache - lifecycleManager.tonWallets.remove(address) + lifecycleManager.tonWallets.remove(walletId) - // Clear local storage entry runCatching { storage.clear(address) } .onSuccess { Log.d(LOG_TAG, "removeWallet: cleared storage entry for $address") } .onFailure { Log.w(LOG_TAG, "removeWallet: failed to clear storage for $address", it) } @@ -1193,33 +1217,33 @@ class WalletKitViewModel @Inject constructor( lifecycleManager.walletMetadata.remove(address) - val walletName = state.value.wallets.firstOrNull { it.address == address }?.name + val walletName = state.value.wallets.firstOrNull { it.walletId == walletId }?.name ?: uiString(R.string.wallet_default_name_fallback) - val previousActiveAddress = state.value.activeWalletAddress - var updatedActiveAddress: String? = null + val previousActiveId = state.value.activeWalletId + var updatedActiveId: String? = null _state.update { - val filteredWallets = it.wallets.filterNot { summary -> summary.address == address } - val newActiveAddress = when { + val filteredWallets = it.wallets.filterNot { summary -> summary.walletId == walletId } + val newActiveId = when { filteredWallets.isEmpty() -> null - it.activeWalletAddress == address -> filteredWallets.first().address - else -> it.activeWalletAddress + it.activeWalletId == walletId -> filteredWallets.first().walletId + else -> it.activeWalletId } - updatedActiveAddress = newActiveAddress + updatedActiveId = newActiveId it.copy( wallets = filteredWallets, - activeWalletAddress = newActiveAddress, + activeWalletId = newActiveId, isWalletSwitcherExpanded = if (filteredWallets.size <= 1) false else it.isWalletSwitcherExpanded, ) } - if (previousActiveAddress != updatedActiveAddress) { - lifecycleManager.persistActiveWalletPreference(updatedActiveAddress) + if (previousActiveId != updatedActiveId) { + lifecycleManager.persistActiveWalletPreference(updatedActiveId) } - updateNftsViewModel(updatedActiveAddress) - attachTransactionHistoryViewModel(updatedActiveAddress) - attachJettonsViewModel(updatedActiveAddress) + updateNftsViewModel(updatedActiveId) + attachTransactionHistoryViewModel(updatedActiveId) + attachJettonsViewModel(updatedActiveId) refreshWallets() sessionsViewModel.refresh() // Refresh to update UI with removed sessions @@ -1285,9 +1309,10 @@ class WalletKitViewModel @Inject constructor( _createWalletFlow.value = CreateWalletFlow.Idle } - fun renameWallet(address: String, newName: String) { - val metadata = lifecycleManager.walletMetadata[address] - if (metadata == null) { + fun renameWallet(walletId: String, newName: String) { + val address = addressForWalletId(walletId) + val metadata = address?.let { lifecycleManager.walletMetadata[it] } + if (address == null || metadata == null) { _state.update { it.copy(error = uiString(R.string.wallet_error_wallet_not_found)) } return } @@ -1391,7 +1416,8 @@ class WalletKitViewModel @Inject constructor( private fun onTransactionRequest(request: TONWalletTransactionRequest) { Log.d(LOG_TAG, "=== onTransactionRequest called ===") - val walletAddress = state.value.activeWalletAddress ?: "" + val activeWallet = state.value.activeWalletId?.let { lifecycleManager.tonWallets[it] } + val walletAddress = activeWallet?.address()?.value ?: "" val dAppInfo = request.event.dAppInfo val fallbackDAppName = uiString(R.string.wallet_event_generic_dapp) val txRequest = request.event.request @@ -1399,7 +1425,7 @@ class WalletKitViewModel @Inject constructor( Log.d(LOG_TAG, "Transaction request - walletAddress: $walletAddress, dAppName: ${dAppInfo?.name}") viewModelScope.launch { - val wallet = lifecycleManager.tonWallets[walletAddress] + val wallet = activeWallet if (wallet != null) { try { val balance = wallet.balance() @@ -1452,7 +1478,7 @@ class WalletKitViewModel @Inject constructor( val event = request.event val dAppInfo = event.dAppInfo val fallbackDAppName = uiString(R.string.wallet_event_generic_dapp) - val walletAddress = event.walletAddress?.value ?: state.value.activeWalletAddress ?: "" + val walletAddress = event.walletAddress?.value ?: addressForWalletId(state.value.activeWalletId) ?: "" val messages = event.request.messages.map { msg -> TransactionMessageUi( @@ -1490,7 +1516,7 @@ class WalletKitViewModel @Inject constructor( val uiRequest = SignDataRequestUi( id = request.hashCode().toString(), - walletAddress = request.event.walletAddress?.value ?: state.value.activeWalletAddress ?: "", + walletAddress = request.event.walletAddress?.value ?: addressForWalletId(state.value.activeWalletId) ?: "", dAppName = dAppInfo?.name, payloadType = payloadType, payloadContent = payloadContent, @@ -1538,11 +1564,12 @@ class WalletKitViewModel @Inject constructor( } } - private fun syncStreamingObservers(address: String?) { - val network = resolveStreamingNetwork(address) + private fun syncStreamingObservers(walletId: String?) { + val network = resolveStreamingNetwork(walletId) + val address = addressForWalletId(walletId) if ( - address == currentStreamingWalletAddress && + walletId == currentStreamingWalletId && network == currentStreamingNetwork && streamingBalanceJob?.isActive == true && streamingTransactionsJob?.isActive == true && @@ -1560,16 +1587,16 @@ class WalletKitViewModel @Inject constructor( streamingTransactionsJob = null streamingConnectionJob = null streamingJettonsJob = null - currentStreamingWalletAddress = address + currentStreamingWalletId = walletId currentStreamingNetwork = network - if (address == null || network == null) { + if (walletId == null || address == null || network == null) { Log.d(LOG_TAG, "STREAMING: observers stopped - no active wallet") _state.update { it.copy(isStreamingConnected = null) } return } - Log.d(LOG_TAG, "STREAMING: subscribing for wallet=$address network=${network.chainId}") + Log.d(LOG_TAG, "STREAMING: subscribing for wallet=$walletId address=$address network=${network.chainId}") streamingConnectionJob = viewModelScope.launch { try { @@ -1607,7 +1634,7 @@ class WalletKitViewModel @Inject constructor( _state.update { state -> state.copy( wallets = state.wallets.map { wallet -> - if (wallet.address == address) { + if (wallet.walletId == walletId) { wallet.copy( balanceNano = update.rawBalance, balance = TonFormatter.formatTon(update.rawBalance), @@ -1632,7 +1659,7 @@ class WalletKitViewModel @Inject constructor( val kit = getKit() kit.streaming().transactions(network, address).collect { update -> Log.d(LOG_TAG, "STREAMING: transactions updated count=${update.transactions.size}") - refreshTransactions(address) + refreshTransactions(walletId) } } catch (e: CancellationException) { throw e @@ -1681,13 +1708,13 @@ class WalletKitViewModel @Inject constructor( } } - private fun resolveStreamingNetwork(address: String?): TONNetwork? { - if (address == null) { + private fun resolveStreamingNetwork(walletId: String?): TONNetwork? { + if (walletId == null) { return null } - return state.value.wallets.firstOrNull { it.address == address }?.network - ?: lifecycleManager.walletMetadata[address]?.network + return state.value.wallets.firstOrNull { it.walletId == walletId }?.network + ?: addressForWalletId(walletId)?.let { lifecycleManager.walletMetadata[it]?.network } ?: DEFAULT_NETWORK } @@ -1859,13 +1886,13 @@ class WalletKitViewModel @Inject constructor( * Load jettons for the active wallet. */ fun loadJettons() { - val address = state.value.activeWalletAddress - if (address == null) { + val walletId = state.value.activeWalletId + if (walletId == null) { Log.w(LOG_TAG, "loadJettons: No active wallet") return } - if (currentJettonsWalletAddress != address) { - attachJettonsViewModel(address) + if (currentJettonsWalletId != walletId) { + attachJettonsViewModel(walletId) } else { activeJettonsViewModel.value?.loadJettons() } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletLifecycleManager.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletLifecycleManager.kt index a5deb7ff..1e5de4b6 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletLifecycleManager.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletLifecycleManager.kt @@ -67,19 +67,18 @@ class WalletLifecycleManager( suspend fun bootstrap(): Result = runCatching { val userPrefs = storage.loadUserPreferences() - lastPersistedActiveWallet = userPrefs?.activeWalletAddress + lastPersistedActiveWallet = userPrefs?.activeWalletId val kit = kitProvider() val wallets = kit.getWallets() tonWallets.clear() wallets.forEach { wallet -> - wallet.address().value?.let { tonWallets[it] = wallet } + tonWallets[wallet.identifier()] = wallet } val metadataCorrections = mutableListOf() for (wallet in wallets) { val address = wallet.address().value ?: continue - tonWallets[address] = wallet if (walletMetadata[address] == null) { val storedRecord = storage.loadWallet(address) if (storedRecord != null) { @@ -113,14 +112,14 @@ class WalletLifecycleManager( val freshWallets = kit.getWallets() // If the bridge returns empty but we have cached wallets, the bridge is likely // reinitializing (e.g. after the in-app WebView browser was closed on a slow CI - // emulator). Preserve the cache so activeWalletAddress is not incorrectly nulled out. + // emulator). Preserve the cache so the active wallet is not incorrectly nulled out. val wallets = if (freshWallets.isEmpty() && tonWallets.isNotEmpty()) { Log.w(LOG_TAG, "loadWalletSummaries: kit returned empty but cache has ${tonWallets.size} wallets – reusing cache") tonWallets.values.toList() } else { tonWallets.clear() freshWallets.forEach { wallet -> - wallet.address().value?.let { tonWallets[it] = wallet } + tonWallets[wallet.identifier()] = wallet } freshWallets } @@ -130,6 +129,7 @@ class WalletLifecycleManager( val result = mutableListOf() for (wallet in wallets) { + val walletId = wallet.identifier() val address = wallet.address().value val metadata = ensureMetadataForAddress(address) @@ -148,6 +148,7 @@ class WalletLifecycleManager( result.add( WalletSummary( + walletId = walletId, address = address, name = metadata.name, network = metadata.network, @@ -166,11 +167,21 @@ class WalletLifecycleManager( return result } - suspend fun persistActiveWalletPreference(address: String?) { - if (lastPersistedActiveWallet == address) return - val updatedPrefs = UserPreferences(activeWalletAddress = address) + data class BalanceUpdate(val nano: String, val formatted: String) + + suspend fun refreshBalance(walletId: String): BalanceUpdate? { + val wallet = tonWallets[walletId] ?: return null + val balance = runCatching { wallet.balance() } + .onFailure { Log.e(LOG_TAG, "refreshBalance: balance failed for $walletId", it) } + .getOrNull() ?: return null + return BalanceUpdate(balance.value, TonFormatter.formatTon(balance.value)) + } + + suspend fun persistActiveWalletPreference(walletId: String?) { + if (lastPersistedActiveWallet == walletId) return + val updatedPrefs = UserPreferences(activeWalletId = walletId) storage.saveUserPreferences(updatedPrefs) - lastPersistedActiveWallet = address + lastPersistedActiveWallet = walletId } suspend fun switchNetworkIfNeeded(target: TONNetwork, onRefresh: suspend () -> Unit) { diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletOperationsViewModel.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletOperationsViewModel.kt index 71ec1320..2b8ee535 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletOperationsViewModel.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletOperationsViewModel.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.launch * Sending tokens is handled by [SendTokensViewModel]. */ class WalletOperationsViewModel( - private val getWalletByAddress: (String) -> ITONWallet?, + private val getWalletById: (String) -> ITONWallet?, private val onWalletSwitched: (String) -> Unit = {}, ) : ViewModel() { @@ -43,7 +43,7 @@ class WalletOperationsViewModel( val state: StateFlow = _state.asStateFlow() data class WalletOperationsState( - val activeWalletAddress: String? = null, + val activeWalletId: String? = null, val isSendingTransaction: Boolean = false, val error: String? = null, val successMessage: String? = null, @@ -52,20 +52,20 @@ class WalletOperationsViewModel( /** * Switch to a different wallet. */ - fun switchWallet(address: String) { + fun switchWallet(walletId: String) { viewModelScope.launch { _state.value = _state.value.copy(error = null) - val wallet = getWalletByAddress(address) + val wallet = getWalletById(walletId) if (wallet == null) { _state.value = _state.value.copy(error = "Wallet not found") return@launch } - _state.value = _state.value.copy(activeWalletAddress = address) - onWalletSwitched(address) + _state.value = _state.value.copy(activeWalletId = walletId) + onWalletSwitched(walletId) - Log.d(TAG, "Switched to wallet: $address") + Log.d(TAG, "Switched to wallet: $walletId") } } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletUiStateCoordinator.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletUiStateCoordinator.kt index 75f8b42d..4aa03820 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletUiStateCoordinator.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletUiStateCoordinator.kt @@ -103,9 +103,9 @@ class WalletUiStateCoordinator( state.update { it.copy(isWalletSwitcherExpanded = false) } } - fun setActiveWallet(address: String?) { + fun setActiveWallet(walletId: String?) { state.update { - it.copy(activeWalletAddress = address, isWalletSwitcherExpanded = false, error = null) + it.copy(activeWalletId = walletId, isWalletSwitcherExpanded = false, error = null) } } diff --git a/AndroidDemo/gradle/libs.versions.toml b/AndroidDemo/gradle/libs.versions.toml index 7051217d..7508bef7 100644 --- a/AndroidDemo/gradle/libs.versions.toml +++ b/AndroidDemo/gradle/libs.versions.toml @@ -16,6 +16,7 @@ kotlinxSerialization = "1.9.0" tinkAndroid = "1.20.0" walletkitAndroid = "0.0.1" webkit = "1.15.0" +tracing = "1.2.0" datastorePreferences = "1.2.0" securityCrypto = "1.1.0" biometric = "1.2.0-alpha05" @@ -52,6 +53,7 @@ androidxLifecycleViewmodelCompose = { module = "androidx.lifecycle:lifecycle-vie kotlinxCoroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesAndroid" } kotlinxSerializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } androidxWebkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } +androidxTracing = { module = "androidx.tracing:tracing", version.ref = "tracing" } androidxDatastorePreferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidxSecurityCrypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } androidxBiometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } diff --git a/README.md b/README.md index 27275bd4..02321ca5 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,26 @@ The script runs five steps: # Custom kit location KIT_DIR=/path/to/walletkit ./Scripts/rebuild-sdk.sh ``` + +## Profiling & benchmarks + +Native performance tooling is in [Scripts/profiling/](Scripts/profiling/). The SDK is instrumented with `androidx.tracing` markers that show up as named slices in a trace: `WalletKit.rpc:*` (forward calls), `WalletKit.reverse:*` (signing), `WalletKit.adapterCall:*`, `WalletKit.initWebView`. + +Capture a Perfetto trace on a device and analyze it offline (no in-IDE profiler): + +```sh +Scripts/profiling/capture-perfetto.sh io.ton.walletkit.demo 60 # exercise the app during the capture +Scripts/profiling/analyze-trace.sh # per-method bridge cost, CPU/thread, UI jank +``` + +Or drag the `.pftrace` into https://ui.perfetto.dev. On an emulator the async `rpc` wall-clock is noise — trust the per-thread CPU and the `encode`/`reverse` slices. `record-simpleperf.sh` produces a sampled CPU flame graph. + +Per-method microbenchmarks for the deterministic value types (address/hex/base64/token math): + +```sh +cd TONWalletKit-Android +./gradlew :microbenchmark:connectedReleaseAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.suppressErrors=EMULATOR,UNLOCKED,LOW-BATTERY +``` + +The `suppressErrors` flag is only needed on an emulator. diff --git a/Scripts/profiling/analyze-trace.sh b/Scripts/profiling/analyze-trace.sh new file mode 100755 index 00000000..3df7deb2 --- /dev/null +++ b/Scripts/profiling/analyze-trace.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Analyze a captured .pftrace offline and print an emulator-robust SDK report. +# Uses Perfetto's trace_processor (cached under ~/.cache/walletkit-profiling). +# +# Usage: ./analyze-trace.sh [trace.pftrace] # omit -> newest in captures/ +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +TRACE="${1:-}" +if [[ -z "$TRACE" ]]; then + TRACE="$(ls -t "$SCRIPT_DIR"/captures/*.pftrace 2>/dev/null | head -1 || true)" +fi +if [[ -z "$TRACE" || ! -f "$TRACE" ]]; then + echo "No trace found. Pass a .pftrace path, or run capture-perfetto.sh first." >&2 + exit 1 +fi + +CACHE="$HOME/.cache/walletkit-profiling" +TP="$CACHE/trace_processor" +mkdir -p "$CACHE" +if [[ ! -x "$TP" ]]; then + echo "Fetching Perfetto trace_processor (one-time) from get.perfetto.dev ..." + curl -LsS https://get.perfetto.dev/trace_processor -o "$TP" + chmod +x "$TP" +fi + +# On an emulator the async WalletKit.rpc:* wall-clock is dominated by the software +# RenderThread + JIT; trust the encode/reverse slices and per-thread CPU instead. +run_q() { + local title="$1" sql="$2" tmp + tmp="$(mktemp)" + printf '%s\n' "$sql" > "$tmp" + echo + echo "── $title ──" + "$TP" "$TRACE" -q "$tmp" 2>/dev/null \ + | grep -vE '^#|%$|Loading trace|common_flags|query\.cc|^column [0-9]' | sed '/^$/d' + rm -f "$tmp" +} + +echo "Trace: $TRACE" + +run_q "[RELIABLE] Kotlin serialization cost (encode)" " +SELECT name AS slice, COUNT(*) AS calls, CAST(SUM(dur)/1e6 AS REAL) AS total_ms, CAST(MAX(dur)/1e6 AS REAL) AS max_ms +FROM slice WHERE name LIKE 'WalletKit.encode:%' GROUP BY name ORDER BY total_ms DESC;" + +run_q "[RELIABLE] CPU per thread — ignore RenderThread / Jit (noise)" " +SELECT t.name AS thread, CAST(SUM(ts.dur)/1e6 AS REAL) AS running_ms +FROM thread_state ts JOIN thread t USING (utid) JOIN process p USING (upid) +WHERE p.name LIKE '%walletkit%' AND ts.state = 'Running' +GROUP BY t.name ORDER BY running_ms DESC LIMIT 20;" + +run_q "[RELIABLE] Native reverse RPC / signing (reverse + adapterCall)" " +SELECT name AS slice, COUNT(*) AS calls, CAST(SUM(dur)/1e6 AS REAL) AS total_ms, CAST(MAX(dur)/1e6 AS REAL) AS max_ms +FROM slice WHERE name LIKE 'WalletKit.reverse:%' OR name LIKE 'WalletKit.adapterCall:%' GROUP BY name ORDER BY total_ms DESC;" + +run_q "[POLLUTED — relative only] rpc wall-clock latency" " +SELECT name AS slice, COUNT(*) AS calls, CAST(SUM(dur)/1e6 AS REAL) AS total_ms, CAST(MAX(dur)/1e6 AS REAL) AS max_ms +FROM slice WHERE name LIKE 'WalletKit.rpc:%' GROUP BY name ORDER BY total_ms DESC;" + +run_q "[UI] Frame jank (needs frametimeline in the capture)" " +SELECT a.jank_type AS jank, COUNT(*) AS frames, CAST(MAX(a.dur)/1e6 AS REAL) AS worst_frame_ms +FROM actual_frame_timeline_slice a JOIN process p USING (upid) +WHERE p.name LIKE '%walletkit%' GROUP BY a.jank_type ORDER BY frames DESC;" + +run_q "[UI] Longest main-thread slices (frame blockers)" " +SELECT s.name AS slice, COUNT(*) AS n, CAST(SUM(s.dur)/1e6 AS REAL) AS total_ms, CAST(MAX(s.dur)/1e6 AS REAL) AS max_ms +FROM slice s JOIN thread_track tt ON s.track_id = tt.id JOIN thread t ON tt.utid = t.utid JOIN process p ON t.upid = p.upid +WHERE p.name LIKE '%walletkit%' AND t.is_main_thread = 1 GROUP BY s.name ORDER BY max_ms DESC LIMIT 15;" diff --git a/Scripts/profiling/capture-perfetto.sh b/Scripts/profiling/capture-perfetto.sh new file mode 100755 index 00000000..b3a2e98f --- /dev/null +++ b/Scripts/profiling/capture-perfetto.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# +# Capture a Perfetto system trace on a connected device (analyze with analyze-trace.sh +# or ui.perfetto.dev). The atrace_apps line surfaces the SDK's WalletKit.* trace slices. +# +# Usage: ./capture-perfetto.sh [package] [duration_seconds] +# +set -euo pipefail + +PACKAGE="${1:-io.ton.walletkit.demo}" +DURATION_S="${2:-20}" +DURATION_MS=$((DURATION_S * 1000)) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="$SCRIPT_DIR/captures" +mkdir -p "$OUT_DIR" +STAMP="$(date +%Y%m%d-%H%M%S)" +LOCAL_OUT="$OUT_DIR/walletkit-$STAMP.pftrace" +REMOTE_OUT="/data/misc/perfetto-traces/walletkit-$STAMP.pftrace" + +if ! adb get-state >/dev/null 2>&1; then + echo "No device connected (adb get-state failed). Plug in a physical device and enable USB debugging." >&2 + exit 1 +fi + +echo "Device: $(adb shell getprop ro.product.model | tr -d '\r') (API $(adb shell getprop ro.build.version.sdk | tr -d '\r'))" +echo "Package: $PACKAGE" +echo "Duration: ${DURATION_S}s" +echo + +# Android 9 needs traced explicitly enabled; harmless on newer versions. +adb shell setprop persist.traced.enable 1 >/dev/null 2>&1 || true + +echo "Recording... exercise the SDK now (connect / sign / send a transaction)." + +adb shell perfetto --txt -c - -o "$REMOTE_OUT" </dev/null 2>&1 || true + +echo +echo "Done: $LOCAL_OUT" +echo "Open https://ui.perfetto.dev and drag the file in (or use the SQL query box)." +echo "Filter the timeline for 'WalletKit.' to isolate the SDK's own slices." diff --git a/Scripts/profiling/captures/.gitignore b/Scripts/profiling/captures/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/Scripts/profiling/captures/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/Scripts/profiling/record-simpleperf.sh b/Scripts/profiling/record-simpleperf.sh new file mode 100755 index 00000000..b9a4177e --- /dev/null +++ b/Scripts/profiling/record-simpleperf.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Sampled CPU flame graph (HTML) of the SDK process via simpleperf. Needs the NDK +# scripts and a debuggable/profileable app (the demo's debug build works). +# +# Usage: ./record-simpleperf.sh [package] [duration_seconds] +# +set -euo pipefail + +PACKAGE="${1:-io.ton.walletkit.demo}" +DURATION_S="${2:-20}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="$SCRIPT_DIR/captures" +mkdir -p "$OUT_DIR" +STAMP="$(date +%Y%m%d-%H%M%S)" +PERF_DATA="$OUT_DIR/perf-$STAMP.data" +REPORT_HTML="$OUT_DIR/perf-$STAMP.html" + +if ! adb get-state >/dev/null 2>&1; then + echo "No device connected (adb get-state failed). Plug in a physical device and enable USB debugging." >&2 + exit 1 +fi + +# Locate the NDK's host-side simpleperf scripts (app_profiler.py, report_html.py). +SIMPLEPERF_DIR="${SIMPLEPERF_DIR:-}" +if [[ -z "$SIMPLEPERF_DIR" ]]; then + for base in \ + "${ANDROID_NDK_HOME:-}" \ + "${ANDROID_NDK_ROOT:-}" \ + "$HOME/Library/Android/sdk/ndk"/* \ + "${ANDROID_HOME:-$HOME/Library/Android/sdk}/ndk"/* ; do + if [[ -n "$base" && -f "$base/simpleperf/app_profiler.py" ]]; then + SIMPLEPERF_DIR="$base/simpleperf" + break + fi + done +fi + +if [[ -z "$SIMPLEPERF_DIR" || ! -f "$SIMPLEPERF_DIR/app_profiler.py" ]]; then + echo "Could not find the NDK simpleperf scripts. Install an NDK via Android Studio," >&2 + echo "or set SIMPLEPERF_DIR=/path/to/ndk//simpleperf" >&2 + exit 1 +fi + +echo "simpleperf: $SIMPLEPERF_DIR" +echo "Package: $PACKAGE" +echo "Duration: ${DURATION_S}s" +echo +echo "Recording (cpu-clock, 1kHz, call graphs)... exercise the SDK now." + +# cpu-clock is a software event, so this works without root; -g captures stacks. +python3 "$SIMPLEPERF_DIR/app_profiler.py" \ + -p "$PACKAGE" \ + -o "$PERF_DATA" \ + -r "-e cpu-clock -f 1000 -g --duration $DURATION_S" + +echo +echo "Generating HTML flame graph..." +python3 "$SIMPLEPERF_DIR/report_html.py" -i "$PERF_DATA" -o "$REPORT_HTML" + +echo +echo "Done: $REPORT_HTML" +echo "Open it in a browser. Search the flame graph for 'io.ton.walletkit' frames." diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/model/TONHex.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/model/TONHex.kt index c088f5bc..953fe7d0 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/model/TONHex.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/model/TONHex.kt @@ -57,7 +57,11 @@ data class TONHex( get() = try { val hex = rawValue require(hex.length % 2 == 0) { "Hex string must have even length" } - hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val out = ByteArray(hex.length / 2) + for (i in out.indices) { + out[i] = ((hexDigit(hex[i * 2]) shl 4) or hexDigit(hex[i * 2 + 1])).toByte() + } + out } catch (e: Exception) { null } @@ -76,7 +80,13 @@ data class TONHex( * @param withPrefix Whether to include "0x" prefix (default: true) */ fun fromData(data: ByteArray, withPrefix: Boolean = true): TONHex { - val hex = data.joinToString("") { "%02x".format(it) } + val chars = CharArray(data.size * 2) + for (i in data.indices) { + val byte = data[i].toInt() and 0xFF + chars[i * 2] = HEX_DIGITS[byte ushr 4] + chars[i * 2 + 1] = HEX_DIGITS[byte and 0x0F] + } + val hex = String(chars) return TONHex(if (withPrefix) "0x$hex" else hex) } @@ -102,6 +112,15 @@ data class TONHex( } return hex } + + private val HEX_DIGITS = "0123456789abcdef".toCharArray() + + private fun hexDigit(c: Char): Int = when (c) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> throw NumberFormatException("Invalid hex character: $c") + } } override fun toString(): String = value diff --git a/TONWalletKit-Android/gradle/libs.versions.toml b/TONWalletKit-Android/gradle/libs.versions.toml index 235a3d81..67761b70 100644 --- a/TONWalletKit-Android/gradle/libs.versions.toml +++ b/TONWalletKit-Android/gradle/libs.versions.toml @@ -10,12 +10,14 @@ kotlinxSerialization = "1.9.0" tonKotlinCrypto = "0.5.0" kotlinxDatetime = "0.7.1" webkit = "1.15.0" +tracing = "1.2.0" datastorePreferences = "1.2.0" securityCrypto = "1.1.0" okhttp = "5.3.2" junit = "4.13.2" androidxTestExt = "1.3.0" androidxTestRunner = "1.7.0" +benchmark = "1.3.4" mockk = "1.14.7" androidxTestCore = "1.7.0" robolectric = "4.16" @@ -28,6 +30,7 @@ kotlinxCoroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinxSerializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinxDatetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } androidxWebkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } +androidxTracing = { module = "androidx.tracing:tracing", version.ref = "tracing" } androidxDatastorePreferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidxSecurityCrypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } @@ -40,6 +43,7 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr shadowsFramework = { module = "org.robolectric:shadows-framework", version.ref = "robolectric" } kotlinxCoroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" } tonKotlinBlockTlb = { module = "org.ton.kotlin:ton-kotlin-block-tlb", version.ref = "tonKotlinCrypto" } +androidxBenchmarkJunit4 = { module = "androidx.benchmark:benchmark-junit4", version.ref = "benchmark" } [plugins] androidLibrary = { id = "com.android.library", version.ref = "agp" } @@ -47,3 +51,4 @@ kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } +benchmark = { id = "androidx.benchmark", version.ref = "benchmark" } diff --git a/TONWalletKit-Android/impl/build.gradle.kts b/TONWalletKit-Android/impl/build.gradle.kts index e4cd3b56..473b91aa 100644 --- a/TONWalletKit-Android/impl/build.gradle.kts +++ b/TONWalletKit-Android/impl/build.gradle.kts @@ -200,6 +200,7 @@ dependencies { implementation(libs.kotlinxCoroutinesAndroid) implementation(libs.kotlinxSerializationJson) implementation(libs.androidxWebkit) + implementation(libs.androidxTracing) // Storage classes are now included in this module (merged from storage module) implementation(libs.androidxDatastorePreferences) diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/BridgeRpcClient.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/BridgeRpcClient.kt index 37c43d91..9e7cf7ce 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/BridgeRpcClient.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/BridgeRpcClient.kt @@ -21,6 +21,7 @@ */ package io.ton.walletkit.engine.infrastructure +import androidx.tracing.Trace import io.ton.walletkit.WalletKitBridgeException import io.ton.walletkit.bridge.BridgeCodec import io.ton.walletkit.bridge.decodeFromBridge @@ -41,6 +42,7 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger internal class BridgeRpcClient( private val webViewManager: WebViewManager, @@ -50,6 +52,7 @@ internal class BridgeRpcClient( ) { private val pending = ConcurrentHashMap>() private val ready = CompletableDeferred() + private val traceCookie = AtomicInteger(0) /** * Reverse-RPC channel: native callbacks JS can invoke by reference. Forward calls (Kotlin→JS, @@ -77,18 +80,31 @@ internal class BridgeRpcClient( val deferred = CompletableDeferred() pending[callId] = deferred - val envelope = buildJsonObject { - put(ResponseConstants.KEY_KIND, ResponseConstants.VALUE_KIND_CALL) - put(ResponseConstants.KEY_ID, callId) - put(ResponseConstants.KEY_METHOD, method) - val encoded = codec.encode(params) - if (encoded !is JsonNull) { - put(ResponseConstants.KEY_PARAMS, encoded) + val envelope: JsonObject + Trace.beginSection(TRACE_ENCODE + method) + try { + envelope = buildJsonObject { + put(ResponseConstants.KEY_KIND, ResponseConstants.VALUE_KIND_CALL) + put(ResponseConstants.KEY_ID, callId) + put(ResponseConstants.KEY_METHOD, method) + val encoded = codec.encode(params) + if (encoded !is JsonNull) { + put(ResponseConstants.KEY_PARAMS, encoded) + } } + } finally { + Trace.endSection() } - webViewManager.transport.send(envelope.toString()) - return deferred.await().raw + val rpcLabel = TRACE_RPC + method + val cookie = traceCookie.getAndIncrement() + Trace.beginAsyncSection(rpcLabel, cookie) + try { + webViewManager.transport.send(envelope.toString()) + return deferred.await().raw + } finally { + Trace.endAsyncSection(rpcLabel, cookie) + } } fun handleResponse(id: String, response: JsonObject) { @@ -141,6 +157,8 @@ internal class BridgeRpcClient( private const val TAG = LogConstants.TAG_WEBVIEW_ENGINE private const val ERROR_CALL_FAILED = "call[" private const val ERROR_FAILED_SUFFIX = "] failed: " + private const val TRACE_RPC = "WalletKit.rpc:" + private const val TRACE_ENCODE = "WalletKit.encode:" } } diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/MessageDispatcher.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/MessageDispatcher.kt index 9346b6b9..98bea066 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/MessageDispatcher.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/MessageDispatcher.kt @@ -23,6 +23,7 @@ package io.ton.walletkit.engine.infrastructure import android.os.Handler import android.webkit.WebView +import androidx.tracing.Trace import io.ton.walletkit.WalletKitBridgeException import io.ton.walletkit.api.generated.TONPreparedSignData import io.ton.walletkit.api.generated.TONProofMessage @@ -79,6 +80,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger /** * Routes messages coming from the JavaScript bridge to the appropriate engine components. @@ -107,6 +109,7 @@ internal class MessageDispatcher( ) { private val mainHandler: Handler = webViewManager.getMainHandler() private val eventListenersSetupMutex = Mutex() + private val traceCookie = AtomicInteger(0) private val _streamingEvents = MutableSharedFlow(extraBufferCapacity = 64) val streamingEvents: SharedFlow = _streamingEvents.asSharedFlow() @@ -280,12 +283,17 @@ internal class MessageDispatcher( } CoroutineScope(Dispatchers.IO).launch { + val traceLabel = TRACE_REVERSE + method + val cookie = traceCookie.getAndIncrement() + Trace.beginAsyncSection(traceLabel, cookie) try { val result = executeNativeRequest(method, params) respondToJs(id, result, null) } catch (e: Exception) { Logger.e(TAG, "Reverse-RPC request failed: method=$method", e) respondToJs(id, null, e.message ?: "Unknown error") + } finally { + Trace.endAsyncSection(traceLabel, cookie) } } } @@ -471,6 +479,8 @@ internal class MessageDispatcher( private const val EMPTY_JSON_OBJECT = "{}" + private const val TRACE_REVERSE = "WalletKit.reverse:" + // Reverse-RPC method names (must match the JS bridgeRequest() method strings) private const val REQUEST_METHOD_SIGN_WITH_CUSTOM_SIGNER = "signWithCustomSigner" private const val REQUEST_METHOD_ADAPTER_GET_STATE_INIT = "adapterGetStateInit" diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/WebViewManager.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/WebViewManager.kt index f34ede6f..0bff6617 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/WebViewManager.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/infrastructure/WebViewManager.kt @@ -35,6 +35,7 @@ import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.tracing.Trace import androidx.webkit.WebViewAssetLoader import io.ton.walletkit.WalletKitBridgeException import io.ton.walletkit.api.generated.TONDAppInfo @@ -140,6 +141,7 @@ internal class WebViewManager( } private fun initializeWebView() { + Trace.beginSection(TRACE_INIT_WEBVIEW) try { Logger.d(TAG, "Initializing WebView on thread: ${Thread.currentThread().name}") webView = WebView(appContext) @@ -250,6 +252,8 @@ internal class WebViewManager( ), null, ) + } finally { + Trace.endSection() } } @@ -290,14 +294,21 @@ internal class WebViewManager( } @JavascriptInterface - fun adapterCallSync(method: String, paramsJson: String): String = runBlocking { + fun adapterCallSync(method: String, paramsJson: String): String { + Trace.beginSection(TRACE_ADAPTER_CALL + method) try { - withTimeout(CALL_TIMEOUT_MS) { - dispatch(method, json.parseToJsonElement(paramsJson).jsonObject) + return runBlocking { + try { + withTimeout(CALL_TIMEOUT_MS) { + dispatch(method, json.parseToJsonElement(paramsJson).jsonObject) + } + } catch (e: Exception) { + Logger.e(TAG, "adapterCallSync($method) failed", e) + throw e + } } - } catch (e: Exception) { - Logger.e(TAG, "adapterCallSync($method) failed", e) - throw e + } finally { + Trace.endSection() } } @@ -564,6 +575,8 @@ internal class WebViewManager( private const val TAG = LogConstants.TAG_WEBVIEW_ENGINE private const val CALL_TIMEOUT_MS = 1000L + private const val TRACE_INIT_WEBVIEW = "WalletKit.initWebView" + private const val TRACE_ADAPTER_CALL = "WalletKit.adapterCall:" private const val MSG_FAILED_INITIALIZE_WEBVIEW = "Failed to initialize WebView" private const val MSG_FAILED_EVALUATE_JS_BRIDGE = "Failed to evaluate JS bridge readiness" private const val MSG_URL_SEPARATOR = " url=" diff --git a/TONWalletKit-Android/microbenchmark/build.gradle.kts b/TONWalletKit-Android/microbenchmark/build.gradle.kts new file mode 100644 index 00000000..1cc4bf05 --- /dev/null +++ b/TONWalletKit-Android/microbenchmark/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.benchmark) +} + +android { + namespace = "io.ton.walletkit.microbenchmark" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner" + } + + // Non-debuggable build required; the flag lives in src/androidTest/AndroidManifest.xml. + testBuildType = "release" + buildTypes { + release { + isDefault = true + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") + } +} + +dependencies { + androidTestImplementation(project(":api")) + androidTestImplementation(libs.androidxBenchmarkJunit4) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidxTestExt) + androidTestImplementation(libs.androidxTestRunner) +} diff --git a/TONWalletKit-Android/microbenchmark/src/androidTest/AndroidManifest.xml b/TONWalletKit-Android/microbenchmark/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000..1f4374f6 --- /dev/null +++ b/TONWalletKit-Android/microbenchmark/src/androidTest/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/TONWalletKit-Android/microbenchmark/src/androidTest/java/io/ton/walletkit/microbenchmark/ModelBenchmark.kt b/TONWalletKit-Android/microbenchmark/src/androidTest/java/io/ton/walletkit/microbenchmark/ModelBenchmark.kt new file mode 100644 index 00000000..dc4d6263 --- /dev/null +++ b/TONWalletKit-Android/microbenchmark/src/androidTest/java/io/ton/walletkit/microbenchmark/ModelBenchmark.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.microbenchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.ton.walletkit.model.TONBase64 +import io.ton.walletkit.model.TONHex +import io.ton.walletkit.model.TONRawAddress +import io.ton.walletkit.model.TONTokenAmount +import io.ton.walletkit.model.TONTokenAmountFormatter +import io.ton.walletkit.model.TONUserFriendlyAddress +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Per-method cost of the SDK's deterministic public value types. Run with + * `./gradlew :microbenchmark:connectedReleaseAndroidTest`. Bridge/network-bound SDK methods are not + * benchmarkable here (they measure latency, not code) — profile those with Perfetto instead. + */ +@RunWith(AndroidJUnit4::class) +class ModelBenchmark { + + @get:Rule + val benchmarkRule = BenchmarkRule() + + private val sampleAddress = TONUserFriendlyAddress( + TONRawAddress(workchain = 0, hash = ByteArray(32) { (it + 1).toByte() }), + ) + private val addressValue = sampleAddress.value + private val bytes32 = ByteArray(32) { it.toByte() } + private val hexString = TONHex.fromData(bytes32, withPrefix = false).value + private val base64String = TONBase64.fromData(bytes32).value + private val formatter = TONTokenAmountFormatter() + private val tokenAmount = TONTokenAmount("123456789012") + + @Test + fun parseUserFriendlyAddress() = benchmarkRule.measureRepeated { + TONUserFriendlyAddress.parseUserFriendly(addressValue) + } + + @Test + fun addressToBounceableForm() = benchmarkRule.measureRepeated { + sampleAddress.toString(isBounceable = false) + } + + @Test + fun addressToRaw() = benchmarkRule.measureRepeated { + sampleAddress.raw + } + + @Test + fun hexEncode() = benchmarkRule.measureRepeated { + TONHex.fromData(bytes32, withPrefix = false) + } + + @Test + fun hexDecode() = benchmarkRule.measureRepeated { + TONHex(hexString).data + } + + @Test + fun base64Encode() = benchmarkRule.measureRepeated { + TONBase64.fromData(bytes32) + } + + @Test + fun base64Decode() = benchmarkRule.measureRepeated { + TONBase64(base64String).data + } + + @Test + fun tokenAmountParse() = benchmarkRule.measureRepeated { + formatter.amount("123.456789012") + } + + @Test + fun tokenAmountFormat() = benchmarkRule.measureRepeated { + formatter.string(tokenAmount) + } +} diff --git a/TONWalletKit-Android/settings.gradle.kts b/TONWalletKit-Android/settings.gradle.kts index d83543ef..19605d58 100644 --- a/TONWalletKit-Android/settings.gradle.kts +++ b/TONWalletKit-Android/settings.gradle.kts @@ -20,3 +20,4 @@ rootProject.name = "TONWalletKit-Android" include(":api") include(":impl") +include(":microbenchmark") From 143929eeef54ad07a0d9ce32773bd3dd8f171d6f Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 3 Jul 2026 14:49:17 +0400 Subject: [PATCH 2/3] feat: remove legacy wallet screen; move test tools to Investigation menu --- .../demo/e2e/dapp/JsDAppController.kt | 30 +- .../demo/e2e/wallet/WalletController.kt | 170 ++----- .../demo/presentation/MainActivity.kt | 35 +- .../demo/presentation/dev/DevPreferences.kt | 43 +- .../demo/presentation/dev/DevToggleTaps.kt | 51 -- .../ui/components/QuickActionsCard.kt | 77 --- .../ui/components/StatusHeader.kt | 73 --- .../presentation/ui/components/WalletCard.kt | 203 -------- .../wallet/home/WalletHomeBalance.kt | 8 - .../wallet/home/WalletHomeContent.kt | 2 - .../ui/screen/LegacyWalletScreen.kt | 441 ------------------ .../ui/screen/WalletKitInvestigationScreen.kt | 237 ++++++++-- .../presentation/ui/screen/WalletScreen.kt | 43 +- .../ui/sections/EventLogSection.kt | 51 -- .../ui/sections/JettonsSection.kt | 178 ------- .../ui/sections/MasterchainInfoSection.kt | 148 ------ .../ui/sections/SessionsSection.kt | 85 ---- .../ui/sections/WalletsSection.kt | 106 ----- .../demo/presentation/util/TestTags.kt | 12 + .../viewmodel/WalletKitViewModel.kt | 14 - 20 files changed, 306 insertions(+), 1701 deletions(-) delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevToggleTaps.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/QuickActionsCard.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/StatusHeader.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletCard.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/EventLogSection.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/JettonsSection.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/MasterchainInfoSection.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/SessionsSection.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt diff --git a/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/dapp/JsDAppController.kt b/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/dapp/JsDAppController.kt index 2f48afec..3c77c9fb 100644 --- a/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/dapp/JsDAppController.kt +++ b/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/dapp/JsDAppController.kt @@ -24,9 +24,11 @@ package io.ton.walletkit.demo.e2e.dapp import android.content.ClipboardManager import android.util.Log import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice @@ -120,19 +122,27 @@ class JsDAppController { fun openBrowser(url: String = DAPP_URL, injectTonConnect: Boolean = false) { jsBridge.clearCache() // Clear any cached WebView reference - if (injectTonConnect) { - composeTestRule.onNodeWithContentDescription("Open TonConnect Browser") - .performClick() - } else { - composeTestRule.onNodeWithTag(TestTags.BROWSER_NO_INJECT_BUTTON) - .performClick() + val target = InstrumentationRegistry.getArguments().getString("browserUrl") + ?.takeIf { it.isNotBlank() } ?: url + + // Modern path: gear -> Investigation -> dApp Browser -> type URL -> open (inject / no-inject). + composeTestRule.onNodeWithTag(TestTags.INVESTIGATION_BUTTON).performClick() + composeTestRule.waitUntil(ELEMENT_TIMEOUT) { + composeTestRule.onAllNodesWithTag(TestTags.INVESTIGATION_BROWSER_ROW).fetchSemanticsNodes().isNotEmpty() } + composeTestRule.onNodeWithTag(TestTags.INVESTIGATION_BROWSER_ROW).performClick() - composeTestRule.waitForIdle() + composeTestRule.waitUntil(ELEMENT_TIMEOUT) { + composeTestRule.onAllNodesWithTag(TestTags.BROWSER_URL_FIELD).fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithTag(TestTags.BROWSER_URL_FIELD).performTextClearance() + composeTestRule.onNodeWithTag(TestTags.BROWSER_URL_FIELD).performTextInput(target) - // Wait for WebView to load + val buttonTag = if (injectTonConnect) TestTags.BROWSER_INJECT_BUTTON else TestTags.BROWSER_NO_INJECT_BUTTON + composeTestRule.onNodeWithTag(buttonTag).performClick() + composeTestRule.waitForIdle() - Log.d("JsDAppController", "Browser opened, waiting for WebView...") + Log.d("JsDAppController", "Browser opened ($target, inject=$injectTonConnect), waiting for WebView...") } /** diff --git a/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/wallet/WalletController.kt b/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/wallet/WalletController.kt index ace58129..7d65bc45 100644 --- a/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/wallet/WalletController.kt +++ b/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/wallet/WalletController.kt @@ -83,28 +83,11 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { // =========================================== /** - * Check if we're on the legacy wallet home screen (the only screen the e2e suite knows how - * to drive). The legacy toolbar exposes [TestTags.BROWSER_NO_INJECT_BUTTON]; the modern - * [WalletScreen] does not. Returns true only when the legacy home is visible AND the - * [AddWalletSheet] is not in front of it. + * Whether the wallet home is visible. The home exposes [TestTags.WALLET_BALANCE]; returns true + * only when it is visible AND the [AddWalletSheet] is not in front of it. */ - fun isOnHomeScreen(): Boolean { - if (!isOnLegacyHome()) return false - return !isAddWalletSheetShowing() - } + fun isOnHomeScreen(): Boolean = isOnModernHome() && !isAddWalletSheetShowing() - private fun isOnLegacyHome(): Boolean = try { - composeTestRule.onNodeWithTag(TestTags.BROWSER_NO_INJECT_BUTTON).assertExists() - true - } catch (e: AssertionError) { - false - } - - /** - * The modern [WalletScreen] is the default for new installs. It carries no legacy testTags - * but exposes [TestTags.WALLET_BALANCE] on the balance area, which is also the 5-tap secret - * gesture target that toggles back to the legacy screen. - */ private fun isOnModernHome(): Boolean = try { composeTestRule.onNodeWithTag(TestTags.WALLET_BALANCE).assertExists() true @@ -293,8 +276,8 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { enterMnemonicOnImportScreen(mnemonic) } isAddWalletSheetShowing() -> { - Log.d("WalletController", "Legacy AddWalletSheet visible - using paste-all field") - importWalletViaLegacySheet(mnemonic) + Log.d("WalletController", "AddWalletSheet visible - using paste-all field") + importWalletViaAddSheet(mnemonic) } else -> { Log.w("WalletController", "importWallet: no recognised import UI - aborting") @@ -302,15 +285,14 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { } } - // Wait for wallet home (either modern or legacy) to appear after import succeeds. + // Wait for the wallet home to appear after import succeeds. composeTestRule.waitForIdle() try { - composeTestRule.waitUntil(15_000L) { isOnLegacyHome() || isOnModernHome() } + composeTestRule.waitUntil(15_000L) { isOnModernHome() } Log.d("WalletController", "Wallet loaded successfully in UI") } catch (e: Exception) { Log.e("WalletController", "Wallet home still not visible after timeout: ${e.message}") } - Log.d("WalletController", "After import - legacy: ${isOnLegacyHome()}, modern: ${isOnModernHome()}") } /** Modern import flow: paste the full phrase into the first tagged word field, tap Continue. */ @@ -336,8 +318,8 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { composeTestRule.waitForIdle() } - /** Legacy AddWalletSheet flow (only reachable when the dev legacy-screen toggle is on). */ - private fun importWalletViaLegacySheet(mnemonic: List) { + /** AddWalletSheet flow: paste the full phrase into the sheet's paste-all field. */ + private fun importWalletViaAddSheet(mnemonic: List) { val mnemonicString = mnemonic.joinToString(" ") composeTestRule.waitUntil(5_000L) { @@ -406,35 +388,16 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { Log.d("WalletController", "=== setupWallet called with ${mnemonic.size} word mnemonic ===") composeTestRule.waitForIdle() - // Check if we're already on the legacy home screen (wallet exists from a previous run - // and the dev toggle is already flipped). + // Already on the wallet home — wallet exists from a previous run. if (isOnHomeScreen()) { - Log.d("WalletController", "Already on legacy home screen - wallet exists, skipping setup") + Log.d("WalletController", "Already on home screen - wallet exists, skipping setup") return } - // Check if AddWalletSheet is already showing (password was set previously, dev toggle on) - if (isAddWalletSheetShowing()) { - Log.d("WalletController", "AddWalletSheet already showing - importing wallet directly") + // A pre-wallet surface is showing — import directly. + if (isAddWalletSheetShowing() || isOnOnboardingScreen() || isOnImportScreen()) { + Log.d("WalletController", "Pre-wallet surface visible - importing wallet") importWallet(mnemonic) - ensureLegacyScreen() - return - } - - // Modern first-run lands on the onboarding screen (or the import screen if the user - // already tapped through). Both are valid pre-wallet states. - if (isOnOnboardingScreen() || isOnImportScreen()) { - Log.d("WalletController", "Modern onboarding visible - importing wallet") - importWallet(mnemonic) - ensureLegacyScreen() - return - } - - // The post-import / post-unlock app lands on the modern WalletScreen by default. - // Tests target legacy UI, so toggle if needed. - if (isOnModernHome()) { - Log.d("WalletController", "On modern WalletScreen - wallet exists, toggling to legacy") - ensureLegacyScreen() return } @@ -450,86 +413,24 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { for (i in 1..10) { composeTestRule.waitForIdle() - // If on legacy home screen, wallet already exists and toggle was on if (isOnHomeScreen()) { - Log.d("WalletController", "On legacy home after auth (check $i) - wallet exists, skipping import") + Log.d("WalletController", "On home after auth (check $i) - wallet exists, skipping import") return } - // If AddWalletSheet is showing, import wallet - if (isAddWalletSheetShowing()) { - Log.d("WalletController", "AddWalletSheet showing after auth (check $i) - importing wallet") + if (isAddWalletSheetShowing() || isOnOnboardingScreen() || isOnImportScreen()) { + Log.d("WalletController", "Import surface after auth (check $i) - importing wallet") importWallet(mnemonic) - ensureLegacyScreen() - return - } - - // Modern first-run onboarding (or already on the import screen). - if (isOnOnboardingScreen() || isOnImportScreen()) { - Log.d("WalletController", "Onboarding visible after auth (check $i) - importing wallet") - importWallet(mnemonic) - ensureLegacyScreen() - return - } - - // Default landing for an existing-wallet app: the modern WalletScreen with the balance - // tile visible. Switch to legacy so the rest of the suite can drive it. - if (isOnModernHome()) { - Log.d("WalletController", "Modern home after auth (check $i) - toggling to legacy") - ensureLegacyScreen() return } - Log.d("WalletController", "Waiting for home/addWallet screen (check $i)...") + Log.d("WalletController", "Waiting for home/import surface (check $i)...") Thread.sleep(200) // Small delay between checks } // Fallback: try to import wallet anyway (this may fail) Log.w("WalletController", "No expected screen detected after 10 checks, attempting import as fallback") importWallet(mnemonic) - ensureLegacyScreen() - } - - /** - * Make sure we end up on the legacy wallet home. The new auth flow drops the user on - * [WalletScreen] by default; e2e tests were written against [LegacyWalletScreen], which is - * reached by the dev-only 5-tap secret on the balance tile. No-op if already on legacy. - */ - @Step("Switch to legacy wallet screen") - fun ensureLegacyScreen() { - composeTestRule.waitForIdle() - if (isOnLegacyHome()) { - Log.d("WalletController", "ensureLegacyScreen: already on legacy home") - return - } - - // Wait for the modern home (with the WALLET_BALANCE tile) to be ready before tapping. - try { - composeTestRule.waitUntil(UI_IDLE_TIMEOUT) { isOnModernHome() } - } catch (e: Exception) { - Log.w("WalletController", "ensureLegacyScreen: timed out waiting for modern home: ${e.message}") - } - - if (!isOnModernHome()) { - Log.w("WalletController", "ensureLegacyScreen: modern home not visible, skipping toggle") - return - } - - // DevToggleTaps requires 5 taps inside a 3-second window. performClick runs on the test - // thread back-to-back so they always land inside that window. - Log.d("WalletController", "ensureLegacyScreen: performing 5 quick taps on WALLET_BALANCE") - repeat(5) { - composeTestRule.onNodeWithTag(TestTags.WALLET_BALANCE).performClick() - } - composeTestRule.waitForIdle() - - try { - composeTestRule.waitUntil(UI_IDLE_TIMEOUT) { isOnLegacyHome() } - Log.d("WalletController", "ensureLegacyScreen: legacy home is now visible") - } catch (e: Exception) { - Log.w("WalletController", "ensureLegacyScreen: legacy home did not appear: ${e.message}") - } - composeTestRule.waitForIdle() } // =========================================== @@ -544,43 +445,34 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { */ @Step("Connect by TonConnect URL") fun connectByUrl(url: String) { - // Wait for wallet home screen with Handle URL button + // Modern path: gear -> Investigation -> TonConnect -> Connect to dApp -> URL dialog. + composeTestRule.onNodeWithTag(TestTags.INVESTIGATION_BUTTON).performClick() composeTestRule.waitUntil(5000) { - composeTestRule.onAllNodesWithTag(TestTags.HANDLE_URL_BUTTON) + composeTestRule.onAllNodesWithTag(TestTags.INVESTIGATION_TONCONNECT_ROW) .fetchSemanticsNodes().isNotEmpty() } + composeTestRule.onNodeWithTag(TestTags.INVESTIGATION_TONCONNECT_ROW).performClick() - // Click "Handle URL" button to open dialog - composeTestRule.onNodeWithTag(TestTags.HANDLE_URL_BUTTON) - .performClick() - - composeTestRule.waitForIdle() + composeTestRule.waitUntil(5000) { + composeTestRule.onAllNodesWithTag(TestTags.INVESTIGATION_CONNECT_ROW) + .fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithTag(TestTags.INVESTIGATION_CONNECT_ROW).performClick() - // Wait for the URL input field in the dialog composeTestRule.waitUntil(3000) { composeTestRule.onAllNodesWithTag(TestTags.TONCONNECT_URL_FIELD) .fetchSemanticsNodes().isNotEmpty() } - - // Enter the TonConnect URL - composeTestRule.onNodeWithTag(TestTags.TONCONNECT_URL_FIELD) - .performTextInput(url) - - // Click the process button - composeTestRule.onNodeWithTag(TestTags.TONCONNECT_PROCESS_BUTTON) - .performClick() - + composeTestRule.onNodeWithTag(TestTags.TONCONNECT_URL_FIELD).performTextInput(url) + composeTestRule.onNodeWithTag(TestTags.TONCONNECT_PROCESS_BUTTON).performClick() composeTestRule.waitForIdle() } - /** - * Wait for the (legacy) wallet home screen to be visible. The e2e suite runs against the - * legacy screen, so [setupWallet] ensures we are toggled there before this is called. - */ + /** Wait for the wallet home screen (balance tile) to be visible. */ @Step("Wait for wallet home screen") fun waitForWalletHome() { composeTestRule.waitUntil(SHEET_APPEAR_TIMEOUT) { - composeTestRule.onAllNodesWithTag(TestTags.WALLET_ADDRESS) + composeTestRule.onAllNodesWithTag(TestTags.WALLET_BALANCE) .fetchSemanticsNodes().isNotEmpty() } } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/MainActivity.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/MainActivity.kt index d282e280..0f1602c9 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/MainActivity.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/MainActivity.kt @@ -45,9 +45,7 @@ import io.ton.walletkit.demo.designsystem.theme.TonTheme import io.ton.walletkit.demo.presentation.actions.WalletActionsImpl import io.ton.walletkit.demo.presentation.dev.DevPreferences import io.ton.walletkit.demo.presentation.state.CreateWalletFlow -import io.ton.walletkit.demo.presentation.state.SheetState import io.ton.walletkit.demo.presentation.ui.screen.CreatePinScreen -import io.ton.walletkit.demo.presentation.ui.screen.LegacyWalletScreen import io.ton.walletkit.demo.presentation.ui.screen.UnlockPinScreen import io.ton.walletkit.demo.presentation.ui.screen.WalletScreen import io.ton.walletkit.demo.presentation.ui.screen.onboarding.CreateWalletOnboardingScreen @@ -132,7 +130,6 @@ private fun AppNavigation( else -> { walletKit.value?.let { kit -> - val useLegacyMainScreen by DevPreferences.useLegacyMainScreen.collectAsState() val createFlow by viewModel.createWalletFlow.collectAsState() val walletsBootstrapped = state.walletsBootstrapped @@ -144,14 +141,10 @@ private fun AppNavigation( } } - LaunchedEffect(hasWallet, useLegacyMainScreen, walletsBootstrapped) { + LaunchedEffect(hasWallet, walletsBootstrapped) { if (!walletsBootstrapped) return@LaunchedEffect if (hasWallet) return@LaunchedEffect - if (useLegacyMainScreen) { - if (state.sheetState !is SheetState.AddWallet) { - viewModel.openAddWalletSheet() - } - } else if (createFlow is CreateWalletFlow.Idle) { + if (createFlow is CreateWalletFlow.Idle) { viewModel.showCreateWalletOnboarding() } } @@ -160,23 +153,13 @@ private fun AppNavigation( flow = createFlow, viewModel = viewModel, fallback = { - if (useLegacyMainScreen) { - LegacyWalletScreen( - state = state, - walletKit = kit, - nftsViewModel = nftsViewModel, - swapViewModel = swapViewModel, - actions = walletActions, - ) - } else { - WalletScreen( - state = state, - walletKit = kit, - nftsViewModel = nftsViewModel, - swapViewModel = swapViewModel, - actions = walletActions, - ) - } + WalletScreen( + state = state, + walletKit = kit, + nftsViewModel = nftsViewModel, + swapViewModel = swapViewModel, + actions = walletActions, + ) }, ) } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevPreferences.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevPreferences.kt index 07fbc176..340ec8c1 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevPreferences.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevPreferences.kt @@ -22,52 +22,17 @@ package io.ton.walletkit.demo.presentation.dev import android.content.Context -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow object DevPreferences { private const val PREFS_NAME = "dev_preferences" - private const val KEY_USE_LEGACY_MAIN_SCREEN = "use_legacy_main_screen" - private val _useLegacyMainScreen = MutableStateFlow(false) - val useLegacyMainScreen: StateFlow = _useLegacyMainScreen.asStateFlow() + fun ensureLoaded(context: Context) = Unit - @Volatile - private var loaded = false - - fun ensureLoaded(context: Context) { - if (loaded) return - synchronized(this) { - if (loaded) return - val prefs = context.applicationContext - .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - _useLegacyMainScreen.value = prefs.getBoolean(KEY_USE_LEGACY_MAIN_SCREEN, false) - loaded = true - } - } - - fun toggleLegacyMainScreen(context: Context): Boolean { - ensureLoaded(context) - val next = !_useLegacyMainScreen.value + fun reset(context: Context) { context.applicationContext .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) .edit() - .putBoolean(KEY_USE_LEGACY_MAIN_SCREEN, next) - .apply() - _useLegacyMainScreen.value = next - return next - } - - fun reset(context: Context) { - synchronized(this) { - context.applicationContext - .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .clear() - .commit() - _useLegacyMainScreen.value = false - loaded = false - } + .clear() + .commit() } } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevToggleTaps.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevToggleTaps.kt deleted file mode 100644 index 00fc879a..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevToggleTaps.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025 TonTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package io.ton.walletkit.demo.presentation.dev - -import android.os.SystemClock -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput - -fun Modifier.devToggleTaps( - requiredTaps: Int = 5, - windowMs: Long = 3_000L, - onTrigger: () -> Unit, -): Modifier = this.then( - Modifier.pointerInput(requiredTaps, windowMs) { - var count = 0 - var firstAt = 0L - detectTapGestures(onTap = { - val now = SystemClock.elapsedRealtime() - if (count == 0 || now - firstAt > windowMs) { - count = 1 - firstAt = now - } else { - count++ - } - if (count >= requiredTaps) { - count = 0 - onTrigger() - } - }) - }, -) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/QuickActionsCard.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/QuickActionsCard.kt deleted file mode 100644 index 417b4459..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/QuickActionsCard.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2025 TonTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package io.ton.walletkit.demo.presentation.ui.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.ton.walletkit.demo.R -import io.ton.walletkit.demo.presentation.util.TestTags - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun QuickActionsCard( - onHandleUrl: () -> Unit, - onAddWallet: () -> Unit, - onRefresh: () -> Unit, - onSwap: () -> Unit = {}, -) { - ElevatedCard { - Column( - modifier = Modifier.padding(ACTION_CARD_PADDING), - verticalArrangement = Arrangement.spacedBy(ACTION_CARD_SPACING), - ) { - Text(stringResource(R.string.quick_actions_title), style = MaterialTheme.typography.titleMedium) - FlowRow(horizontalArrangement = Arrangement.spacedBy(ACTION_BUTTON_SPACING)) { - FilledTonalButton( - onClick = onHandleUrl, - modifier = Modifier.testTag(TestTags.HANDLE_URL_BUTTON), - ) { Text(stringResource(R.string.action_handle_url)) } - FilledTonalButton(onClick = onAddWallet) { Text(stringResource(R.string.action_add_wallet)) } - FilledTonalButton(onClick = onRefresh) { Text(stringResource(R.string.action_refresh)) } - FilledTonalButton(onClick = onSwap) { Text("Swap") } - } - } - } -} - -private val ACTION_CARD_PADDING = 20.dp -private val ACTION_CARD_SPACING = 16.dp -private val ACTION_BUTTON_SPACING = 12.dp - -@Preview(showBackground = true) -@Composable -private fun QuickActionsCardPreview() { - QuickActionsCard(onHandleUrl = {}, onAddWallet = {}, onRefresh = {}) -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/StatusHeader.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/StatusHeader.kt deleted file mode 100644 index d13e3385..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/StatusHeader.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (c) 2025 TonTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package io.ton.walletkit.demo.presentation.ui.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.ton.walletkit.demo.R -import io.ton.walletkit.demo.presentation.state.WalletUiState -import io.ton.walletkit.demo.presentation.ui.preview.PreviewData - -@Composable -fun StatusHeader(state: WalletUiState) { - val clipboardManager = LocalClipboardManager.current - - // Auto-copy to clipboard when clipboardContent is set - LaunchedEffect(state.clipboardContent) { - state.clipboardContent?.let { content -> - clipboardManager.setText(AnnotatedString(content)) - } - } - - Column(verticalArrangement = Arrangement.spacedBy(STATUS_HEADER_SPACING)) { - Text( - text = state.status.ifBlank { "" }, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - if (state.lastUpdated != null) { - Text( - text = stringResource(R.string.status_updated_now), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -private val STATUS_HEADER_SPACING = 8.dp - -@Preview(showBackground = true) -@Composable -private fun StatusHeaderPreview() { - StatusHeader(state = PreviewData.uiState) -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletCard.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletCard.kt deleted file mode 100644 index 5a0c4d66..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletCard.kt +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright (c) 2025 TonTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package io.ton.walletkit.demo.presentation.ui.components - -import android.content.ClipData -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Button -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.ton.walletkit.demo.R -import io.ton.walletkit.demo.presentation.model.WalletSummary -import io.ton.walletkit.demo.presentation.ui.icons.ContentCopy -import io.ton.walletkit.demo.presentation.ui.preview.PreviewData -import io.ton.walletkit.demo.presentation.util.TestTags -import io.ton.walletkit.demo.presentation.util.abbreviated -import kotlinx.coroutines.launch - -@Composable -fun WalletCard( - wallet: WalletSummary, - onDetails: () -> Unit, - onSend: () -> Unit = {}, - onStake: () -> Unit = {}, - isStreamingConnected: Boolean? = null, - onRefresh: () -> Unit = {}, -) { - val clipboard = LocalClipboard.current - val coroutineScope = rememberCoroutineScope() - ElevatedCard { - Column( - modifier = Modifier.padding(WALLET_CARD_PADDING), - verticalArrangement = Arrangement.spacedBy(WALLET_CARD_SECTION_SPACING), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), - ) { - Column(verticalArrangement = Arrangement.spacedBy(WALLET_CARD_LABEL_SPACING)) { - Text(wallet.name, style = MaterialTheme.typography.titleMedium) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(STREAMING_DOT_SPACING), - ) { - NetworkBadge(wallet.network) - if (isStreamingConnected != null) { - Box( - modifier = Modifier - .size(STREAMING_DOT_SIZE) - .background( - color = if (isStreamingConnected) STREAMING_DOT_CONNECTED else STREAMING_DOT_DISCONNECTED, - shape = CircleShape, - ), - ) - } - } - } - TextButton(onClick = onDetails) { Text(stringResource(R.string.action_details)) } - } - - Column(verticalArrangement = Arrangement.spacedBy(WALLET_CARD_CONTENT_SPACING)) { - Text( - stringResource(R.string.label_wallet_address), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = wallet.address.abbreviated(), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f).testTag(TestTags.WALLET_ADDRESS), - ) - IconButton( - onClick = { - coroutineScope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(CLIPBOARD_WALLET_ADDRESS_LABEL, wallet.address)), - ) - } - }, - ) { - Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.action_copy_address)) - } - } - } - - Column(verticalArrangement = Arrangement.spacedBy(WALLET_CARD_LABEL_SPACING)) { - Text( - stringResource(R.string.label_balance), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), - ) { - val balanceText = wallet.balance ?: stringResource(R.string.wallet_balance_placeholder) - Text(balanceText, style = MaterialTheme.typography.headlineSmall) - IconButton(onClick = onRefresh) { - Icon( - Icons.Default.Refresh, - contentDescription = stringResource(R.string.action_refresh), - ) - } - } - } - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(WALLET_CARD_BUTTON_SPACING), - ) { - Button( - onClick = onSend, - modifier = Modifier - .weight(1f) - .testTag(TestTags.WALLET_SEND_BUTTON), - ) { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = null, - modifier = Modifier.padding(end = SEND_ICON_PADDING), - ) - Text(stringResource(R.string.action_send)) - } - OutlinedButton( - onClick = onStake, - modifier = Modifier - .weight(1f) - .testTag(TestTags.WALLET_STAKE_BUTTON), - ) { - Text(stringResource(R.string.action_stake)) - } - } - } - } - } -} - -private val WALLET_CARD_PADDING = 20.dp -private val WALLET_CARD_SECTION_SPACING = 12.dp -private val WALLET_CARD_CONTENT_SPACING = 8.dp -private val WALLET_CARD_LABEL_SPACING = 4.dp -private val WALLET_CARD_BUTTON_SPACING = 8.dp -private val SEND_ICON_PADDING = 4.dp -private val STREAMING_DOT_SIZE = 8.dp -private val STREAMING_DOT_SPACING = 6.dp -private val STREAMING_DOT_CONNECTED = Color(0xFF4CAF50) -private val STREAMING_DOT_DISCONNECTED = Color(0xFFBDBDBD) -private const val CLIPBOARD_WALLET_ADDRESS_LABEL = "wallet_address" - -@Preview(showBackground = true) -@Composable -private fun WalletCardPreview() { - WalletCard(wallet = PreviewData.wallet, onDetails = {}, onSend = {}, onStake = {}) -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt index 050422d7..858493c0 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt @@ -34,7 +34,6 @@ import io.ton.walletkit.demo.designsystem.components.text.TonText import io.ton.walletkit.demo.designsystem.icons.TonIcon import io.ton.walletkit.demo.designsystem.icons.TonIconImage import io.ton.walletkit.demo.designsystem.theme.TonTheme -import io.ton.walletkit.demo.presentation.dev.devToggleTaps import io.ton.walletkit.demo.presentation.util.TestTags @Composable @@ -45,7 +44,6 @@ fun WalletHomeBalance( truncatedAddress: String, onCopyAddress: () -> Unit, modifier: Modifier = Modifier, - onSecretTap: (() -> Unit)? = null, ) { val animated = rememberCountUp(totalBalance) val formatted = formatCountUp(animated, maxFractionDigits) @@ -53,14 +51,8 @@ fun WalletHomeBalance( val integerPart = if (dotIndex < 0) formatted else formatted.substring(0, dotIndex) val fractionPart = if (dotIndex < 0) "" else formatted.substring(dotIndex) - val gestureModifier = if (onSecretTap != null) { - Modifier.devToggleTaps(onTrigger = onSecretTap) - } else { - Modifier - } Column( modifier = modifier - .then(gestureModifier) .testTag(TestTags.WALLET_BALANCE), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt index 2fa9886b..2206b001 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt @@ -57,7 +57,6 @@ fun WalletHomeContent( onShowAllNFTs: () -> Unit, onNFTTap: (WalletHomeNFTPreview) -> Unit, modifier: Modifier = Modifier, - onBalanceSecretTap: (() -> Unit)? = null, ) { val scrollState = rememberScrollState() @@ -77,7 +76,6 @@ fun WalletHomeContent( .fillMaxWidth() .padding(horizontal = 16.dp) .padding(top = 8.dp, bottom = 8.dp), - onSecretTap = onBalanceSecretTap, ) WalletHomeActionsRow( diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt deleted file mode 100644 index 16a12bfc..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt +++ /dev/null @@ -1,441 +0,0 @@ -/* - * Copyright (c) 2025 TonTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package io.ton.walletkit.demo.presentation.ui.screen - -import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SheetValue -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.ton.walletkit.ITONWalletKit -import io.ton.walletkit.api.generated.TONNFT -import io.ton.walletkit.demo.R -import io.ton.walletkit.demo.designsystem.theme.TonTheme -import io.ton.walletkit.demo.presentation.actions.WalletActions -import io.ton.walletkit.demo.presentation.dev.DevPreferences -import io.ton.walletkit.demo.presentation.dev.devToggleTaps -import io.ton.walletkit.demo.presentation.model.NFTDetails -import io.ton.walletkit.demo.presentation.state.SheetState -import io.ton.walletkit.demo.presentation.state.WalletUiState -import io.ton.walletkit.demo.presentation.ui.components.QuickActionsCard -import io.ton.walletkit.demo.presentation.ui.components.StatusHeader -import io.ton.walletkit.demo.presentation.ui.components.WalletSwitcher -import io.ton.walletkit.demo.presentation.ui.dialog.SignerConfirmationDialog -import io.ton.walletkit.demo.presentation.ui.dialog.UrlPromptDialog -import io.ton.walletkit.demo.presentation.ui.icons.Language -import io.ton.walletkit.demo.presentation.ui.sections.EventLogSection -import io.ton.walletkit.demo.presentation.ui.sections.JettonsSection -import io.ton.walletkit.demo.presentation.ui.sections.MasterchainInfoSection -import io.ton.walletkit.demo.presentation.ui.sections.NFTsSection -import io.ton.walletkit.demo.presentation.ui.sections.SessionsSection -import io.ton.walletkit.demo.presentation.ui.sections.WalletsSection -import io.ton.walletkit.demo.presentation.ui.sheet.AddWalletSheet -import io.ton.walletkit.demo.presentation.ui.sheet.BrowserSession -import io.ton.walletkit.demo.presentation.ui.sheet.BrowserSheet -import io.ton.walletkit.demo.presentation.ui.sheet.ConnectRequestSheet -import io.ton.walletkit.demo.presentation.ui.sheet.JettonDetailsSheet -import io.ton.walletkit.demo.presentation.ui.sheet.SignDataSheet -import io.ton.walletkit.demo.presentation.ui.sheet.SignMessageRequestSheet -import io.ton.walletkit.demo.presentation.ui.sheet.StakingSheet -import io.ton.walletkit.demo.presentation.ui.sheet.SwapSheet -import io.ton.walletkit.demo.presentation.ui.sheet.TransactionDetailSheet -import io.ton.walletkit.demo.presentation.ui.sheet.TransactionRequestSheet -import io.ton.walletkit.demo.presentation.ui.sheet.TransferJettonSheet -import io.ton.walletkit.demo.presentation.ui.sheet.WalletDetailsSheet -import io.ton.walletkit.demo.presentation.util.TestTags -import io.ton.walletkit.demo.presentation.viewmodel.NFTsListViewModel -import io.ton.walletkit.demo.presentation.viewmodel.SwapViewModel - -// Pre-redesign main screen, kept behind a dev toggle. 5 taps on the title bar -// flips back to the new home screen. -private const val DEFAULT_DAPP_URL = "https://allure-test-runner.vercel.app/e2e" - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun LegacyWalletScreen( - state: WalletUiState, - walletKit: ITONWalletKit, - nftsViewModel: NFTsListViewModel?, - swapViewModel: SwapViewModel?, - actions: WalletActions, -) { - val scrollState = rememberScrollState() - val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current - - val onRefreshAll: () -> Unit = { - actions.onRefresh() - actions.onRefreshJettons() - nftsViewModel?.refresh() - } - - var selectedNFT by remember { mutableStateOf(null) } - val nftDetailsSheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { it != SheetValue.Hidden }, - ) - - val injectedSession = remember { BrowserSession(injectTonConnect = true) } - val plainSession = remember { BrowserSession(injectTonConnect = false) } - - DisposableEffect(Unit) { - onDispose { - injectedSession.destroyAllWebViews() - plainSession.destroyAllWebViews() - } - } - - LaunchedEffect(state.error) { - val error = state.error ?: return@LaunchedEffect - snackbarHostState.showSnackbar(error) - } - - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - confirmValueChange = { it != SheetValue.Hidden }, - ) - val sheet = state.sheetState - val showSheet = sheet !is SheetState.None - LaunchedEffect(state.sheetState) { - if (state.sheetState is SheetState.None && sheetState.isVisible) { - sheetState.hide() - } - val browserSheet = state.sheetState as? SheetState.Browser ?: return@LaunchedEffect - val session = if (browserSheet.injectTonConnect) injectedSession else plainSession - if (session.tabs.isEmpty()) session.openTab(browserSheet.url) - } - val activeWallet = state.wallets.firstOrNull { it.walletId == state.activeWalletId } - ?: state.wallets.firstOrNull() - if (showSheet) { - ModalBottomSheet( - onDismissRequest = actions::onDismissSheet, - sheetState = sheetState, - containerColor = TonTheme.colors.bgPrimary, - dragHandle = null, - ) { - when (sheet) { - SheetState.AddWallet -> AddWalletSheet( - onDismiss = actions::onDismissSheet, - onImportWallet = actions::onImportWallet, - onGenerateWallet = actions::onGenerateWallet, - walletCount = state.wallets.size, - ) - - is SheetState.Connect -> ConnectRequestSheet( - request = sheet.request, - wallets = state.wallets, - onApprove = actions::onApproveConnect, - onReject = actions::onRejectConnect, - ) - - is SheetState.Transaction -> TransactionRequestSheet( - request = sheet.request, - onApprove = { actions.onApproveTransaction(sheet.request) }, - onReject = { actions.onRejectTransaction(sheet.request) }, - wallet = state.wallets.firstOrNull { it.address == sheet.request.walletAddress } - ?: activeWallet, - ) - - is SheetState.SignData -> SignDataSheet( - request = sheet.request, - onApprove = { actions.onApproveSignData(sheet.request) }, - onReject = { actions.onRejectSignData(sheet.request) }, - wallet = state.wallets.firstOrNull { it.address == sheet.request.walletAddress } - ?: activeWallet, - ) - - is SheetState.SignMessage -> SignMessageRequestSheet( - request = sheet.request, - onApprove = { actions.onApproveSignMessage(sheet.request) }, - onReject = { actions.onRejectSignMessage(sheet.request) }, - wallet = state.wallets.firstOrNull { it.address == sheet.request.walletAddress } - ?: activeWallet, - ) - - is SheetState.WalletDetails -> WalletDetailsSheet( - wallet = sheet.wallet, - onDismiss = actions::onDismissSheet, - ) - - is SheetState.SendTransaction -> SendTransactionScreen( - wallet = sheet.wallet, - walletKit = walletKit, - onBack = actions::onDismissSheet, - ) - - is SheetState.Staking -> StakingSheet( - wallet = sheet.wallet, - walletKit = walletKit, - sheetKey = sheet.openedAt, - onDismiss = actions::onDismissSheet, - ) - - is SheetState.TransactionDetail -> TransactionDetailSheet( - transaction = sheet.transaction, - onDismiss = actions::onDismissSheet, - ) - - is SheetState.Browser -> BrowserSheet( - session = if (sheet.injectTonConnect) injectedSession else plainSession, - onClose = actions::onDismissSheet, - walletKit = walletKit, - ) - - is SheetState.JettonDetails -> { - JettonDetailsSheet( - jetton = sheet.jetton, - onDismiss = actions::onDismissSheet, - onTransfer = { actions.onShowTransferJetton(sheet.jetton) }, - ) - } - - is SheetState.TransferJetton -> { - TransferJettonSheet( - jetton = sheet.jetton, - onDismiss = actions::onDismissSheet, - onTransfer = { recipient, amount, comment -> - actions.onTransferJetton(sheet.jetton.jettonAddress ?: "", recipient, amount, comment) - }, - isLoading = false, - ) - } - - is SheetState.Swap -> { - swapViewModel?.let { vm -> - SwapSheet(viewModel = vm, onDismiss = actions::onDismissSheet) - } - } - - SheetState.None -> Unit - } - } - } - - if (state.isUrlPromptVisible) { - UrlPromptDialog( - onDismiss = actions::onDismissUrlPrompt, - onConfirm = actions::onHandleUrl, - ) - } - - state.pendingSignerConfirmation?.let { request -> - SignerConfirmationDialog( - request = request, - onConfirm = actions::onConfirmSignerApproval, - onCancel = actions::onCancelSignerApproval, - ) - } - - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.wallet_screen_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.devToggleTaps { - val nowLegacy = DevPreferences.toggleLegacyMainScreen(context) - Toast.makeText( - context, - if (nowLegacy) "Legacy main screen ON" else "Legacy main screen OFF", - Toast.LENGTH_SHORT, - ).show() - }, - ) - }, - actions = { - IconButton(onClick = { actions.onOpenBrowser(DEFAULT_DAPP_URL) }) { - Icon(painterResource(R.drawable.ic_ton), contentDescription = "Open TonConnect Browser") - } - IconButton( - onClick = { actions.onOpenBrowser(DEFAULT_DAPP_URL, injectTonConnect = false) }, - modifier = Modifier.testTag(TestTags.BROWSER_NO_INJECT_BUTTON), - ) { - Icon(Icons.Default.Language, contentDescription = "Open Plain Browser") - } - }, - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(scrollState) - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - Box(modifier = Modifier.fillMaxWidth().height(4.dp)) { - if (state.isLoadingWallets || state.isLoadingSessions) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - } - StatusHeader(state) - - QuickActionsCard( - onHandleUrl = actions::onUrlPromptClick, - onAddWallet = actions::onAddWalletClick, - onRefresh = onRefreshAll, - onSwap = actions::onSwapClick, - ) - - if (state.wallets.size > 1) { - WalletSwitcher( - wallets = state.wallets, - activeWalletId = state.activeWalletId, - isExpanded = state.isWalletSwitcherExpanded, - onToggle = actions::onToggleWalletSwitcher, - onSwitchWallet = actions::onSwitchWallet, - onRemoveWallet = actions::onRemoveWallet, - onRenameWallet = actions::onRenameWallet, - ) - } - - WalletsSection( - activeWallet = activeWallet, - totalWallets = state.wallets.size, - onWalletSelected = actions::onWalletDetails, - onSendFromWallet = actions::onSendFromWallet, - onStakeFromWallet = actions::onStakeFromWallet, - isStreamingConnected = state.isStreamingConnected, - onRefresh = actions::onRefresh, - ) - - if (nftsViewModel != null) { - NFTsSection( - viewModel = nftsViewModel, - onNFTClick = { nft -> selectedNFT = nft }, - ) - } - - JettonsSection( - jettons = state.jettons, - isLoading = state.isLoadingJettons, - error = state.jettonsError, - canLoadMore = state.canLoadMoreJettons, - onJettonClick = actions::onShowJettonDetails, - onLoadMore = actions::onLoadMoreJettons, - onRefresh = actions::onRefreshJettons, - ) - - if (activeWallet != null) { - MasterchainInfoSection(network = activeWallet.network) - } - - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "Transaction History", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Coming Soon", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - SessionsSection( - sessions = state.sessions, - onDisconnect = actions::onDisconnectSession, - ) - - if (state.events.isNotEmpty()) { - EventLogSection(events = state.events) - } - - Spacer(modifier = Modifier.height(48.dp)) - } - } - - selectedNFT?.let { nft -> - ModalBottomSheet( - onDismissRequest = { selectedNFT = null }, - sheetState = nftDetailsSheetState, - containerColor = TonTheme.colors.bgPrimary, - dragHandle = null, - ) { - activeWallet?.let { wallet -> - val nftDetails = NFTDetails.from(nft) - NFTDetailsScreen( - walletId = wallet.walletId, - walletKit = walletKit, - nftDetails = nftDetails, - onClose = { selectedNFT = null }, - onTransferSuccess = { - val nftAddress = selectedNFT?.address?.value - nftAddress?.let { addr -> nftsViewModel?.removeNft(addr) } - nftsViewModel?.refreshWithDelay() - }, - ) - } - } - } -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt index 15f119f1..59dfac7a 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt @@ -19,13 +19,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -@file:SuppressLint("SetJavaScriptEnabled") - package io.ton.walletkit.demo.presentation.ui.screen -import android.annotation.SuppressLint -import android.view.ViewGroup -import android.webkit.WebView import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -35,6 +30,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,21 +41,30 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import io.ton.walletkit.ITONWalletKit +import io.ton.walletkit.api.ChainIds +import io.ton.walletkit.api.MAINNET +import io.ton.walletkit.api.TESTNET +import io.ton.walletkit.api.TETRA +import io.ton.walletkit.api.WalletVersions +import io.ton.walletkit.api.generated.TONNetwork import io.ton.walletkit.demo.R +import io.ton.walletkit.demo.designsystem.components.button.TonButton +import io.ton.walletkit.demo.designsystem.components.button.TonButtonConfig +import io.ton.walletkit.demo.designsystem.components.segmentedcontrol.TonSegmentedControl import io.ton.walletkit.demo.designsystem.components.text.TonText import io.ton.walletkit.demo.designsystem.theme.SmoothCornerShape import io.ton.walletkit.demo.designsystem.theme.TonTheme +import io.ton.walletkit.demo.domain.model.WalletInterfaceType import io.ton.walletkit.demo.presentation.ui.dialog.UrlPromptDialog -import io.ton.walletkit.demo.presentation.ui.screen.iframesec.RealBridgeIframeCase import io.ton.walletkit.demo.presentation.ui.screen.iframesec.WalletKitRealBridgeIframeScreen import io.ton.walletkit.demo.presentation.util.QrScanner -import io.ton.walletkit.extensions.injectTonConnect +import io.ton.walletkit.demo.presentation.util.TestTags -private enum class InvestigationPage { Tonconnect, DappWebView, RealBridge } +private enum class InvestigationPage { Tonconnect, Browser, AddWallet, RealBridge } /** * Developer "Wallet Kit Investigation" screen: a list of debug tools reached from the wallet-home @@ -68,6 +75,9 @@ private enum class InvestigationPage { Tonconnect, DappWebView, RealBridge } fun WalletKitInvestigationScreen( onBack: () -> Unit, onConnect: (String) -> Unit, + onOpenBrowser: (String, Boolean) -> Unit, + onImportWallet: (String, TONNetwork, List, String, String, WalletInterfaceType) -> Unit, + onGenerateWallet: (String, TONNetwork, String, WalletInterfaceType) -> Unit, walletKit: ITONWalletKit, modifier: Modifier = Modifier, ) { @@ -79,9 +89,25 @@ fun WalletKitInvestigationScreen( WalletKitTonconnectScreen(onBack = { page = null }, onConnect = onConnect, modifier = modifier) return } - InvestigationPage.DappWebView -> { + InvestigationPage.Browser -> { BackHandler { page = null } - DappWebViewScreen(onBack = { page = null }, walletKit = walletKit, modifier = modifier) + BrowserLauncherScreen(onBack = { page = null }, onOpenBrowser = onOpenBrowser, modifier = modifier) + return + } + InvestigationPage.AddWallet -> { + BackHandler { page = null } + TestAddWalletScreen( + onBack = { page = null }, + onImport = { name, network, mnemonic, secretKey, version, interfaceType -> + onImportWallet(name, network, mnemonic, secretKey, version, interfaceType) + page = null + }, + onGenerate = { name, network, version, interfaceType -> + onGenerateWallet(name, network, version, interfaceType) + page = null + }, + modifier = modifier, + ) return } InvestigationPage.RealBridge -> { @@ -107,8 +133,18 @@ fun WalletKitInvestigationScreen( InvestigationRow( title = stringResource(R.string.investigation_tonconnect), onClick = { page = InvestigationPage.Tonconnect }, + modifier = Modifier.testTag(TestTags.INVESTIGATION_TONCONNECT_ROW), + ) + InvestigationRow( + title = "dApp Browser", + onClick = { page = InvestigationPage.Browser }, + modifier = Modifier.testTag(TestTags.INVESTIGATION_BROWSER_ROW), + ) + InvestigationRow( + title = "Add Wallet", + onClick = { page = InvestigationPage.AddWallet }, + modifier = Modifier.testTag(TestTags.INVESTIGATION_ADD_WALLET_ROW), ) - InvestigationRow(title = "dApp WebView", onClick = { page = InvestigationPage.DappWebView }) InvestigationRow( title = "Iframe Security — Real dApp Bridge", onClick = { page = InvestigationPage.RealBridge }, @@ -142,6 +178,7 @@ private fun WalletKitTonconnectScreen( InvestigationRow( title = stringResource(R.string.investigation_connect_to_dapp), onClick = { showPrompt = true }, + modifier = Modifier.testTag(TestTags.INVESTIGATION_CONNECT_ROW), ) InvestigationRow( title = stringResource(R.string.investigation_scan_qr), @@ -169,45 +206,179 @@ private fun WalletKitTonconnectScreen( } } -/** Minimal dApp WebView with the real WalletKit injection — mirrors the iOS investigation entry. */ +private const val DEFAULT_DAPP_URL = "https://tonconnect-demo-dapp-with-react-ui.vercel.app/" + @Composable -private fun DappWebViewScreen( +private fun BrowserLauncherScreen( onBack: () -> Unit, - walletKit: ITONWalletKit, + onOpenBrowser: (String, Boolean) -> Unit, modifier: Modifier = Modifier, ) { + var url by remember { mutableStateOf(DEFAULT_DAPP_URL) } Column( modifier = modifier .fillMaxSize() .background(TonTheme.colors.bgSecondary), ) { - SubScreenTopBar(title = "dApp WebView", onBack = onBack) - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { ctx -> - WebView(ctx).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - injectTonConnect(walletKit) - loadUrl(RealBridgeIframeCase.DAPP_URL) - } - }, - ) + SubScreenTopBar(title = "dApp Browser", onBack = onBack) + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextField( + value = url, + onValueChange = { url = it }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.BROWSER_URL_FIELD), + ) + InvestigationRow( + title = "Open (injected)", + onClick = { onOpenBrowser(url, true) }, + modifier = Modifier.testTag(TestTags.BROWSER_INJECT_BUTTON), + ) + InvestigationRow( + title = "Open (no-inject)", + onClick = { onOpenBrowser(url, false) }, + modifier = Modifier.testTag(TestTags.BROWSER_NO_INJECT_BUTTON), + ) + } + } +} + +@Composable +private fun TestAddWalletScreen( + onBack: () -> Unit, + onImport: (String, TONNetwork, List, String, String, WalletInterfaceType) -> Unit, + onGenerate: (String, TONNetwork, String, WalletInterfaceType) -> Unit, + modifier: Modifier = Modifier, +) { + var name by remember { mutableStateOf("Test wallet") } + var network by remember { mutableStateOf(TONNetwork.MAINNET) } + var version by remember { mutableStateOf(WalletVersions.V5R1) } + var interfaceType by remember { mutableStateOf(WalletInterfaceType.MNEMONIC) } + var mnemonic by remember { mutableStateOf("") } + var secretKey by remember { mutableStateOf("") } + + Column( + modifier = modifier + .fillMaxSize() + .background(TonTheme.colors.bgSecondary), + ) { + SubScreenTopBar(title = "Add Wallet", onBack = onBack) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + AddWalletFieldLabel("Name") + TextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag(TestTags.ADD_WALLET_NAME_FIELD), + ) + + AddWalletFieldLabel("Network") + TonSegmentedControl( + selection = network, + items = listOf(TONNetwork.MAINNET, TONNetwork.TESTNET, TONNetwork.TETRA), + title = ::networkLabel, + onSelect = { network = it }, + modifier = Modifier.fillMaxWidth(), + ) + + AddWalletFieldLabel("Version") + TonSegmentedControl( + selection = version, + items = listOf(WalletVersions.V5R1, WalletVersions.V4R2), + title = { it }, + onSelect = { version = it }, + modifier = Modifier.fillMaxWidth(), + ) + + AddWalletFieldLabel("Interface") + TonSegmentedControl( + selection = interfaceType, + items = WalletInterfaceType.entries, + title = ::interfaceLabel, + onSelect = { interfaceType = it }, + modifier = Modifier.fillMaxWidth(), + ) + + if (interfaceType == WalletInterfaceType.SECRET_KEY) { + AddWalletFieldLabel("Secret key (hex)") + TextField( + value = secretKey, + onValueChange = { secretKey = it.trim() }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag(TestTags.ADD_WALLET_SECRET_KEY_FIELD), + ) + } else { + AddWalletFieldLabel("Recovery phrase") + TextField( + value = mnemonic, + onValueChange = { mnemonic = it }, + minLines = 2, + modifier = Modifier.fillMaxWidth().testTag(TestTags.ADD_WALLET_MNEMONIC_FIELD), + ) + } + + TonButton( + text = "Import", + onClick = { + val words = mnemonic.trim().lowercase().split(Regex("\\s+")).filter { it.isNotBlank() } + onImport(name, network, words, secretKey, version, interfaceType) + }, + config = TonButtonConfig.Primary, + modifier = Modifier.fillMaxWidth().testTag(TestTags.ADD_WALLET_IMPORT_BUTTON), + ) + TonButton( + text = "Generate", + onClick = { onGenerate(name, network, version, interfaceType) }, + config = TonButtonConfig.Secondary, + enabled = interfaceType != WalletInterfaceType.SECRET_KEY, + modifier = Modifier.fillMaxWidth().testTag(TestTags.ADD_WALLET_GENERATE_BUTTON), + ) + } } } @Composable -private fun InvestigationRow(title: String, onClick: () -> Unit) { +private fun AddWalletFieldLabel(text: String) { + TonText( + text = text, + style = TonTheme.typography.subheadline2, + color = TonTheme.colors.textSecondary, + ) +} + +private fun networkLabel(network: TONNetwork): String = when (network.chainId) { + ChainIds.MAINNET -> "Mainnet" + ChainIds.TESTNET -> "Testnet" + ChainIds.TETRA -> "Tetra" + else -> "Unknown" +} + +private fun interfaceLabel(type: WalletInterfaceType): String = when (type) { + WalletInterfaceType.MNEMONIC -> "Mnemonic" + WalletInterfaceType.SECRET_KEY -> "Secret Key" + WalletInterfaceType.SIGNER -> "Signer" +} + +@Composable +private fun InvestigationRow(title: String, onClick: () -> Unit, modifier: Modifier = Modifier) { val shape = SmoothCornerShape(12.dp) TonText( text = title, style = TonTheme.typography.body, color = TonTheme.colors.textPrimary, - modifier = Modifier + modifier = modifier .fillMaxWidth() .clip(shape) .background(TonTheme.colors.bgPrimary) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt index dc90d2b1..24bd4680 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt @@ -88,7 +88,6 @@ import io.ton.walletkit.demo.designsystem.icons.TonIconImage import io.ton.walletkit.demo.designsystem.theme.TonTheme import io.ton.walletkit.demo.domain.model.WalletInterfaceType import io.ton.walletkit.demo.presentation.actions.WalletActions -import io.ton.walletkit.demo.presentation.dev.DevPreferences import io.ton.walletkit.demo.presentation.model.ConnectRequestUi import io.ton.walletkit.demo.presentation.model.JettonDetails import io.ton.walletkit.demo.presentation.model.JettonSummary @@ -98,7 +97,6 @@ import io.ton.walletkit.demo.presentation.model.TransactionRequestUi import io.ton.walletkit.demo.presentation.model.WalletSummary import io.ton.walletkit.demo.presentation.state.SheetState import io.ton.walletkit.demo.presentation.state.WalletUiState -import io.ton.walletkit.demo.presentation.ui.components.QuickActionsCard import io.ton.walletkit.demo.presentation.ui.components.wallet.home.WalletHomeAssetIcon import io.ton.walletkit.demo.presentation.ui.components.wallet.home.WalletHomeAssetItem import io.ton.walletkit.demo.presentation.ui.components.wallet.home.WalletHomeContent @@ -121,13 +119,10 @@ import io.ton.walletkit.demo.presentation.ui.sheet.WalletDetailsSheet import io.ton.walletkit.demo.presentation.ui.sheet.WalletsBottomSheet import io.ton.walletkit.demo.presentation.util.JettonFormatters import io.ton.walletkit.demo.presentation.util.QrScanner +import io.ton.walletkit.demo.presentation.util.TestTags import io.ton.walletkit.demo.presentation.viewmodel.NFTsListViewModel import io.ton.walletkit.demo.presentation.viewmodel.SwapViewModel -// URL for the TonConnect E2E test runner dApp -// This is the same dApp used by web demo-wallet E2E tests -private const val DEFAULT_DAPP_URL = "https://allure-test-runner.vercel.app/e2e" - private const val MAX_ASSETS = 3 private const val MAX_NFTS = 5 private const val MAX_FRACTION_DIGITS = 5 @@ -451,7 +446,22 @@ fun WalletScreen( HomeSubScreen.Investigation -> { WalletKitInvestigationScreen( onBack = { subScreen = HomeSubScreen.None }, - onConnect = actions::onHandleUrl, + onConnect = { url -> + subScreen = HomeSubScreen.None + actions.onHandleUrl(url) + }, + onOpenBrowser = { url, inject -> + subScreen = HomeSubScreen.None + actions.onOpenBrowser(url, inject) + }, + onImportWallet = { name, network, mnemonic, secretKey, version, interfaceType -> + subScreen = HomeSubScreen.None + actions.onImportWallet(name, network, mnemonic, secretKey, version, interfaceType) + }, + onGenerateWallet = { name, network, version, interfaceType -> + subScreen = HomeSubScreen.None + actions.onGenerateWallet(name, network, version, interfaceType) + }, walletKit = walletKit, ) } @@ -515,7 +525,10 @@ fun WalletScreen( } }, actions = { - IconButton(onClick = { subScreen = HomeSubScreen.Investigation }) { + IconButton( + onClick = { subScreen = HomeSubScreen.Investigation }, + modifier = Modifier.testTag(TestTags.INVESTIGATION_BUTTON), + ) { TonIconImage( icon = TonIcon.SettingsControl, size = 24.dp, @@ -535,7 +548,11 @@ fun WalletScreen( // Always reserve space for progress indicator to prevent content shift. Box(modifier = Modifier.fillMaxWidth().height(4.dp)) { if (state.isLoadingWallets || state.isLoadingSessions) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = TonTheme.colors.bgBrand, + trackColor = TonTheme.colors.bgBrandSubtle, + ) } } @@ -558,14 +575,6 @@ fun WalletScreen( onNFTTap = { preview -> nftsList.firstOrNull { it.address.value == preview.address }?.let { selectedNFT = it } }, - onBalanceSecretTap = { - val nowLegacy = DevPreferences.toggleLegacyMainScreen(context) - Toast.makeText( - context, - if (nowLegacy) "Legacy main screen ON" else "Legacy main screen OFF", - Toast.LENGTH_SHORT, - ).show() - }, ) } } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/EventLogSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/EventLogSection.kt deleted file mode 100644 index 91625301..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/EventLogSection.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2025 TonTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package io.ton.walletkit.demo.presentation.ui.sections - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.ton.walletkit.demo.R - -@Composable -fun EventLogSection(events: List) { - Column(verticalArrangement = Arrangement.spacedBy(EVENT_SECTION_SPACING)) { - Text(stringResource(R.string.events_recent_title), style = MaterialTheme.typography.titleMedium) - events.forEach { event -> - HorizontalDivider() - Text(event, style = MaterialTheme.typography.bodySmall) - } - } -} -private val EVENT_SECTION_SPACING = 8.dp - -@Preview(showBackground = true) -@Composable -private fun EventLogSectionPreview() { - EventLogSection(events = listOf("Handled TON Connect URL", "Approved transaction")) -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/JettonsSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/JettonsSection.kt deleted file mode 100644 index 93d50f91..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/JettonsSection.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (c) 2025 TonTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package io.ton.walletkit.demo.presentation.ui.sections - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.ton.walletkit.demo.presentation.model.JettonSummary -import io.ton.walletkit.demo.presentation.ui.components.JettonListItem - -/** - * Section displaying jettons owned by the wallet. - * - * Mirrors iOS WalletJettonsListView for cross-platform consistency. - */ -@Composable -fun JettonsSection( - jettons: List, - isLoading: Boolean, - error: String?, - canLoadMore: Boolean, - onJettonClick: (JettonSummary) -> Unit, - onLoadMore: () -> Unit, - onRefresh: () -> Unit, - modifier: Modifier = Modifier, -) { - // Auto-load jettons when section is first displayed - LaunchedEffect(Unit) { - onRefresh() - } - - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Header - Text( - text = "Jettons", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 16.dp), - ) - - when { - // Loading state - isLoading && jettons.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - - // Error state - error != null && jettons.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "Failed to load jettons", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - Button(onClick = onRefresh) { - Text("Retry") - } - } - } - } - - // Empty state - jettons.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "No Jettons found", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button(onClick = onRefresh) { - Text("Try Again") - } - } - } - } - - // Success state - display jettons - else -> { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Display each jetton - jettons.forEach { jetton -> - JettonListItem( - jetton = jetton, - onClick = onJettonClick, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - - // Load More button - if (canLoadMore) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - Button( - onClick = onLoadMore, - enabled = !isLoading, - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.padding(horizontal = 8.dp), - ) - } else { - Text("Load more") - } - } - } - } - } - } - } - } -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/MasterchainInfoSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/MasterchainInfoSection.kt deleted file mode 100644 index 7c7a4de6..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/MasterchainInfoSection.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (c) 2025 TonTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package io.ton.walletkit.demo.presentation.ui.sections - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import io.ton.walletkit.api.generated.TONMasterchainInfo -import io.ton.walletkit.api.generated.TONNetwork -import io.ton.walletkit.api.isTetra -import io.ton.walletkit.demo.core.TonAPIClient -import io.ton.walletkit.demo.core.ToncenterAPIClient -import kotlinx.coroutines.launch - -/** - * Section demonstrating getMasterchainInfo() API call. - */ -@Composable -fun MasterchainInfoSection( - network: TONNetwork, - modifier: Modifier = Modifier, -) { - var info by remember(network) { mutableStateOf(null) } - var isLoading by remember(network) { mutableStateOf(false) } - var error by remember(network) { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - Column(modifier = modifier.fillMaxWidth()) { - Text( - text = "Masterchain Info", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.height(8.dp)) - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), - ) { - Button( - onClick = { - scope.launch { - isLoading = true - error = null - try { - val client = if (network.isTetra) TonAPIClient.tetra() else ToncenterAPIClient(network) - info = client.getMasterchainInfo() - } catch (e: Exception) { - error = e.message ?: "Unknown error" - } finally { - isLoading = false - } - } - }, - enabled = !isLoading, - ) { - Text("Fetch") - } - if (isLoading) { - Spacer(modifier = Modifier.width(12.dp)) - CircularProgressIndicator( - modifier = Modifier.height(24.dp).width(24.dp), - strokeWidth = 2.dp, - ) - } - } - - error?.let { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Error: $it", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - - info?.let { mc -> - Spacer(modifier = Modifier.height(12.dp)) - InfoRow("Seqno", mc.seqno.toString()) - InfoRow("Workchain", mc.workchain.toString()) - InfoRow("Shard", mc.shard) - InfoRow("Root Hash", mc.rootHash.value) - InfoRow("File Hash", mc.fileHash.value) - } - } - } - } -} - -@Composable -private fun InfoRow(label: String, value: String) { - Row(modifier = Modifier.padding(vertical = 2.dp)) { - Text( - text = "$label: ", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = value, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - } -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/SessionsSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/SessionsSection.kt deleted file mode 100644 index c721373e..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/SessionsSection.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2025 TonTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package io.ton.walletkit.demo.presentation.ui.sections - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.ton.walletkit.demo.R -import io.ton.walletkit.demo.presentation.model.SessionSummary -import io.ton.walletkit.demo.presentation.ui.components.EmptyStateCard -import io.ton.walletkit.demo.presentation.ui.components.SessionCard -import io.ton.walletkit.demo.presentation.ui.preview.PreviewData - -@Composable -fun SessionsSection(sessions: List, onDisconnect: (String) -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(SESSIONS_SECTION_SPACING)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(R.string.sessions_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.width(SESSIONS_TITLE_SPACING)) - Text( - "${sessions.size}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - if (sessions.isEmpty()) { - EmptyStateCard( - title = stringResource(R.string.sessions_empty_title), - description = stringResource(R.string.sessions_empty_description), - ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(SESSIONS_LIST_SPACING)) { - sessions.forEach { session -> - SessionCard( - session = session, - onDisconnect = { onDisconnect(session.sessionId) }, - ) - } - } - } - } -} - -private val SESSIONS_SECTION_SPACING = 12.dp -private val SESSIONS_TITLE_SPACING = 8.dp -private val SESSIONS_LIST_SPACING = 12.dp - -@Preview(showBackground = true) -@Composable -private fun SessionsSectionPreview() { - SessionsSection(sessions = listOf(PreviewData.session), onDisconnect = {}) -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt deleted file mode 100644 index 21c3945e..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright (c) 2025 TonTech - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package io.ton.walletkit.demo.presentation.ui.sections - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.ton.walletkit.demo.R -import io.ton.walletkit.demo.presentation.model.WalletSummary -import io.ton.walletkit.demo.presentation.ui.components.EmptyStateCard -import io.ton.walletkit.demo.presentation.ui.components.WalletCard -import io.ton.walletkit.demo.presentation.ui.preview.PreviewData - -@Composable -fun WalletsSection( - activeWallet: WalletSummary?, - totalWallets: Int, - onWalletSelected: (String) -> Unit, - onSendFromWallet: (String) -> Unit = {}, - onStakeFromWallet: (String) -> Unit = {}, - isStreamingConnected: Boolean? = null, - onRefresh: () -> Unit = {}, -) { - Column(verticalArrangement = Arrangement.spacedBy(WALLETS_SECTION_SPACING)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(R.string.wallets_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.width(WALLETS_TITLE_SPACING)) - Text( - pluralStringResource(R.plurals.wallets_count, totalWallets, totalWallets), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - if (activeWallet == null) { - EmptyStateCard( - title = stringResource(R.string.wallets_empty_title), - description = stringResource(R.string.wallets_empty_description), - ) - } else { - WalletCard( - wallet = activeWallet, - onDetails = { onWalletSelected(activeWallet.walletId) }, - onSend = { onSendFromWallet(activeWallet.walletId) }, - onStake = { onStakeFromWallet(activeWallet.walletId) }, - isStreamingConnected = isStreamingConnected, - onRefresh = onRefresh, - ) - if (totalWallets > 1) { - Text( - text = stringResource(R.string.wallets_switcher_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -private val WALLETS_SECTION_SPACING = 12.dp -private val WALLETS_TITLE_SPACING = 8.dp - -@Preview(showBackground = true) -@Composable -private fun WalletsSectionPreview() { - WalletsSection( - activeWallet = PreviewData.wallet, - totalWallets = 3, - onWalletSelected = {}, - onSendFromWallet = {}, - onStakeFromWallet = {}, - ) -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/TestTags.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/TestTags.kt index 0a201a54..64242e4b 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/TestTags.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/TestTags.kt @@ -60,6 +60,18 @@ object TestTags { const val ADD_WALLET_BUTTON = "add-wallet" const val REFRESH_BUTTON = "refresh" const val BROWSER_NO_INJECT_BUTTON = "browser-no-inject" + const val BROWSER_INJECT_BUTTON = "browser-inject" + const val BROWSER_URL_FIELD = "browser-url" + const val INVESTIGATION_BUTTON = "investigation" + const val INVESTIGATION_TONCONNECT_ROW = "investigation-tonconnect" + const val INVESTIGATION_BROWSER_ROW = "investigation-browser" + const val INVESTIGATION_CONNECT_ROW = "investigation-connect-to-dapp" + const val INVESTIGATION_ADD_WALLET_ROW = "investigation-add-wallet" + const val ADD_WALLET_NAME_FIELD = "add-wallet-name" + const val ADD_WALLET_MNEMONIC_FIELD = "add-wallet-mnemonic" + const val ADD_WALLET_SECRET_KEY_FIELD = "add-wallet-secret-key" + const val ADD_WALLET_IMPORT_BUTTON = "add-wallet-import" + const val ADD_WALLET_GENERATE_BUTTON = "add-wallet-generate" // ConnectRequestSheet const val CONNECT_REQUEST_SHEET = "request" diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt index e8e3934a..64cea8ab 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt @@ -45,7 +45,6 @@ import io.ton.walletkit.demo.data.storage.DemoAppStorage import io.ton.walletkit.demo.data.storage.WalletRecord import io.ton.walletkit.demo.domain.model.WalletInterfaceType import io.ton.walletkit.demo.domain.model.WalletMetadata -import io.ton.walletkit.demo.presentation.dev.DevPreferences import io.ton.walletkit.demo.presentation.model.ConnectPermissionUi import io.ton.walletkit.demo.presentation.model.ConnectRequestUi import io.ton.walletkit.demo.presentation.model.JettonDetails @@ -1790,14 +1789,6 @@ class WalletKitViewModel @Inject constructor( // Wait until the initial wallet load cycle in bootstrap() has fully completed // before deciding whether to open the "add wallet" sheet. _state.first { it.walletsBootstrapped } - - // Modern flow uses CreateWalletOnboardingScreen, orchestrated by MainActivity - // when there's no wallet — auto-opening AddWalletSheet here would race and - // leave a stale sheet behind for the user to bump into later. Only fire for - // the legacy main screen, which still relies on the bottom-sheet flow. - if (_state.value.wallets.isEmpty() && DevPreferences.useLegacyMainScreen.value) { - uiCoordinator.openAddWalletSheet() - } } catch (e: Exception) { Log.e(LOG_TAG, "Failed to setup password", e) val reason = e.message ?: uiString(R.string.wallet_error_unknown) @@ -1831,11 +1822,6 @@ class WalletKitViewModel @Inject constructor( // Wait until the initial wallet load cycle in bootstrap() has fully completed // before deciding whether to open the "add wallet" sheet. _state.first { it.walletsBootstrapped } - // See [setupPassword] — modern main screen drives onboarding from MainActivity, - // so skip the legacy AddWalletSheet auto-open unless we're explicitly on legacy. - if (_state.value.wallets.isEmpty() && DevPreferences.useLegacyMainScreen.value) { - uiCoordinator.openAddWalletSheet() - } } } From 28f3bfaff24659ed92403e2c2a54e46fe6038838 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Fri, 3 Jul 2026 14:50:56 +0400 Subject: [PATCH 3/3] Revert "feat: remove legacy wallet screen; move test tools to Investigation menu" This reverts commit 143929eeef54ad07a0d9ce32773bd3dd8f171d6f. --- .../demo/e2e/dapp/JsDAppController.kt | 30 +- .../demo/e2e/wallet/WalletController.kt | 170 +++++-- .../demo/presentation/MainActivity.kt | 35 +- .../demo/presentation/dev/DevPreferences.kt | 43 +- .../demo/presentation/dev/DevToggleTaps.kt | 51 ++ .../ui/components/QuickActionsCard.kt | 77 +++ .../ui/components/StatusHeader.kt | 73 +++ .../presentation/ui/components/WalletCard.kt | 203 ++++++++ .../wallet/home/WalletHomeBalance.kt | 8 + .../wallet/home/WalletHomeContent.kt | 2 + .../ui/screen/LegacyWalletScreen.kt | 441 ++++++++++++++++++ .../ui/screen/WalletKitInvestigationScreen.kt | 237 ++-------- .../presentation/ui/screen/WalletScreen.kt | 43 +- .../ui/sections/EventLogSection.kt | 51 ++ .../ui/sections/JettonsSection.kt | 178 +++++++ .../ui/sections/MasterchainInfoSection.kt | 148 ++++++ .../ui/sections/SessionsSection.kt | 85 ++++ .../ui/sections/WalletsSection.kt | 106 +++++ .../demo/presentation/util/TestTags.kt | 12 - .../viewmodel/WalletKitViewModel.kt | 14 + 20 files changed, 1701 insertions(+), 306 deletions(-) create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevToggleTaps.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/QuickActionsCard.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/StatusHeader.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletCard.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/EventLogSection.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/JettonsSection.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/MasterchainInfoSection.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/SessionsSection.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt diff --git a/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/dapp/JsDAppController.kt b/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/dapp/JsDAppController.kt index 3c77c9fb..2f48afec 100644 --- a/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/dapp/JsDAppController.kt +++ b/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/dapp/JsDAppController.kt @@ -24,11 +24,9 @@ package io.ton.walletkit.demo.e2e.dapp import android.content.ClipboardManager import android.util.Log import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextClearance -import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice @@ -122,27 +120,19 @@ class JsDAppController { fun openBrowser(url: String = DAPP_URL, injectTonConnect: Boolean = false) { jsBridge.clearCache() // Clear any cached WebView reference - val target = InstrumentationRegistry.getArguments().getString("browserUrl") - ?.takeIf { it.isNotBlank() } ?: url - - // Modern path: gear -> Investigation -> dApp Browser -> type URL -> open (inject / no-inject). - composeTestRule.onNodeWithTag(TestTags.INVESTIGATION_BUTTON).performClick() - composeTestRule.waitUntil(ELEMENT_TIMEOUT) { - composeTestRule.onAllNodesWithTag(TestTags.INVESTIGATION_BROWSER_ROW).fetchSemanticsNodes().isNotEmpty() - } - composeTestRule.onNodeWithTag(TestTags.INVESTIGATION_BROWSER_ROW).performClick() - - composeTestRule.waitUntil(ELEMENT_TIMEOUT) { - composeTestRule.onAllNodesWithTag(TestTags.BROWSER_URL_FIELD).fetchSemanticsNodes().isNotEmpty() + if (injectTonConnect) { + composeTestRule.onNodeWithContentDescription("Open TonConnect Browser") + .performClick() + } else { + composeTestRule.onNodeWithTag(TestTags.BROWSER_NO_INJECT_BUTTON) + .performClick() } - composeTestRule.onNodeWithTag(TestTags.BROWSER_URL_FIELD).performTextClearance() - composeTestRule.onNodeWithTag(TestTags.BROWSER_URL_FIELD).performTextInput(target) - val buttonTag = if (injectTonConnect) TestTags.BROWSER_INJECT_BUTTON else TestTags.BROWSER_NO_INJECT_BUTTON - composeTestRule.onNodeWithTag(buttonTag).performClick() composeTestRule.waitForIdle() - Log.d("JsDAppController", "Browser opened ($target, inject=$injectTonConnect), waiting for WebView...") + // Wait for WebView to load + + Log.d("JsDAppController", "Browser opened, waiting for WebView...") } /** diff --git a/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/wallet/WalletController.kt b/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/wallet/WalletController.kt index 7d65bc45..ace58129 100644 --- a/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/wallet/WalletController.kt +++ b/AndroidDemo/app/src/androidTest/java/io/ton/walletkit/demo/e2e/wallet/WalletController.kt @@ -83,11 +83,28 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { // =========================================== /** - * Whether the wallet home is visible. The home exposes [TestTags.WALLET_BALANCE]; returns true - * only when it is visible AND the [AddWalletSheet] is not in front of it. + * Check if we're on the legacy wallet home screen (the only screen the e2e suite knows how + * to drive). The legacy toolbar exposes [TestTags.BROWSER_NO_INJECT_BUTTON]; the modern + * [WalletScreen] does not. Returns true only when the legacy home is visible AND the + * [AddWalletSheet] is not in front of it. */ - fun isOnHomeScreen(): Boolean = isOnModernHome() && !isAddWalletSheetShowing() + fun isOnHomeScreen(): Boolean { + if (!isOnLegacyHome()) return false + return !isAddWalletSheetShowing() + } + private fun isOnLegacyHome(): Boolean = try { + composeTestRule.onNodeWithTag(TestTags.BROWSER_NO_INJECT_BUTTON).assertExists() + true + } catch (e: AssertionError) { + false + } + + /** + * The modern [WalletScreen] is the default for new installs. It carries no legacy testTags + * but exposes [TestTags.WALLET_BALANCE] on the balance area, which is also the 5-tap secret + * gesture target that toggles back to the legacy screen. + */ private fun isOnModernHome(): Boolean = try { composeTestRule.onNodeWithTag(TestTags.WALLET_BALANCE).assertExists() true @@ -276,8 +293,8 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { enterMnemonicOnImportScreen(mnemonic) } isAddWalletSheetShowing() -> { - Log.d("WalletController", "AddWalletSheet visible - using paste-all field") - importWalletViaAddSheet(mnemonic) + Log.d("WalletController", "Legacy AddWalletSheet visible - using paste-all field") + importWalletViaLegacySheet(mnemonic) } else -> { Log.w("WalletController", "importWallet: no recognised import UI - aborting") @@ -285,14 +302,15 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { } } - // Wait for the wallet home to appear after import succeeds. + // Wait for wallet home (either modern or legacy) to appear after import succeeds. composeTestRule.waitForIdle() try { - composeTestRule.waitUntil(15_000L) { isOnModernHome() } + composeTestRule.waitUntil(15_000L) { isOnLegacyHome() || isOnModernHome() } Log.d("WalletController", "Wallet loaded successfully in UI") } catch (e: Exception) { Log.e("WalletController", "Wallet home still not visible after timeout: ${e.message}") } + Log.d("WalletController", "After import - legacy: ${isOnLegacyHome()}, modern: ${isOnModernHome()}") } /** Modern import flow: paste the full phrase into the first tagged word field, tap Continue. */ @@ -318,8 +336,8 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { composeTestRule.waitForIdle() } - /** AddWalletSheet flow: paste the full phrase into the sheet's paste-all field. */ - private fun importWalletViaAddSheet(mnemonic: List) { + /** Legacy AddWalletSheet flow (only reachable when the dev legacy-screen toggle is on). */ + private fun importWalletViaLegacySheet(mnemonic: List) { val mnemonicString = mnemonic.joinToString(" ") composeTestRule.waitUntil(5_000L) { @@ -388,16 +406,35 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { Log.d("WalletController", "=== setupWallet called with ${mnemonic.size} word mnemonic ===") composeTestRule.waitForIdle() - // Already on the wallet home — wallet exists from a previous run. + // Check if we're already on the legacy home screen (wallet exists from a previous run + // and the dev toggle is already flipped). if (isOnHomeScreen()) { - Log.d("WalletController", "Already on home screen - wallet exists, skipping setup") + Log.d("WalletController", "Already on legacy home screen - wallet exists, skipping setup") return } - // A pre-wallet surface is showing — import directly. - if (isAddWalletSheetShowing() || isOnOnboardingScreen() || isOnImportScreen()) { - Log.d("WalletController", "Pre-wallet surface visible - importing wallet") + // Check if AddWalletSheet is already showing (password was set previously, dev toggle on) + if (isAddWalletSheetShowing()) { + Log.d("WalletController", "AddWalletSheet already showing - importing wallet directly") importWallet(mnemonic) + ensureLegacyScreen() + return + } + + // Modern first-run lands on the onboarding screen (or the import screen if the user + // already tapped through). Both are valid pre-wallet states. + if (isOnOnboardingScreen() || isOnImportScreen()) { + Log.d("WalletController", "Modern onboarding visible - importing wallet") + importWallet(mnemonic) + ensureLegacyScreen() + return + } + + // The post-import / post-unlock app lands on the modern WalletScreen by default. + // Tests target legacy UI, so toggle if needed. + if (isOnModernHome()) { + Log.d("WalletController", "On modern WalletScreen - wallet exists, toggling to legacy") + ensureLegacyScreen() return } @@ -413,24 +450,86 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { for (i in 1..10) { composeTestRule.waitForIdle() + // If on legacy home screen, wallet already exists and toggle was on if (isOnHomeScreen()) { - Log.d("WalletController", "On home after auth (check $i) - wallet exists, skipping import") + Log.d("WalletController", "On legacy home after auth (check $i) - wallet exists, skipping import") return } - if (isAddWalletSheetShowing() || isOnOnboardingScreen() || isOnImportScreen()) { - Log.d("WalletController", "Import surface after auth (check $i) - importing wallet") + // If AddWalletSheet is showing, import wallet + if (isAddWalletSheetShowing()) { + Log.d("WalletController", "AddWalletSheet showing after auth (check $i) - importing wallet") importWallet(mnemonic) + ensureLegacyScreen() + return + } + + // Modern first-run onboarding (or already on the import screen). + if (isOnOnboardingScreen() || isOnImportScreen()) { + Log.d("WalletController", "Onboarding visible after auth (check $i) - importing wallet") + importWallet(mnemonic) + ensureLegacyScreen() + return + } + + // Default landing for an existing-wallet app: the modern WalletScreen with the balance + // tile visible. Switch to legacy so the rest of the suite can drive it. + if (isOnModernHome()) { + Log.d("WalletController", "Modern home after auth (check $i) - toggling to legacy") + ensureLegacyScreen() return } - Log.d("WalletController", "Waiting for home/import surface (check $i)...") + Log.d("WalletController", "Waiting for home/addWallet screen (check $i)...") Thread.sleep(200) // Small delay between checks } // Fallback: try to import wallet anyway (this may fail) Log.w("WalletController", "No expected screen detected after 10 checks, attempting import as fallback") importWallet(mnemonic) + ensureLegacyScreen() + } + + /** + * Make sure we end up on the legacy wallet home. The new auth flow drops the user on + * [WalletScreen] by default; e2e tests were written against [LegacyWalletScreen], which is + * reached by the dev-only 5-tap secret on the balance tile. No-op if already on legacy. + */ + @Step("Switch to legacy wallet screen") + fun ensureLegacyScreen() { + composeTestRule.waitForIdle() + if (isOnLegacyHome()) { + Log.d("WalletController", "ensureLegacyScreen: already on legacy home") + return + } + + // Wait for the modern home (with the WALLET_BALANCE tile) to be ready before tapping. + try { + composeTestRule.waitUntil(UI_IDLE_TIMEOUT) { isOnModernHome() } + } catch (e: Exception) { + Log.w("WalletController", "ensureLegacyScreen: timed out waiting for modern home: ${e.message}") + } + + if (!isOnModernHome()) { + Log.w("WalletController", "ensureLegacyScreen: modern home not visible, skipping toggle") + return + } + + // DevToggleTaps requires 5 taps inside a 3-second window. performClick runs on the test + // thread back-to-back so they always land inside that window. + Log.d("WalletController", "ensureLegacyScreen: performing 5 quick taps on WALLET_BALANCE") + repeat(5) { + composeTestRule.onNodeWithTag(TestTags.WALLET_BALANCE).performClick() + } + composeTestRule.waitForIdle() + + try { + composeTestRule.waitUntil(UI_IDLE_TIMEOUT) { isOnLegacyHome() } + Log.d("WalletController", "ensureLegacyScreen: legacy home is now visible") + } catch (e: Exception) { + Log.w("WalletController", "ensureLegacyScreen: legacy home did not appear: ${e.message}") + } + composeTestRule.waitForIdle() } // =========================================== @@ -445,34 +544,43 @@ class WalletController(composeTestRule: ComposeTestRule? = null) { */ @Step("Connect by TonConnect URL") fun connectByUrl(url: String) { - // Modern path: gear -> Investigation -> TonConnect -> Connect to dApp -> URL dialog. - composeTestRule.onNodeWithTag(TestTags.INVESTIGATION_BUTTON).performClick() + // Wait for wallet home screen with Handle URL button composeTestRule.waitUntil(5000) { - composeTestRule.onAllNodesWithTag(TestTags.INVESTIGATION_TONCONNECT_ROW) + composeTestRule.onAllNodesWithTag(TestTags.HANDLE_URL_BUTTON) .fetchSemanticsNodes().isNotEmpty() } - composeTestRule.onNodeWithTag(TestTags.INVESTIGATION_TONCONNECT_ROW).performClick() - composeTestRule.waitUntil(5000) { - composeTestRule.onAllNodesWithTag(TestTags.INVESTIGATION_CONNECT_ROW) - .fetchSemanticsNodes().isNotEmpty() - } - composeTestRule.onNodeWithTag(TestTags.INVESTIGATION_CONNECT_ROW).performClick() + // Click "Handle URL" button to open dialog + composeTestRule.onNodeWithTag(TestTags.HANDLE_URL_BUTTON) + .performClick() + + composeTestRule.waitForIdle() + // Wait for the URL input field in the dialog composeTestRule.waitUntil(3000) { composeTestRule.onAllNodesWithTag(TestTags.TONCONNECT_URL_FIELD) .fetchSemanticsNodes().isNotEmpty() } - composeTestRule.onNodeWithTag(TestTags.TONCONNECT_URL_FIELD).performTextInput(url) - composeTestRule.onNodeWithTag(TestTags.TONCONNECT_PROCESS_BUTTON).performClick() + + // Enter the TonConnect URL + composeTestRule.onNodeWithTag(TestTags.TONCONNECT_URL_FIELD) + .performTextInput(url) + + // Click the process button + composeTestRule.onNodeWithTag(TestTags.TONCONNECT_PROCESS_BUTTON) + .performClick() + composeTestRule.waitForIdle() } - /** Wait for the wallet home screen (balance tile) to be visible. */ + /** + * Wait for the (legacy) wallet home screen to be visible. The e2e suite runs against the + * legacy screen, so [setupWallet] ensures we are toggled there before this is called. + */ @Step("Wait for wallet home screen") fun waitForWalletHome() { composeTestRule.waitUntil(SHEET_APPEAR_TIMEOUT) { - composeTestRule.onAllNodesWithTag(TestTags.WALLET_BALANCE) + composeTestRule.onAllNodesWithTag(TestTags.WALLET_ADDRESS) .fetchSemanticsNodes().isNotEmpty() } } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/MainActivity.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/MainActivity.kt index 0f1602c9..d282e280 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/MainActivity.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/MainActivity.kt @@ -45,7 +45,9 @@ import io.ton.walletkit.demo.designsystem.theme.TonTheme import io.ton.walletkit.demo.presentation.actions.WalletActionsImpl import io.ton.walletkit.demo.presentation.dev.DevPreferences import io.ton.walletkit.demo.presentation.state.CreateWalletFlow +import io.ton.walletkit.demo.presentation.state.SheetState import io.ton.walletkit.demo.presentation.ui.screen.CreatePinScreen +import io.ton.walletkit.demo.presentation.ui.screen.LegacyWalletScreen import io.ton.walletkit.demo.presentation.ui.screen.UnlockPinScreen import io.ton.walletkit.demo.presentation.ui.screen.WalletScreen import io.ton.walletkit.demo.presentation.ui.screen.onboarding.CreateWalletOnboardingScreen @@ -130,6 +132,7 @@ private fun AppNavigation( else -> { walletKit.value?.let { kit -> + val useLegacyMainScreen by DevPreferences.useLegacyMainScreen.collectAsState() val createFlow by viewModel.createWalletFlow.collectAsState() val walletsBootstrapped = state.walletsBootstrapped @@ -141,10 +144,14 @@ private fun AppNavigation( } } - LaunchedEffect(hasWallet, walletsBootstrapped) { + LaunchedEffect(hasWallet, useLegacyMainScreen, walletsBootstrapped) { if (!walletsBootstrapped) return@LaunchedEffect if (hasWallet) return@LaunchedEffect - if (createFlow is CreateWalletFlow.Idle) { + if (useLegacyMainScreen) { + if (state.sheetState !is SheetState.AddWallet) { + viewModel.openAddWalletSheet() + } + } else if (createFlow is CreateWalletFlow.Idle) { viewModel.showCreateWalletOnboarding() } } @@ -153,13 +160,23 @@ private fun AppNavigation( flow = createFlow, viewModel = viewModel, fallback = { - WalletScreen( - state = state, - walletKit = kit, - nftsViewModel = nftsViewModel, - swapViewModel = swapViewModel, - actions = walletActions, - ) + if (useLegacyMainScreen) { + LegacyWalletScreen( + state = state, + walletKit = kit, + nftsViewModel = nftsViewModel, + swapViewModel = swapViewModel, + actions = walletActions, + ) + } else { + WalletScreen( + state = state, + walletKit = kit, + nftsViewModel = nftsViewModel, + swapViewModel = swapViewModel, + actions = walletActions, + ) + } }, ) } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevPreferences.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevPreferences.kt index 340ec8c1..07fbc176 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevPreferences.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevPreferences.kt @@ -22,17 +22,52 @@ package io.ton.walletkit.demo.presentation.dev import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow object DevPreferences { private const val PREFS_NAME = "dev_preferences" + private const val KEY_USE_LEGACY_MAIN_SCREEN = "use_legacy_main_screen" - fun ensureLoaded(context: Context) = Unit + private val _useLegacyMainScreen = MutableStateFlow(false) + val useLegacyMainScreen: StateFlow = _useLegacyMainScreen.asStateFlow() - fun reset(context: Context) { + @Volatile + private var loaded = false + + fun ensureLoaded(context: Context) { + if (loaded) return + synchronized(this) { + if (loaded) return + val prefs = context.applicationContext + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + _useLegacyMainScreen.value = prefs.getBoolean(KEY_USE_LEGACY_MAIN_SCREEN, false) + loaded = true + } + } + + fun toggleLegacyMainScreen(context: Context): Boolean { + ensureLoaded(context) + val next = !_useLegacyMainScreen.value context.applicationContext .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) .edit() - .clear() - .commit() + .putBoolean(KEY_USE_LEGACY_MAIN_SCREEN, next) + .apply() + _useLegacyMainScreen.value = next + return next + } + + fun reset(context: Context) { + synchronized(this) { + context.applicationContext + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + _useLegacyMainScreen.value = false + loaded = false + } } } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevToggleTaps.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevToggleTaps.kt new file mode 100644 index 00000000..00fc879a --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/dev/DevToggleTaps.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.dev + +import android.os.SystemClock +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput + +fun Modifier.devToggleTaps( + requiredTaps: Int = 5, + windowMs: Long = 3_000L, + onTrigger: () -> Unit, +): Modifier = this.then( + Modifier.pointerInput(requiredTaps, windowMs) { + var count = 0 + var firstAt = 0L + detectTapGestures(onTap = { + val now = SystemClock.elapsedRealtime() + if (count == 0 || now - firstAt > windowMs) { + count = 1 + firstAt = now + } else { + count++ + } + if (count >= requiredTaps) { + count = 0 + onTrigger() + } + }) + }, +) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/QuickActionsCard.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/QuickActionsCard.kt new file mode 100644 index 00000000..417b4459 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/QuickActionsCard.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.R +import io.ton.walletkit.demo.presentation.util.TestTags + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun QuickActionsCard( + onHandleUrl: () -> Unit, + onAddWallet: () -> Unit, + onRefresh: () -> Unit, + onSwap: () -> Unit = {}, +) { + ElevatedCard { + Column( + modifier = Modifier.padding(ACTION_CARD_PADDING), + verticalArrangement = Arrangement.spacedBy(ACTION_CARD_SPACING), + ) { + Text(stringResource(R.string.quick_actions_title), style = MaterialTheme.typography.titleMedium) + FlowRow(horizontalArrangement = Arrangement.spacedBy(ACTION_BUTTON_SPACING)) { + FilledTonalButton( + onClick = onHandleUrl, + modifier = Modifier.testTag(TestTags.HANDLE_URL_BUTTON), + ) { Text(stringResource(R.string.action_handle_url)) } + FilledTonalButton(onClick = onAddWallet) { Text(stringResource(R.string.action_add_wallet)) } + FilledTonalButton(onClick = onRefresh) { Text(stringResource(R.string.action_refresh)) } + FilledTonalButton(onClick = onSwap) { Text("Swap") } + } + } + } +} + +private val ACTION_CARD_PADDING = 20.dp +private val ACTION_CARD_SPACING = 16.dp +private val ACTION_BUTTON_SPACING = 12.dp + +@Preview(showBackground = true) +@Composable +private fun QuickActionsCardPreview() { + QuickActionsCard(onHandleUrl = {}, onAddWallet = {}, onRefresh = {}) +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/StatusHeader.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/StatusHeader.kt new file mode 100644 index 00000000..d13e3385 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/StatusHeader.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.R +import io.ton.walletkit.demo.presentation.state.WalletUiState +import io.ton.walletkit.demo.presentation.ui.preview.PreviewData + +@Composable +fun StatusHeader(state: WalletUiState) { + val clipboardManager = LocalClipboardManager.current + + // Auto-copy to clipboard when clipboardContent is set + LaunchedEffect(state.clipboardContent) { + state.clipboardContent?.let { content -> + clipboardManager.setText(AnnotatedString(content)) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(STATUS_HEADER_SPACING)) { + Text( + text = state.status.ifBlank { "" }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + if (state.lastUpdated != null) { + Text( + text = stringResource(R.string.status_updated_now), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private val STATUS_HEADER_SPACING = 8.dp + +@Preview(showBackground = true) +@Composable +private fun StatusHeaderPreview() { + StatusHeader(state = PreviewData.uiState) +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletCard.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletCard.kt new file mode 100644 index 00000000..5a0c4d66 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/WalletCard.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.components + +import android.content.ClipData +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.R +import io.ton.walletkit.demo.presentation.model.WalletSummary +import io.ton.walletkit.demo.presentation.ui.icons.ContentCopy +import io.ton.walletkit.demo.presentation.ui.preview.PreviewData +import io.ton.walletkit.demo.presentation.util.TestTags +import io.ton.walletkit.demo.presentation.util.abbreviated +import kotlinx.coroutines.launch + +@Composable +fun WalletCard( + wallet: WalletSummary, + onDetails: () -> Unit, + onSend: () -> Unit = {}, + onStake: () -> Unit = {}, + isStreamingConnected: Boolean? = null, + onRefresh: () -> Unit = {}, +) { + val clipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() + ElevatedCard { + Column( + modifier = Modifier.padding(WALLET_CARD_PADDING), + verticalArrangement = Arrangement.spacedBy(WALLET_CARD_SECTION_SPACING), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Column(verticalArrangement = Arrangement.spacedBy(WALLET_CARD_LABEL_SPACING)) { + Text(wallet.name, style = MaterialTheme.typography.titleMedium) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(STREAMING_DOT_SPACING), + ) { + NetworkBadge(wallet.network) + if (isStreamingConnected != null) { + Box( + modifier = Modifier + .size(STREAMING_DOT_SIZE) + .background( + color = if (isStreamingConnected) STREAMING_DOT_CONNECTED else STREAMING_DOT_DISCONNECTED, + shape = CircleShape, + ), + ) + } + } + } + TextButton(onClick = onDetails) { Text(stringResource(R.string.action_details)) } + } + + Column(verticalArrangement = Arrangement.spacedBy(WALLET_CARD_CONTENT_SPACING)) { + Text( + stringResource(R.string.label_wallet_address), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = wallet.address.abbreviated(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f).testTag(TestTags.WALLET_ADDRESS), + ) + IconButton( + onClick = { + coroutineScope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(CLIPBOARD_WALLET_ADDRESS_LABEL, wallet.address)), + ) + } + }, + ) { + Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.action_copy_address)) + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(WALLET_CARD_LABEL_SPACING)) { + Text( + stringResource(R.string.label_balance), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + val balanceText = wallet.balance ?: stringResource(R.string.wallet_balance_placeholder) + Text(balanceText, style = MaterialTheme.typography.headlineSmall) + IconButton(onClick = onRefresh) { + Icon( + Icons.Default.Refresh, + contentDescription = stringResource(R.string.action_refresh), + ) + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(WALLET_CARD_BUTTON_SPACING), + ) { + Button( + onClick = onSend, + modifier = Modifier + .weight(1f) + .testTag(TestTags.WALLET_SEND_BUTTON), + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = null, + modifier = Modifier.padding(end = SEND_ICON_PADDING), + ) + Text(stringResource(R.string.action_send)) + } + OutlinedButton( + onClick = onStake, + modifier = Modifier + .weight(1f) + .testTag(TestTags.WALLET_STAKE_BUTTON), + ) { + Text(stringResource(R.string.action_stake)) + } + } + } + } + } +} + +private val WALLET_CARD_PADDING = 20.dp +private val WALLET_CARD_SECTION_SPACING = 12.dp +private val WALLET_CARD_CONTENT_SPACING = 8.dp +private val WALLET_CARD_LABEL_SPACING = 4.dp +private val WALLET_CARD_BUTTON_SPACING = 8.dp +private val SEND_ICON_PADDING = 4.dp +private val STREAMING_DOT_SIZE = 8.dp +private val STREAMING_DOT_SPACING = 6.dp +private val STREAMING_DOT_CONNECTED = Color(0xFF4CAF50) +private val STREAMING_DOT_DISCONNECTED = Color(0xFFBDBDBD) +private const val CLIPBOARD_WALLET_ADDRESS_LABEL = "wallet_address" + +@Preview(showBackground = true) +@Composable +private fun WalletCardPreview() { + WalletCard(wallet = PreviewData.wallet, onDetails = {}, onSend = {}, onStake = {}) +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt index 858493c0..050422d7 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeBalance.kt @@ -34,6 +34,7 @@ import io.ton.walletkit.demo.designsystem.components.text.TonText import io.ton.walletkit.demo.designsystem.icons.TonIcon import io.ton.walletkit.demo.designsystem.icons.TonIconImage import io.ton.walletkit.demo.designsystem.theme.TonTheme +import io.ton.walletkit.demo.presentation.dev.devToggleTaps import io.ton.walletkit.demo.presentation.util.TestTags @Composable @@ -44,6 +45,7 @@ fun WalletHomeBalance( truncatedAddress: String, onCopyAddress: () -> Unit, modifier: Modifier = Modifier, + onSecretTap: (() -> Unit)? = null, ) { val animated = rememberCountUp(totalBalance) val formatted = formatCountUp(animated, maxFractionDigits) @@ -51,8 +53,14 @@ fun WalletHomeBalance( val integerPart = if (dotIndex < 0) formatted else formatted.substring(0, dotIndex) val fractionPart = if (dotIndex < 0) "" else formatted.substring(dotIndex) + val gestureModifier = if (onSecretTap != null) { + Modifier.devToggleTaps(onTrigger = onSecretTap) + } else { + Modifier + } Column( modifier = modifier + .then(gestureModifier) .testTag(TestTags.WALLET_BALANCE), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt index 2206b001..2fa9886b 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/components/wallet/home/WalletHomeContent.kt @@ -57,6 +57,7 @@ fun WalletHomeContent( onShowAllNFTs: () -> Unit, onNFTTap: (WalletHomeNFTPreview) -> Unit, modifier: Modifier = Modifier, + onBalanceSecretTap: (() -> Unit)? = null, ) { val scrollState = rememberScrollState() @@ -76,6 +77,7 @@ fun WalletHomeContent( .fillMaxWidth() .padding(horizontal = 16.dp) .padding(top = 8.dp, bottom = 8.dp), + onSecretTap = onBalanceSecretTap, ) WalletHomeActionsRow( diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt new file mode 100644 index 00000000..16a12bfc --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/LegacyWalletScreen.kt @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.screen + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.ton.walletkit.ITONWalletKit +import io.ton.walletkit.api.generated.TONNFT +import io.ton.walletkit.demo.R +import io.ton.walletkit.demo.designsystem.theme.TonTheme +import io.ton.walletkit.demo.presentation.actions.WalletActions +import io.ton.walletkit.demo.presentation.dev.DevPreferences +import io.ton.walletkit.demo.presentation.dev.devToggleTaps +import io.ton.walletkit.demo.presentation.model.NFTDetails +import io.ton.walletkit.demo.presentation.state.SheetState +import io.ton.walletkit.demo.presentation.state.WalletUiState +import io.ton.walletkit.demo.presentation.ui.components.QuickActionsCard +import io.ton.walletkit.demo.presentation.ui.components.StatusHeader +import io.ton.walletkit.demo.presentation.ui.components.WalletSwitcher +import io.ton.walletkit.demo.presentation.ui.dialog.SignerConfirmationDialog +import io.ton.walletkit.demo.presentation.ui.dialog.UrlPromptDialog +import io.ton.walletkit.demo.presentation.ui.icons.Language +import io.ton.walletkit.demo.presentation.ui.sections.EventLogSection +import io.ton.walletkit.demo.presentation.ui.sections.JettonsSection +import io.ton.walletkit.demo.presentation.ui.sections.MasterchainInfoSection +import io.ton.walletkit.demo.presentation.ui.sections.NFTsSection +import io.ton.walletkit.demo.presentation.ui.sections.SessionsSection +import io.ton.walletkit.demo.presentation.ui.sections.WalletsSection +import io.ton.walletkit.demo.presentation.ui.sheet.AddWalletSheet +import io.ton.walletkit.demo.presentation.ui.sheet.BrowserSession +import io.ton.walletkit.demo.presentation.ui.sheet.BrowserSheet +import io.ton.walletkit.demo.presentation.ui.sheet.ConnectRequestSheet +import io.ton.walletkit.demo.presentation.ui.sheet.JettonDetailsSheet +import io.ton.walletkit.demo.presentation.ui.sheet.SignDataSheet +import io.ton.walletkit.demo.presentation.ui.sheet.SignMessageRequestSheet +import io.ton.walletkit.demo.presentation.ui.sheet.StakingSheet +import io.ton.walletkit.demo.presentation.ui.sheet.SwapSheet +import io.ton.walletkit.demo.presentation.ui.sheet.TransactionDetailSheet +import io.ton.walletkit.demo.presentation.ui.sheet.TransactionRequestSheet +import io.ton.walletkit.demo.presentation.ui.sheet.TransferJettonSheet +import io.ton.walletkit.demo.presentation.ui.sheet.WalletDetailsSheet +import io.ton.walletkit.demo.presentation.util.TestTags +import io.ton.walletkit.demo.presentation.viewmodel.NFTsListViewModel +import io.ton.walletkit.demo.presentation.viewmodel.SwapViewModel + +// Pre-redesign main screen, kept behind a dev toggle. 5 taps on the title bar +// flips back to the new home screen. +private const val DEFAULT_DAPP_URL = "https://allure-test-runner.vercel.app/e2e" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LegacyWalletScreen( + state: WalletUiState, + walletKit: ITONWalletKit, + nftsViewModel: NFTsListViewModel?, + swapViewModel: SwapViewModel?, + actions: WalletActions, +) { + val scrollState = rememberScrollState() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + val onRefreshAll: () -> Unit = { + actions.onRefresh() + actions.onRefreshJettons() + nftsViewModel?.refresh() + } + + var selectedNFT by remember { mutableStateOf(null) } + val nftDetailsSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden }, + ) + + val injectedSession = remember { BrowserSession(injectTonConnect = true) } + val plainSession = remember { BrowserSession(injectTonConnect = false) } + + DisposableEffect(Unit) { + onDispose { + injectedSession.destroyAllWebViews() + plainSession.destroyAllWebViews() + } + } + + LaunchedEffect(state.error) { + val error = state.error ?: return@LaunchedEffect + snackbarHostState.showSnackbar(error) + } + + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden }, + ) + val sheet = state.sheetState + val showSheet = sheet !is SheetState.None + LaunchedEffect(state.sheetState) { + if (state.sheetState is SheetState.None && sheetState.isVisible) { + sheetState.hide() + } + val browserSheet = state.sheetState as? SheetState.Browser ?: return@LaunchedEffect + val session = if (browserSheet.injectTonConnect) injectedSession else plainSession + if (session.tabs.isEmpty()) session.openTab(browserSheet.url) + } + val activeWallet = state.wallets.firstOrNull { it.walletId == state.activeWalletId } + ?: state.wallets.firstOrNull() + if (showSheet) { + ModalBottomSheet( + onDismissRequest = actions::onDismissSheet, + sheetState = sheetState, + containerColor = TonTheme.colors.bgPrimary, + dragHandle = null, + ) { + when (sheet) { + SheetState.AddWallet -> AddWalletSheet( + onDismiss = actions::onDismissSheet, + onImportWallet = actions::onImportWallet, + onGenerateWallet = actions::onGenerateWallet, + walletCount = state.wallets.size, + ) + + is SheetState.Connect -> ConnectRequestSheet( + request = sheet.request, + wallets = state.wallets, + onApprove = actions::onApproveConnect, + onReject = actions::onRejectConnect, + ) + + is SheetState.Transaction -> TransactionRequestSheet( + request = sheet.request, + onApprove = { actions.onApproveTransaction(sheet.request) }, + onReject = { actions.onRejectTransaction(sheet.request) }, + wallet = state.wallets.firstOrNull { it.address == sheet.request.walletAddress } + ?: activeWallet, + ) + + is SheetState.SignData -> SignDataSheet( + request = sheet.request, + onApprove = { actions.onApproveSignData(sheet.request) }, + onReject = { actions.onRejectSignData(sheet.request) }, + wallet = state.wallets.firstOrNull { it.address == sheet.request.walletAddress } + ?: activeWallet, + ) + + is SheetState.SignMessage -> SignMessageRequestSheet( + request = sheet.request, + onApprove = { actions.onApproveSignMessage(sheet.request) }, + onReject = { actions.onRejectSignMessage(sheet.request) }, + wallet = state.wallets.firstOrNull { it.address == sheet.request.walletAddress } + ?: activeWallet, + ) + + is SheetState.WalletDetails -> WalletDetailsSheet( + wallet = sheet.wallet, + onDismiss = actions::onDismissSheet, + ) + + is SheetState.SendTransaction -> SendTransactionScreen( + wallet = sheet.wallet, + walletKit = walletKit, + onBack = actions::onDismissSheet, + ) + + is SheetState.Staking -> StakingSheet( + wallet = sheet.wallet, + walletKit = walletKit, + sheetKey = sheet.openedAt, + onDismiss = actions::onDismissSheet, + ) + + is SheetState.TransactionDetail -> TransactionDetailSheet( + transaction = sheet.transaction, + onDismiss = actions::onDismissSheet, + ) + + is SheetState.Browser -> BrowserSheet( + session = if (sheet.injectTonConnect) injectedSession else plainSession, + onClose = actions::onDismissSheet, + walletKit = walletKit, + ) + + is SheetState.JettonDetails -> { + JettonDetailsSheet( + jetton = sheet.jetton, + onDismiss = actions::onDismissSheet, + onTransfer = { actions.onShowTransferJetton(sheet.jetton) }, + ) + } + + is SheetState.TransferJetton -> { + TransferJettonSheet( + jetton = sheet.jetton, + onDismiss = actions::onDismissSheet, + onTransfer = { recipient, amount, comment -> + actions.onTransferJetton(sheet.jetton.jettonAddress ?: "", recipient, amount, comment) + }, + isLoading = false, + ) + } + + is SheetState.Swap -> { + swapViewModel?.let { vm -> + SwapSheet(viewModel = vm, onDismiss = actions::onDismissSheet) + } + } + + SheetState.None -> Unit + } + } + } + + if (state.isUrlPromptVisible) { + UrlPromptDialog( + onDismiss = actions::onDismissUrlPrompt, + onConfirm = actions::onHandleUrl, + ) + } + + state.pendingSignerConfirmation?.let { request -> + SignerConfirmationDialog( + request = request, + onConfirm = actions::onConfirmSignerApproval, + onCancel = actions::onCancelSignerApproval, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.wallet_screen_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.devToggleTaps { + val nowLegacy = DevPreferences.toggleLegacyMainScreen(context) + Toast.makeText( + context, + if (nowLegacy) "Legacy main screen ON" else "Legacy main screen OFF", + Toast.LENGTH_SHORT, + ).show() + }, + ) + }, + actions = { + IconButton(onClick = { actions.onOpenBrowser(DEFAULT_DAPP_URL) }) { + Icon(painterResource(R.drawable.ic_ton), contentDescription = "Open TonConnect Browser") + } + IconButton( + onClick = { actions.onOpenBrowser(DEFAULT_DAPP_URL, injectTonConnect = false) }, + modifier = Modifier.testTag(TestTags.BROWSER_NO_INJECT_BUTTON), + ) { + Icon(Icons.Default.Language, contentDescription = "Open Plain Browser") + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(scrollState) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Box(modifier = Modifier.fillMaxWidth().height(4.dp)) { + if (state.isLoadingWallets || state.isLoadingSessions) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + StatusHeader(state) + + QuickActionsCard( + onHandleUrl = actions::onUrlPromptClick, + onAddWallet = actions::onAddWalletClick, + onRefresh = onRefreshAll, + onSwap = actions::onSwapClick, + ) + + if (state.wallets.size > 1) { + WalletSwitcher( + wallets = state.wallets, + activeWalletId = state.activeWalletId, + isExpanded = state.isWalletSwitcherExpanded, + onToggle = actions::onToggleWalletSwitcher, + onSwitchWallet = actions::onSwitchWallet, + onRemoveWallet = actions::onRemoveWallet, + onRenameWallet = actions::onRenameWallet, + ) + } + + WalletsSection( + activeWallet = activeWallet, + totalWallets = state.wallets.size, + onWalletSelected = actions::onWalletDetails, + onSendFromWallet = actions::onSendFromWallet, + onStakeFromWallet = actions::onStakeFromWallet, + isStreamingConnected = state.isStreamingConnected, + onRefresh = actions::onRefresh, + ) + + if (nftsViewModel != null) { + NFTsSection( + viewModel = nftsViewModel, + onNFTClick = { nft -> selectedNFT = nft }, + ) + } + + JettonsSection( + jettons = state.jettons, + isLoading = state.isLoadingJettons, + error = state.jettonsError, + canLoadMore = state.canLoadMoreJettons, + onJettonClick = actions::onShowJettonDetails, + onLoadMore = actions::onLoadMoreJettons, + onRefresh = actions::onRefreshJettons, + ) + + if (activeWallet != null) { + MasterchainInfoSection(network = activeWallet.network) + } + + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Transaction History", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Coming Soon", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + SessionsSection( + sessions = state.sessions, + onDisconnect = actions::onDisconnectSession, + ) + + if (state.events.isNotEmpty()) { + EventLogSection(events = state.events) + } + + Spacer(modifier = Modifier.height(48.dp)) + } + } + + selectedNFT?.let { nft -> + ModalBottomSheet( + onDismissRequest = { selectedNFT = null }, + sheetState = nftDetailsSheetState, + containerColor = TonTheme.colors.bgPrimary, + dragHandle = null, + ) { + activeWallet?.let { wallet -> + val nftDetails = NFTDetails.from(nft) + NFTDetailsScreen( + walletId = wallet.walletId, + walletKit = walletKit, + nftDetails = nftDetails, + onClose = { selectedNFT = null }, + onTransferSuccess = { + val nftAddress = selectedNFT?.address?.value + nftAddress?.let { addr -> nftsViewModel?.removeNft(addr) } + nftsViewModel?.refreshWithDelay() + }, + ) + } + } + } +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt index 59dfac7a..15f119f1 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt @@ -19,8 +19,13 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +@file:SuppressLint("SetJavaScriptEnabled") + package io.ton.walletkit.demo.presentation.ui.screen +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebView import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -30,9 +35,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -41,30 +43,21 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import io.ton.walletkit.ITONWalletKit -import io.ton.walletkit.api.ChainIds -import io.ton.walletkit.api.MAINNET -import io.ton.walletkit.api.TESTNET -import io.ton.walletkit.api.TETRA -import io.ton.walletkit.api.WalletVersions -import io.ton.walletkit.api.generated.TONNetwork import io.ton.walletkit.demo.R -import io.ton.walletkit.demo.designsystem.components.button.TonButton -import io.ton.walletkit.demo.designsystem.components.button.TonButtonConfig -import io.ton.walletkit.demo.designsystem.components.segmentedcontrol.TonSegmentedControl import io.ton.walletkit.demo.designsystem.components.text.TonText import io.ton.walletkit.demo.designsystem.theme.SmoothCornerShape import io.ton.walletkit.demo.designsystem.theme.TonTheme -import io.ton.walletkit.demo.domain.model.WalletInterfaceType import io.ton.walletkit.demo.presentation.ui.dialog.UrlPromptDialog +import io.ton.walletkit.demo.presentation.ui.screen.iframesec.RealBridgeIframeCase import io.ton.walletkit.demo.presentation.ui.screen.iframesec.WalletKitRealBridgeIframeScreen import io.ton.walletkit.demo.presentation.util.QrScanner -import io.ton.walletkit.demo.presentation.util.TestTags +import io.ton.walletkit.extensions.injectTonConnect -private enum class InvestigationPage { Tonconnect, Browser, AddWallet, RealBridge } +private enum class InvestigationPage { Tonconnect, DappWebView, RealBridge } /** * Developer "Wallet Kit Investigation" screen: a list of debug tools reached from the wallet-home @@ -75,9 +68,6 @@ private enum class InvestigationPage { Tonconnect, Browser, AddWallet, RealBridg fun WalletKitInvestigationScreen( onBack: () -> Unit, onConnect: (String) -> Unit, - onOpenBrowser: (String, Boolean) -> Unit, - onImportWallet: (String, TONNetwork, List, String, String, WalletInterfaceType) -> Unit, - onGenerateWallet: (String, TONNetwork, String, WalletInterfaceType) -> Unit, walletKit: ITONWalletKit, modifier: Modifier = Modifier, ) { @@ -89,25 +79,9 @@ fun WalletKitInvestigationScreen( WalletKitTonconnectScreen(onBack = { page = null }, onConnect = onConnect, modifier = modifier) return } - InvestigationPage.Browser -> { + InvestigationPage.DappWebView -> { BackHandler { page = null } - BrowserLauncherScreen(onBack = { page = null }, onOpenBrowser = onOpenBrowser, modifier = modifier) - return - } - InvestigationPage.AddWallet -> { - BackHandler { page = null } - TestAddWalletScreen( - onBack = { page = null }, - onImport = { name, network, mnemonic, secretKey, version, interfaceType -> - onImportWallet(name, network, mnemonic, secretKey, version, interfaceType) - page = null - }, - onGenerate = { name, network, version, interfaceType -> - onGenerateWallet(name, network, version, interfaceType) - page = null - }, - modifier = modifier, - ) + DappWebViewScreen(onBack = { page = null }, walletKit = walletKit, modifier = modifier) return } InvestigationPage.RealBridge -> { @@ -133,18 +107,8 @@ fun WalletKitInvestigationScreen( InvestigationRow( title = stringResource(R.string.investigation_tonconnect), onClick = { page = InvestigationPage.Tonconnect }, - modifier = Modifier.testTag(TestTags.INVESTIGATION_TONCONNECT_ROW), - ) - InvestigationRow( - title = "dApp Browser", - onClick = { page = InvestigationPage.Browser }, - modifier = Modifier.testTag(TestTags.INVESTIGATION_BROWSER_ROW), - ) - InvestigationRow( - title = "Add Wallet", - onClick = { page = InvestigationPage.AddWallet }, - modifier = Modifier.testTag(TestTags.INVESTIGATION_ADD_WALLET_ROW), ) + InvestigationRow(title = "dApp WebView", onClick = { page = InvestigationPage.DappWebView }) InvestigationRow( title = "Iframe Security — Real dApp Bridge", onClick = { page = InvestigationPage.RealBridge }, @@ -178,7 +142,6 @@ private fun WalletKitTonconnectScreen( InvestigationRow( title = stringResource(R.string.investigation_connect_to_dapp), onClick = { showPrompt = true }, - modifier = Modifier.testTag(TestTags.INVESTIGATION_CONNECT_ROW), ) InvestigationRow( title = stringResource(R.string.investigation_scan_qr), @@ -206,179 +169,45 @@ private fun WalletKitTonconnectScreen( } } -private const val DEFAULT_DAPP_URL = "https://tonconnect-demo-dapp-with-react-ui.vercel.app/" - -@Composable -private fun BrowserLauncherScreen( - onBack: () -> Unit, - onOpenBrowser: (String, Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - var url by remember { mutableStateOf(DEFAULT_DAPP_URL) } - Column( - modifier = modifier - .fillMaxSize() - .background(TonTheme.colors.bgSecondary), - ) { - SubScreenTopBar(title = "dApp Browser", onBack = onBack) - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - TextField( - value = url, - onValueChange = { url = it }, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.BROWSER_URL_FIELD), - ) - InvestigationRow( - title = "Open (injected)", - onClick = { onOpenBrowser(url, true) }, - modifier = Modifier.testTag(TestTags.BROWSER_INJECT_BUTTON), - ) - InvestigationRow( - title = "Open (no-inject)", - onClick = { onOpenBrowser(url, false) }, - modifier = Modifier.testTag(TestTags.BROWSER_NO_INJECT_BUTTON), - ) - } - } -} - +/** Minimal dApp WebView with the real WalletKit injection — mirrors the iOS investigation entry. */ @Composable -private fun TestAddWalletScreen( +private fun DappWebViewScreen( onBack: () -> Unit, - onImport: (String, TONNetwork, List, String, String, WalletInterfaceType) -> Unit, - onGenerate: (String, TONNetwork, String, WalletInterfaceType) -> Unit, + walletKit: ITONWalletKit, modifier: Modifier = Modifier, ) { - var name by remember { mutableStateOf("Test wallet") } - var network by remember { mutableStateOf(TONNetwork.MAINNET) } - var version by remember { mutableStateOf(WalletVersions.V5R1) } - var interfaceType by remember { mutableStateOf(WalletInterfaceType.MNEMONIC) } - var mnemonic by remember { mutableStateOf("") } - var secretKey by remember { mutableStateOf("") } - Column( modifier = modifier .fillMaxSize() .background(TonTheme.colors.bgSecondary), ) { - SubScreenTopBar(title = "Add Wallet", onBack = onBack) - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - AddWalletFieldLabel("Name") - TextField( - value = name, - onValueChange = { name = it }, - singleLine = true, - modifier = Modifier.fillMaxWidth().testTag(TestTags.ADD_WALLET_NAME_FIELD), - ) - - AddWalletFieldLabel("Network") - TonSegmentedControl( - selection = network, - items = listOf(TONNetwork.MAINNET, TONNetwork.TESTNET, TONNetwork.TETRA), - title = ::networkLabel, - onSelect = { network = it }, - modifier = Modifier.fillMaxWidth(), - ) - - AddWalletFieldLabel("Version") - TonSegmentedControl( - selection = version, - items = listOf(WalletVersions.V5R1, WalletVersions.V4R2), - title = { it }, - onSelect = { version = it }, - modifier = Modifier.fillMaxWidth(), - ) - - AddWalletFieldLabel("Interface") - TonSegmentedControl( - selection = interfaceType, - items = WalletInterfaceType.entries, - title = ::interfaceLabel, - onSelect = { interfaceType = it }, - modifier = Modifier.fillMaxWidth(), - ) - - if (interfaceType == WalletInterfaceType.SECRET_KEY) { - AddWalletFieldLabel("Secret key (hex)") - TextField( - value = secretKey, - onValueChange = { secretKey = it.trim() }, - singleLine = true, - modifier = Modifier.fillMaxWidth().testTag(TestTags.ADD_WALLET_SECRET_KEY_FIELD), - ) - } else { - AddWalletFieldLabel("Recovery phrase") - TextField( - value = mnemonic, - onValueChange = { mnemonic = it }, - minLines = 2, - modifier = Modifier.fillMaxWidth().testTag(TestTags.ADD_WALLET_MNEMONIC_FIELD), - ) - } - - TonButton( - text = "Import", - onClick = { - val words = mnemonic.trim().lowercase().split(Regex("\\s+")).filter { it.isNotBlank() } - onImport(name, network, words, secretKey, version, interfaceType) - }, - config = TonButtonConfig.Primary, - modifier = Modifier.fillMaxWidth().testTag(TestTags.ADD_WALLET_IMPORT_BUTTON), - ) - TonButton( - text = "Generate", - onClick = { onGenerate(name, network, version, interfaceType) }, - config = TonButtonConfig.Secondary, - enabled = interfaceType != WalletInterfaceType.SECRET_KEY, - modifier = Modifier.fillMaxWidth().testTag(TestTags.ADD_WALLET_GENERATE_BUTTON), - ) - } + SubScreenTopBar(title = "dApp WebView", onBack = onBack) + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + injectTonConnect(walletKit) + loadUrl(RealBridgeIframeCase.DAPP_URL) + } + }, + ) } } @Composable -private fun AddWalletFieldLabel(text: String) { - TonText( - text = text, - style = TonTheme.typography.subheadline2, - color = TonTheme.colors.textSecondary, - ) -} - -private fun networkLabel(network: TONNetwork): String = when (network.chainId) { - ChainIds.MAINNET -> "Mainnet" - ChainIds.TESTNET -> "Testnet" - ChainIds.TETRA -> "Tetra" - else -> "Unknown" -} - -private fun interfaceLabel(type: WalletInterfaceType): String = when (type) { - WalletInterfaceType.MNEMONIC -> "Mnemonic" - WalletInterfaceType.SECRET_KEY -> "Secret Key" - WalletInterfaceType.SIGNER -> "Signer" -} - -@Composable -private fun InvestigationRow(title: String, onClick: () -> Unit, modifier: Modifier = Modifier) { +private fun InvestigationRow(title: String, onClick: () -> Unit) { val shape = SmoothCornerShape(12.dp) TonText( text = title, style = TonTheme.typography.body, color = TonTheme.colors.textPrimary, - modifier = modifier + modifier = Modifier .fillMaxWidth() .clip(shape) .background(TonTheme.colors.bgPrimary) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt index 24bd4680..dc90d2b1 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt @@ -88,6 +88,7 @@ import io.ton.walletkit.demo.designsystem.icons.TonIconImage import io.ton.walletkit.demo.designsystem.theme.TonTheme import io.ton.walletkit.demo.domain.model.WalletInterfaceType import io.ton.walletkit.demo.presentation.actions.WalletActions +import io.ton.walletkit.demo.presentation.dev.DevPreferences import io.ton.walletkit.demo.presentation.model.ConnectRequestUi import io.ton.walletkit.demo.presentation.model.JettonDetails import io.ton.walletkit.demo.presentation.model.JettonSummary @@ -97,6 +98,7 @@ import io.ton.walletkit.demo.presentation.model.TransactionRequestUi import io.ton.walletkit.demo.presentation.model.WalletSummary import io.ton.walletkit.demo.presentation.state.SheetState import io.ton.walletkit.demo.presentation.state.WalletUiState +import io.ton.walletkit.demo.presentation.ui.components.QuickActionsCard import io.ton.walletkit.demo.presentation.ui.components.wallet.home.WalletHomeAssetIcon import io.ton.walletkit.demo.presentation.ui.components.wallet.home.WalletHomeAssetItem import io.ton.walletkit.demo.presentation.ui.components.wallet.home.WalletHomeContent @@ -119,10 +121,13 @@ import io.ton.walletkit.demo.presentation.ui.sheet.WalletDetailsSheet import io.ton.walletkit.demo.presentation.ui.sheet.WalletsBottomSheet import io.ton.walletkit.demo.presentation.util.JettonFormatters import io.ton.walletkit.demo.presentation.util.QrScanner -import io.ton.walletkit.demo.presentation.util.TestTags import io.ton.walletkit.demo.presentation.viewmodel.NFTsListViewModel import io.ton.walletkit.demo.presentation.viewmodel.SwapViewModel +// URL for the TonConnect E2E test runner dApp +// This is the same dApp used by web demo-wallet E2E tests +private const val DEFAULT_DAPP_URL = "https://allure-test-runner.vercel.app/e2e" + private const val MAX_ASSETS = 3 private const val MAX_NFTS = 5 private const val MAX_FRACTION_DIGITS = 5 @@ -446,22 +451,7 @@ fun WalletScreen( HomeSubScreen.Investigation -> { WalletKitInvestigationScreen( onBack = { subScreen = HomeSubScreen.None }, - onConnect = { url -> - subScreen = HomeSubScreen.None - actions.onHandleUrl(url) - }, - onOpenBrowser = { url, inject -> - subScreen = HomeSubScreen.None - actions.onOpenBrowser(url, inject) - }, - onImportWallet = { name, network, mnemonic, secretKey, version, interfaceType -> - subScreen = HomeSubScreen.None - actions.onImportWallet(name, network, mnemonic, secretKey, version, interfaceType) - }, - onGenerateWallet = { name, network, version, interfaceType -> - subScreen = HomeSubScreen.None - actions.onGenerateWallet(name, network, version, interfaceType) - }, + onConnect = actions::onHandleUrl, walletKit = walletKit, ) } @@ -525,10 +515,7 @@ fun WalletScreen( } }, actions = { - IconButton( - onClick = { subScreen = HomeSubScreen.Investigation }, - modifier = Modifier.testTag(TestTags.INVESTIGATION_BUTTON), - ) { + IconButton(onClick = { subScreen = HomeSubScreen.Investigation }) { TonIconImage( icon = TonIcon.SettingsControl, size = 24.dp, @@ -548,11 +535,7 @@ fun WalletScreen( // Always reserve space for progress indicator to prevent content shift. Box(modifier = Modifier.fillMaxWidth().height(4.dp)) { if (state.isLoadingWallets || state.isLoadingSessions) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - color = TonTheme.colors.bgBrand, - trackColor = TonTheme.colors.bgBrandSubtle, - ) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } } @@ -575,6 +558,14 @@ fun WalletScreen( onNFTTap = { preview -> nftsList.firstOrNull { it.address.value == preview.address }?.let { selectedNFT = it } }, + onBalanceSecretTap = { + val nowLegacy = DevPreferences.toggleLegacyMainScreen(context) + Toast.makeText( + context, + if (nowLegacy) "Legacy main screen ON" else "Legacy main screen OFF", + Toast.LENGTH_SHORT, + ).show() + }, ) } } diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/EventLogSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/EventLogSection.kt new file mode 100644 index 00000000..91625301 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/EventLogSection.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.R + +@Composable +fun EventLogSection(events: List) { + Column(verticalArrangement = Arrangement.spacedBy(EVENT_SECTION_SPACING)) { + Text(stringResource(R.string.events_recent_title), style = MaterialTheme.typography.titleMedium) + events.forEach { event -> + HorizontalDivider() + Text(event, style = MaterialTheme.typography.bodySmall) + } + } +} +private val EVENT_SECTION_SPACING = 8.dp + +@Preview(showBackground = true) +@Composable +private fun EventLogSectionPreview() { + EventLogSection(events = listOf("Handled TON Connect URL", "Approved transaction")) +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/JettonsSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/JettonsSection.kt new file mode 100644 index 00000000..93d50f91 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/JettonsSection.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.presentation.model.JettonSummary +import io.ton.walletkit.demo.presentation.ui.components.JettonListItem + +/** + * Section displaying jettons owned by the wallet. + * + * Mirrors iOS WalletJettonsListView for cross-platform consistency. + */ +@Composable +fun JettonsSection( + jettons: List, + isLoading: Boolean, + error: String?, + canLoadMore: Boolean, + onJettonClick: (JettonSummary) -> Unit, + onLoadMore: () -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + // Auto-load jettons when section is first displayed + LaunchedEffect(Unit) { + onRefresh() + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Header + Text( + text = "Jettons", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + when { + // Loading state + isLoading && jettons.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + // Error state + error != null && jettons.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Failed to load jettons", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Button(onClick = onRefresh) { + Text("Retry") + } + } + } + } + + // Empty state + jettons.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "No Jettons found", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onRefresh) { + Text("Try Again") + } + } + } + } + + // Success state - display jettons + else -> { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Display each jetton + jettons.forEach { jetton -> + JettonListItem( + jetton = jetton, + onClick = onJettonClick, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + // Load More button + if (canLoadMore) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Button( + onClick = onLoadMore, + enabled = !isLoading, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.padding(horizontal = 8.dp), + ) + } else { + Text("Load more") + } + } + } + } + } + } + } + } +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/MasterchainInfoSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/MasterchainInfoSection.kt new file mode 100644 index 00000000..7c7a4de6 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/MasterchainInfoSection.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.ton.walletkit.api.generated.TONMasterchainInfo +import io.ton.walletkit.api.generated.TONNetwork +import io.ton.walletkit.api.isTetra +import io.ton.walletkit.demo.core.TonAPIClient +import io.ton.walletkit.demo.core.ToncenterAPIClient +import kotlinx.coroutines.launch + +/** + * Section demonstrating getMasterchainInfo() API call. + */ +@Composable +fun MasterchainInfoSection( + network: TONNetwork, + modifier: Modifier = Modifier, +) { + var info by remember(network) { mutableStateOf(null) } + var isLoading by remember(network) { mutableStateOf(false) } + var error by remember(network) { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Masterchain Info", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Button( + onClick = { + scope.launch { + isLoading = true + error = null + try { + val client = if (network.isTetra) TonAPIClient.tetra() else ToncenterAPIClient(network) + info = client.getMasterchainInfo() + } catch (e: Exception) { + error = e.message ?: "Unknown error" + } finally { + isLoading = false + } + } + }, + enabled = !isLoading, + ) { + Text("Fetch") + } + if (isLoading) { + Spacer(modifier = Modifier.width(12.dp)) + CircularProgressIndicator( + modifier = Modifier.height(24.dp).width(24.dp), + strokeWidth = 2.dp, + ) + } + } + + error?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Error: $it", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + info?.let { mc -> + Spacer(modifier = Modifier.height(12.dp)) + InfoRow("Seqno", mc.seqno.toString()) + InfoRow("Workchain", mc.workchain.toString()) + InfoRow("Shard", mc.shard) + InfoRow("Root Hash", mc.rootHash.value) + InfoRow("File Hash", mc.fileHash.value) + } + } + } + } +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row(modifier = Modifier.padding(vertical = 2.dp)) { + Text( + text = "$label: ", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/SessionsSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/SessionsSection.kt new file mode 100644 index 00000000..c721373e --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/SessionsSection.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.R +import io.ton.walletkit.demo.presentation.model.SessionSummary +import io.ton.walletkit.demo.presentation.ui.components.EmptyStateCard +import io.ton.walletkit.demo.presentation.ui.components.SessionCard +import io.ton.walletkit.demo.presentation.ui.preview.PreviewData + +@Composable +fun SessionsSection(sessions: List, onDisconnect: (String) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(SESSIONS_SECTION_SPACING)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.sessions_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(SESSIONS_TITLE_SPACING)) + Text( + "${sessions.size}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + if (sessions.isEmpty()) { + EmptyStateCard( + title = stringResource(R.string.sessions_empty_title), + description = stringResource(R.string.sessions_empty_description), + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(SESSIONS_LIST_SPACING)) { + sessions.forEach { session -> + SessionCard( + session = session, + onDisconnect = { onDisconnect(session.sessionId) }, + ) + } + } + } + } +} + +private val SESSIONS_SECTION_SPACING = 12.dp +private val SESSIONS_TITLE_SPACING = 8.dp +private val SESSIONS_LIST_SPACING = 12.dp + +@Preview(showBackground = true) +@Composable +private fun SessionsSectionPreview() { + SessionsSection(sessions = listOf(PreviewData.session), onDisconnect = {}) +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt new file mode 100644 index 00000000..21c3945e --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/sections/WalletsSection.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.demo.presentation.ui.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.ton.walletkit.demo.R +import io.ton.walletkit.demo.presentation.model.WalletSummary +import io.ton.walletkit.demo.presentation.ui.components.EmptyStateCard +import io.ton.walletkit.demo.presentation.ui.components.WalletCard +import io.ton.walletkit.demo.presentation.ui.preview.PreviewData + +@Composable +fun WalletsSection( + activeWallet: WalletSummary?, + totalWallets: Int, + onWalletSelected: (String) -> Unit, + onSendFromWallet: (String) -> Unit = {}, + onStakeFromWallet: (String) -> Unit = {}, + isStreamingConnected: Boolean? = null, + onRefresh: () -> Unit = {}, +) { + Column(verticalArrangement = Arrangement.spacedBy(WALLETS_SECTION_SPACING)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.wallets_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(WALLETS_TITLE_SPACING)) + Text( + pluralStringResource(R.plurals.wallets_count, totalWallets, totalWallets), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + if (activeWallet == null) { + EmptyStateCard( + title = stringResource(R.string.wallets_empty_title), + description = stringResource(R.string.wallets_empty_description), + ) + } else { + WalletCard( + wallet = activeWallet, + onDetails = { onWalletSelected(activeWallet.walletId) }, + onSend = { onSendFromWallet(activeWallet.walletId) }, + onStake = { onStakeFromWallet(activeWallet.walletId) }, + isStreamingConnected = isStreamingConnected, + onRefresh = onRefresh, + ) + if (totalWallets > 1) { + Text( + text = stringResource(R.string.wallets_switcher_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private val WALLETS_SECTION_SPACING = 12.dp +private val WALLETS_TITLE_SPACING = 8.dp + +@Preview(showBackground = true) +@Composable +private fun WalletsSectionPreview() { + WalletsSection( + activeWallet = PreviewData.wallet, + totalWallets = 3, + onWalletSelected = {}, + onSendFromWallet = {}, + onStakeFromWallet = {}, + ) +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/TestTags.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/TestTags.kt index 64242e4b..0a201a54 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/TestTags.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/util/TestTags.kt @@ -60,18 +60,6 @@ object TestTags { const val ADD_WALLET_BUTTON = "add-wallet" const val REFRESH_BUTTON = "refresh" const val BROWSER_NO_INJECT_BUTTON = "browser-no-inject" - const val BROWSER_INJECT_BUTTON = "browser-inject" - const val BROWSER_URL_FIELD = "browser-url" - const val INVESTIGATION_BUTTON = "investigation" - const val INVESTIGATION_TONCONNECT_ROW = "investigation-tonconnect" - const val INVESTIGATION_BROWSER_ROW = "investigation-browser" - const val INVESTIGATION_CONNECT_ROW = "investigation-connect-to-dapp" - const val INVESTIGATION_ADD_WALLET_ROW = "investigation-add-wallet" - const val ADD_WALLET_NAME_FIELD = "add-wallet-name" - const val ADD_WALLET_MNEMONIC_FIELD = "add-wallet-mnemonic" - const val ADD_WALLET_SECRET_KEY_FIELD = "add-wallet-secret-key" - const val ADD_WALLET_IMPORT_BUTTON = "add-wallet-import" - const val ADD_WALLET_GENERATE_BUTTON = "add-wallet-generate" // ConnectRequestSheet const val CONNECT_REQUEST_SHEET = "request" diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt index 64cea8ab..e8e3934a 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/WalletKitViewModel.kt @@ -45,6 +45,7 @@ import io.ton.walletkit.demo.data.storage.DemoAppStorage import io.ton.walletkit.demo.data.storage.WalletRecord import io.ton.walletkit.demo.domain.model.WalletInterfaceType import io.ton.walletkit.demo.domain.model.WalletMetadata +import io.ton.walletkit.demo.presentation.dev.DevPreferences import io.ton.walletkit.demo.presentation.model.ConnectPermissionUi import io.ton.walletkit.demo.presentation.model.ConnectRequestUi import io.ton.walletkit.demo.presentation.model.JettonDetails @@ -1789,6 +1790,14 @@ class WalletKitViewModel @Inject constructor( // Wait until the initial wallet load cycle in bootstrap() has fully completed // before deciding whether to open the "add wallet" sheet. _state.first { it.walletsBootstrapped } + + // Modern flow uses CreateWalletOnboardingScreen, orchestrated by MainActivity + // when there's no wallet — auto-opening AddWalletSheet here would race and + // leave a stale sheet behind for the user to bump into later. Only fire for + // the legacy main screen, which still relies on the bottom-sheet flow. + if (_state.value.wallets.isEmpty() && DevPreferences.useLegacyMainScreen.value) { + uiCoordinator.openAddWalletSheet() + } } catch (e: Exception) { Log.e(LOG_TAG, "Failed to setup password", e) val reason = e.message ?: uiString(R.string.wallet_error_unknown) @@ -1822,6 +1831,11 @@ class WalletKitViewModel @Inject constructor( // Wait until the initial wallet load cycle in bootstrap() has fully completed // before deciding whether to open the "add wallet" sheet. _state.first { it.walletsBootstrapped } + // See [setupPassword] — modern main screen drives onboarding from MainActivity, + // so skip the legacy AddWalletSheet auto-open unless we're explicitly on legacy. + if (_state.value.wallets.isEmpty() && DevPreferences.useLegacyMainScreen.value) { + uiCoordinator.openAddWalletSheet() + } } }