diff --git a/app/src/main/java/one/mixin/android/extension/UrlExtension.kt b/app/src/main/java/one/mixin/android/extension/UrlExtension.kt index d7bc1630ae..a21910eaf8 100644 --- a/app/src/main/java/one/mixin/android/extension/UrlExtension.kt +++ b/app/src/main/java/one/mixin/android/extension/UrlExtension.kt @@ -301,6 +301,8 @@ fun String.isValidStartParam(): Boolean { fun String.isExternalTransferUrl() = externalTransferAssetIdMap.keys.any { startsWith("$it:", ignoreCase = true) } +fun String.isEthereumOrSolURLString() = startsWith("ethereum:", true) || startsWith("solana:", true) + fun String.isLightningUrl() = startsWith("lnbc", true) || startsWith("lnurl", true) || startsWith("lightning:", true) private fun String.isUserScheme() = diff --git a/app/src/main/java/one/mixin/android/repository/Web3Repository.kt b/app/src/main/java/one/mixin/android/repository/Web3Repository.kt index 1d8aefbafa..75e7afcd08 100644 --- a/app/src/main/java/one/mixin/android/repository/Web3Repository.kt +++ b/app/src/main/java/one/mixin/android/repository/Web3Repository.kt @@ -22,6 +22,7 @@ import one.mixin.android.db.web3.Web3WalletDao import one.mixin.android.db.web3.updateWithLocalKeyInfo import one.mixin.android.db.web3.vo.Web3Address import one.mixin.android.db.web3.vo.Web3Chain +import one.mixin.android.db.web3.vo.Web3Token import one.mixin.android.db.web3.vo.Web3TokenItem import one.mixin.android.db.web3.vo.Web3TokensExtra import one.mixin.android.db.web3.vo.Web3TransactionItem @@ -52,8 +53,37 @@ constructor( suspend fun web3TokenItemByAddress(address: String) = web3TokenDao.web3TokenItemByAddress(address) suspend fun web3TokenItemById(walletId: String, assetId: String) = web3TokenDao.web3TokenItemById(walletId, assetId) - - suspend fun findWeb3TokenItemsByIds(walletId: String, assetIds: List) = web3TokenDao.findWeb3TokenItemsByIds(walletId, assetIds) + + suspend fun findAndRefreshWeb3TokenItem(walletId: String, assetId: String): Web3TokenItem? { + val localToken = web3TokenDao.web3TokenItemById(walletId, assetId) + if (localToken != null) { + return localToken + } + + return try { + val token = tokenRepository.findOrSyncAsset(assetId) ?: return null + val w = token.toWeb3TokenItem(walletId) + web3TokenDao.insert( + Web3Token( + walletId = walletId, + assetId = token.assetId, + chainId = token.chainId, + assetKey = token.assetKey ?: "", + name = token.name, + symbol = token.symbol, + iconUrl = token.chainIconUrl ?: "", + priceUsd = token.priceUsd, + precision = token.precision, + balance = "0", + changeUsd = "0" + ) + ) + w + } catch (e: Exception) { + Timber.e(e) + null + } + } fun web3TokensExcludeHidden(walletId: String) = web3TokenDao.web3TokenItemsExcludeHidden(walletId) diff --git a/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt b/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt index abac728651..b55f2ef88f 100644 --- a/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt @@ -34,11 +34,13 @@ import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.databinding.FragmentAddressInputBinding import one.mixin.android.db.web3.vo.Web3TokenItem import one.mixin.android.db.web3.vo.Web3Wallet +import one.mixin.android.db.web3.vo.buildTransaction import one.mixin.android.db.web3.vo.isImported import one.mixin.android.db.web3.vo.isWatch import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.hideKeyboard import one.mixin.android.extension.indeterminateProgressDialog +import one.mixin.android.extension.isEthereumOrSolURLString import one.mixin.android.extension.isExternalTransferUrl import one.mixin.android.extension.isLightningUrl import one.mixin.android.extension.openPermissionSetting @@ -46,6 +48,8 @@ import one.mixin.android.extension.toast import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshAddressJob import one.mixin.android.job.SyncOutputJob +import one.mixin.android.pay.ExternalTransfer +import one.mixin.android.pay.parseExternalTransferUri import one.mixin.android.ui.address.FetchUserAddressFragment.Companion.ARGS_TO_USER import one.mixin.android.ui.address.page.AddressInputPage import one.mixin.android.ui.address.page.LabelInputPage @@ -55,6 +59,7 @@ import one.mixin.android.ui.common.BaseFragment import one.mixin.android.ui.common.biometric.AddressManageBiometricItem import one.mixin.android.ui.conversation.link.LinkBottomSheetDialogFragment import one.mixin.android.ui.home.web3.Web3ViewModel +import one.mixin.android.ui.home.web3.showGasCheckAndBrowserBottomSheetDialogFragment import one.mixin.android.ui.qr.CaptureActivity import one.mixin.android.ui.wallet.InputFragment import one.mixin.android.ui.wallet.TransactionsFragment.Companion.ARGS_ASSET @@ -70,7 +75,10 @@ import one.mixin.android.util.viewBinding import one.mixin.android.vo.Address import one.mixin.android.vo.WithdrawalMemoPossibility import one.mixin.android.vo.safe.TokenItem +import one.mixin.android.web3.Rpc +import one.mixin.android.web3.js.Web3Signer import timber.log.Timber +import java.math.BigDecimal import javax.inject.Inject @AndroidEntryPoint @@ -83,6 +91,105 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres const val ARGS_WALLET = "args_wallet" } + private suspend fun handleWeb3ExternalTransfer(url: String) { + Timber.d("[$TAG] handleWeb3ExternalTransfer url=%s", url) + val (ext, insufficientSymbol) = parseExternalForWeb3(url) + if (insufficientSymbol != null) { + withContext(Dispatchers.Main) { + val message: String = getString(R.string.insufficient_balance_symbol, insufficientSymbol) + toast(message) + } + return + } + if (ext == null) { + Timber.e("[$TAG] handleWeb3ExternalTransfer parseExternalForWeb3 returned null, url=%s", url) + toast(R.string.Data_error) + return + } + Timber.d("[$TAG] handleWeb3ExternalTransfer parsed assetId=%s destination=%s amount=%s", ext.assetId, ext.destination, ext.amount) + val t = web3ViewModel.findAndRefreshWeb3TokenItem(Web3Signer.currentWalletId, ext.assetId) + if (t == null) { + Timber.e("[$TAG] handleWeb3ExternalTransfer web3 token not found for assetId=%s", ext.assetId) + toast(R.string.Data_error) + return + } + val c = web3ViewModel.findAndRefreshWeb3TokenItem(Web3Signer.currentWalletId, t.chainId) + if (c == null) { + Timber.e("[$TAG] handleWeb3ExternalTransfer web3 chain token not found for chainId=%s", t.chainId) + toast(R.string.Data_error) + return + } + val from = web3ViewModel.getAddressesByChainId(walletId = Web3Signer.currentWalletId, c.chainId)?.destination + if (from == null) { + Timber.e("[$TAG] handleWeb3ExternalTransfer from address not found for chainId=%s", c.chainId) + toast(R.string.Data_error) + return + } + val to = ext.destination + val amount = ext.amount + if (amount.isNullOrBlank() || amount == "0") { + navigateToInputFragmentWithBundle( + Bundle().apply { + putString(InputFragment.ARGS_FROM_ADDRESS, from) + putString(InputFragment.ARGS_TO_ADDRESS, to) + putParcelable(InputFragment.ARGS_WEB3_TOKEN, t) + putParcelable(InputFragment.ARGS_WEB3_CHAIN_TOKEN, c) + putParcelable(ARGS_WALLET, wallet) + } + ) + } else { + val transaction = t.buildTransaction(rpc, from, to, amount) + showGasCheckAndBrowserBottomSheetDialogFragment( + requireActivity(), + transaction, + amount = amount, + token = t, + chainToken = c, + toAddress = to, + onTxhash = { _, _ -> }, + onDismiss = { _ -> } + ) + } + } + + private suspend fun parseExternalForWeb3(url: String): Pair { + var insufficientSymbol:String? = null + val result = parseExternalTransferUri( + url, + validateAddress = { assetId, chainId, destination -> + web3ViewModel.validateExternalAddress(assetId, chainId, destination, null).data + }, + getFee = { assetId, destination -> + web3ViewModel.getFees(assetId, destination).data + }, + findAssetIdByAssetKey = { assetKey -> + web3ViewModel.findAssetIdByAssetKey(assetKey) + }, + getAssetPrecisionById = { assetId -> + web3ViewModel.getAssetPrecisionById(assetId).data + }, + balanceCheck = { assetId, amount, feeAssetId, feeAmount -> + if (feeAssetId != null && feeAmount != null) { + val feeExtra = web3ViewModel.findAndRefreshWeb3TokenItem(Web3Signer.currentWalletId, feeAssetId) + val feeBalance = feeExtra?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO + if (feeBalance < feeAmount) { + insufficientSymbol = feeExtra?.symbol + } + } + val extra = web3ViewModel.findAndRefreshWeb3TokenItem(Web3Signer.currentWalletId, assetId) + val balance = extra?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO + if (balance < amount) { + insufficientSymbol = extra?.symbol + } + }, + parseLighting = { ln -> + val r = web3ViewModel.paySuspend(one.mixin.android.api.request.TransferRequest(assetId = one.mixin.android.Constants.ChainId.LIGHTNING_NETWORK_CHAIN_ID, rawPaymentUrl = ln)) + r.data + } + ) + return Pair(result, insufficientSymbol) + } + private val token: TokenItem? by lazy { requireArguments().getParcelableCompat( ARGS_ASSET, @@ -151,6 +258,9 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres @Inject lateinit var jobManager: MixinJobManager + @Inject + lateinit var rpc: Rpc + enum class TransferDestination { Initial, Address, @@ -332,12 +442,19 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres navController.navigate(TransferDestination.Address.name) }, onSend = { address -> + Timber.d("[$TAG] onSend address=%s token=%s web3Token=%s", address, token, web3Token) errorInfo = null if (token != null && (address.isExternalTransferUrl() || address.isLightningUrl())) { LinkBottomSheetDialogFragment.newInstance(address).show( parentFragmentManager, LinkBottomSheetDialogFragment.TAG ) + } else if (web3Token != null && address.isEthereumOrSolURLString()) { + lifecycleScope.launch { + isLoading = true + handleWeb3ExternalTransfer(address) + isLoading = false + } } else { val memoEnabled = token?.withdrawalMemoPossibility == WithdrawalMemoPossibility.POSITIVE || token?.withdrawalMemoPossibility == WithdrawalMemoPossibility.POSSIBLE @@ -355,6 +472,7 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres } } else if (web3Token != null) { lifecycleScope.launch { + Timber.d("[$TAG] onSend web3 normal address=%s", address) web3Token?.let { token -> val fromAddress = web3ViewModel.getAddressesByChainId(token.walletId, token.chainId)?.destination if (fromAddress.isNullOrBlank()) { @@ -374,6 +492,7 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres } } else { token?.let { t -> + Timber.d("[$TAG] onSend normal token address=%s", address) validateAndNavigateToInput( assetId = t.assetId, chainId = t.chainId, @@ -590,8 +709,8 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres private fun handleScanResult(data: Intent?, isAddr: Boolean = true) { if (data == null) return - - data.getStringExtra(CaptureActivity.Companion.ARGS_FOR_SCAN_RESULT)?.let { result -> + data.getStringExtra(CaptureActivity.ARGS_FOR_SCAN_RESULT)?.let { result -> + Timber.d("[$TAG] handleScanResult result=%s currentScanType=%s", result, currentScanType) if (token != null && (result.isLightningUrl() || result.isExternalTransferUrl())) { LinkBottomSheetDialogFragment.newInstance(result).show( parentFragmentManager, @@ -599,6 +718,14 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres ) return@let } + if (web3Token != null && result.isEthereumOrSolURLString()) { + lifecycleScope.launch { + isLoading = true + handleWeb3ExternalTransfer(result) + isLoading = false + } + return@let + } when (currentScanType) { ScanType.ADDRESS -> { scannedAddress = if (isIcapAddress(result)) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt index 64ac28863c..37994d9b91 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/Web3ViewModel.kt @@ -19,6 +19,7 @@ import one.mixin.android.R import one.mixin.android.api.MixinResponse import one.mixin.android.api.handleMixinResponse import one.mixin.android.api.request.AccountUpdateRequest +import one.mixin.android.api.request.TransferRequest import one.mixin.android.api.request.web3.EstimateFeeRequest import one.mixin.android.api.response.PaymentStatus import one.mixin.android.api.response.web3.StakeAccount @@ -44,6 +45,7 @@ import one.mixin.android.ui.common.biometric.NftBiometricItem import one.mixin.android.ui.common.biometric.maxUtxoCount import one.mixin.android.ui.home.inscription.component.OwnerState import one.mixin.android.ui.oldwallet.AssetRepository +import one.mixin.android.vo.AssetPrecision import one.mixin.android.util.GsonHelper import one.mixin.android.util.mlkit.firstUrl import one.mixin.android.vo.Account @@ -99,6 +101,11 @@ internal constructor( web3Repository.web3TokenItemById(walletId, assetId) } + + suspend fun findAndRefreshWeb3TokenItem(walletId: String, assetId: String) = withContext(Dispatchers.IO) { + web3Repository.findAndRefreshWeb3TokenItem(walletId, assetId) + } + fun getTokenPriceUsdFlow(assetId: String): Flow = flow { val item = tokenRepository.findAssetItemById(assetId)?.priceUsd emit(item) @@ -132,20 +139,6 @@ internal constructor( } } - fun getLatestActiveSignSessions(): List { - val v2List = - WalletConnectV2.getListOfActiveSessions().mapIndexed { index, wcSession -> - ConnectionUI( - index = index, - icon = wcSession.metaData?.icons?.firstOrNull(), - name = wcSession.metaData!!.name.takeIf { it.isNotBlank() } ?: "Dapp", - uri = wcSession.metaData!!.url.takeIf { it.isNotBlank() } ?: "Not provided", - data = wcSession.topic, - ) - } - return v2List - } - fun dapps(chainId: String): List { val gson = GsonHelper.customGson val dapps = MixinApplication.get().defaultSharedPreferences.getString("dapp_$chainId", null) @@ -164,24 +157,6 @@ internal constructor( } } - private fun updateTokens(chain: String, tokens: List) { - val tokenMap = if (chain == ChainType.ethereum.name) evmTokenMap else solanaTokenMap - val newTokenIds = tokens.map { "${it.chainId}${it.assetKey}" }.toSet() - - val missingTokenIds = tokenMap.keys - newTokenIds - missingTokenIds.forEach { tokenId -> - val token = tokenMap[tokenId] - if (token != null) { - tokenMap[tokenId] = token.copy(balance = "0") - } - } - - tokens.forEach { token -> - val tokenId = "${token.chainId}${token.assetKey}" - tokenMap[tokenId] = token - } - } - suspend fun fetchSessionsSuspend(ids: List) = userRepository.fetchSessionsSuspend(ids) suspend fun findBotPublicKey( @@ -198,19 +173,32 @@ internal constructor( tokenRepository.findAndCheckDepositEntry(token.chainId, token.assetId).first } - suspend fun web3TokenItems(chainIds: List) = tokenRepository.web3TokenItems(chainIds) - suspend fun getFees( id: String, destination: String, ) = tokenRepository.getFees(id, destination) + suspend fun validateExternalAddress( + assetId: String, + chain: String, + destination: String, + tag: String?, + ) = accountRepository.validateExternalAddress(assetId, chain, destination, tag) + + suspend fun findAssetIdByAssetKey(assetKey: String): String? = + tokenRepository.findAssetIdByAssetKey(assetKey) + + suspend fun getAssetPrecisionById(assetId: String): MixinResponse = + tokenRepository.getAssetPrecisionById(assetId) + + suspend fun paySuspend(request: TransferRequest) = + withContext(Dispatchers.IO) { + tokenRepository.paySuspend(request) + } + suspend fun findTokenItems(ids: List): List = tokenRepository.findTokenItems(ids) - suspend fun findWeb3TokenItems(walletId: String): List = - tokenRepository.findWeb3TokenItems(walletId) - suspend fun findTokensExtra(assetId: String) = withContext(Dispatchers.IO) { tokenRepository.findTokensExtra(assetId) @@ -320,7 +308,6 @@ internal constructor( } } - suspend fun getWeb3Tx(txhash: String) = assetRepository.getWeb3Tx(txhash) suspend fun isBlockhashValid(blockhash: String): Boolean = withContext(Dispatchers.IO) { @@ -426,18 +413,6 @@ internal constructor( return web3Repository.getAddressesByChainId(walletId, chainId) } - suspend fun getClassicWalletId(): String? = web3Repository.getClassicWalletId() - - suspend fun getTransactionsById(traceId: String) = tokenRepository.getTransactionsById(traceId) - - suspend fun findTokensByIds(walletId: String, assetIds: List): List = withContext(Dispatchers.IO) { - return@withContext web3Repository.findWeb3TokenItemsByIds(walletId, assetIds) - } - - suspend fun getRawTransactionByHashAndChain(hash: String, chainId: String) = tokenRepository.getRawTransactionByHashAndChain(hash, chainId) - - suspend fun getWalletName(walletId: String): String? = web3Repository.findWalletById(walletId)?.name - suspend fun findWalletById(walletId: String) = web3Repository.findWalletById(walletId) suspend fun getAddresses(walletId: String) = web3Repository.getAddresses(walletId) @@ -455,8 +430,6 @@ internal constructor( suspend fun getPendingTransactions(walletId: String) = tokenRepository.getPendingTransactions(walletId) - suspend fun getPendingRawTransactions(walletId: String, chainId: String) = tokenRepository.getPendingRawTransactions(walletId, chainId) - fun getPendingTransactionCount(walletId: String): LiveData = tokenRepository.getPendingTransactionCount(walletId) suspend fun transaction(hash: String, chainId: String) = tokenRepository.transaction(hash, chainId)