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")