diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a95bd4bba..be95b2723d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,10 +12,6 @@ jobs: runs-on: ubuntu-latest steps: - - uses: maxim-lobanov/setup-android-tools@v1 - with: - packages: cmdline-tools;latest - - name: Install deps run: sudo apt-get install -y unzip diffutils @@ -29,6 +25,12 @@ jobs: with: submodules: recursive + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + #need for java 11, latest version work only with jav 17 + cmdline-tools-version: 8512546 + - run: ./gradlew clean test mbw::assembleProdnetDebug mbw::assembleBtctestnetDebug mbw::assembleBtctestnetRelease - uses: actions/upload-artifact@v3 diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index afbd60950a..ca43e248b4 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -145,6 +145,11 @@ + + + + + @@ -177,6 +182,11 @@ + + + + + @@ -1028,6 +1038,11 @@ + + + + + @@ -2357,6 +2372,11 @@ + + + + + diff --git a/mbw/build.gradle b/mbw/build.gradle index 39ae146f87..74d25e8d85 100644 --- a/mbw/build.gradle +++ b/mbw/build.gradle @@ -88,6 +88,7 @@ dependencies { implementation "com.google.code.findbugs:annotations:$findBugsVersion" implementation "com.squareup:otto:$ottoVersion" + implementation 'androidx.browser:browser:1.5.0' implementation "androidx.biometric:biometric:1.1.0" implementation "androidx.core:core-ktx:1.6.0" implementation "androidx.fragment:fragment-ktx:1.3.6" @@ -148,7 +149,7 @@ dependencies { } kapt "com.github.bumptech.glide:compiler:4.7.1" implementation "info.guardianproject.netcipher:netcipher:2.1.0" - + implementation "com.airbnb.android:lottie:3.4.0" androidTestImplementation 'androidx.test:core:1.2.0' androidTestImplementation 'androidx.test.ext:junit:1.1.1' @@ -220,8 +221,8 @@ android { buildToolsVersion androidSdkBuildVersion defaultConfig { - versionCode 3160200 - versionName '3.16.2.0' + versionCode 3170000 + versionName '3.17.0' multiDexEnabled true diff --git a/mbw/src/androidTest/java/com/mycelium/wallet/TokensTest.kt b/mbw/src/androidTest/java/com/mycelium/wallet/TokensTest.kt new file mode 100644 index 0000000000..1962b5333b --- /dev/null +++ b/mbw/src/androidTest/java/com/mycelium/wallet/TokensTest.kt @@ -0,0 +1,22 @@ +package com.mycelium.wallet + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mycelium.wallet.external.changelly.ChangellyAPIService.Companion.retrofit +import kotlinx.coroutines.runBlocking +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import java.math.BigDecimal + +@RunWith(AndroidJUnit4::class) +class TokensTest { + + @Test + fun testTokenList() { +// Log.e("!!!", WalletConfiguration.TOKENS.joinToString { it.symbol + " " + it.name }) + + Log.e("!!!", "size = " + WalletConfiguration.TOKENS.size) + Log.e("!!!", WalletConfiguration.TOKENS.joinToString { it.symbol + " = " + it.prodAddress }) + } +} \ No newline at end of file diff --git a/mbw/src/androidTest/java/com/mycelium/wallet/activity/SendMainActivityTest.kt b/mbw/src/androidTest/java/com/mycelium/wallet/activity/SendMainActivityTest.kt index f08e89d941..ef5e052fd3 100644 --- a/mbw/src/androidTest/java/com/mycelium/wallet/activity/SendMainActivityTest.kt +++ b/mbw/src/androidTest/java/com/mycelium/wallet/activity/SendMainActivityTest.kt @@ -15,7 +15,6 @@ import com.mrd.bitlib.crypto.InMemoryPrivateKey import com.mrd.bitlib.model.NetworkParameters import com.mycelium.wallet.MbwManager import com.mycelium.wallet.R -import com.mycelium.wallet.activity.GetAmountActivity.ACCOUNT import com.mycelium.wallet.activity.send.SendCoinsActivity import com.mycelium.wallet.activity.send.view.SelectableRecyclerView import org.junit.After @@ -39,7 +38,7 @@ class SendMainActivityTest { @Before fun setUp() { // manually launch SendMainActivity to pass Intent with accountId - sut = sendMainActivityRule.launchActivity(Intent().putExtra(ACCOUNT, accountId)) + sut = sendMainActivityRule.launchActivity(Intent().putExtra("account", accountId)) receiversAddressesList = sut!!.findViewById(R.id.receiversAddressList) } diff --git a/mbw/src/androidTest/java/com/mycelium/wallet/external/changelly/ChangellyHeaderInterceptorTest.kt b/mbw/src/androidTest/java/com/mycelium/wallet/external/changelly/ChangellyHeaderInterceptorTest.kt new file mode 100644 index 0000000000..ebd8e33be3 --- /dev/null +++ b/mbw/src/androidTest/java/com/mycelium/wallet/external/changelly/ChangellyHeaderInterceptorTest.kt @@ -0,0 +1,27 @@ +package com.mycelium.wallet.external.changelly + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mycelium.wallet.external.changelly.ChangellyAPIService.Companion.retrofit +import kotlinx.coroutines.runBlocking +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import java.math.BigDecimal + +@RunWith(AndroidJUnit4::class) +class ChangellyHeaderInterceptorTest { + + @Test + fun testChandllyDefaultMethod() = runBlocking { + val api = retrofit.create(ChangellyAPIService::class.java) + val response = api.getFixRate("eth", "btc") + Assert.assertEquals(response.code(), 200) + } + + @Test + fun testChandllyExchangeAmountFix() = runBlocking { + val api = retrofit.create(ChangellyAPIService::class.java) + val response = api.exchangeAmountFix("eth", "btc", BigDecimal.ONE) + Assert.assertEquals(response.code(), 200) + } +} \ No newline at end of file diff --git a/mbw/src/main/AndroidManifest.xml b/mbw/src/main/AndroidManifest.xml index 24da16c434..b23919f9f3 100644 --- a/mbw/src/main/AndroidManifest.xml +++ b/mbw/src/main/AndroidManifest.xml @@ -150,6 +150,7 @@ @@ -212,9 +213,6 @@ android:name=".activity.receive.ReceiveCoinsActivity" android:theme="@style/MyceliumFIOMapping" /> - - diff --git a/mbw/src/main/assets/token-logos/cake_logo.png b/mbw/src/main/assets/token-logos/cake_logo.png new file mode 100644 index 0000000000..15679d22a9 Binary files /dev/null and b/mbw/src/main/assets/token-logos/cake_logo.png differ diff --git a/mbw/src/main/java/com/mycelium/bequant/InvestmentAccount.kt b/mbw/src/main/java/com/mycelium/bequant/InvestmentAccount.kt index 0ede694710..9f9ef158f4 100644 --- a/mbw/src/main/java/com/mycelium/bequant/InvestmentAccount.kt +++ b/mbw/src/main/java/com/mycelium/bequant/InvestmentAccount.kt @@ -44,6 +44,14 @@ class InvestmentAccount : WalletAccount { TODO("Not yet implemented") } + override fun createTx( + outputs: List>, + fee: Fee, + data: TransactionData? + ): Transaction { + TODO("Not yet implemented") + } + override fun canSign(): Boolean { TODO("Not yet implemented") } @@ -203,4 +211,4 @@ class InvestmentAccount : WalletAccount { } override fun interruptSync() {} -} \ No newline at end of file +} diff --git a/mbw/src/main/java/com/mycelium/bequant/remote/model/UserStatus.kt b/mbw/src/main/java/com/mycelium/bequant/remote/model/UserStatus.kt new file mode 100644 index 0000000000..40efb7968a --- /dev/null +++ b/mbw/src/main/java/com/mycelium/bequant/remote/model/UserStatus.kt @@ -0,0 +1,16 @@ +package com.mycelium.bequant.remote.model + +enum class UserStatus { + VIP, + REGULAR; + + fun isVIP() = this == VIP + + companion object { + fun fromName(name: String?) = when (name) { + VIP.name -> VIP + REGULAR.name -> REGULAR + else -> null + } + } +} diff --git a/mbw/src/main/java/com/mycelium/bequant/remote/repositories/Api.kt b/mbw/src/main/java/com/mycelium/bequant/remote/repositories/Api.kt index 6488f99b1e..4669cfb928 100644 --- a/mbw/src/main/java/com/mycelium/bequant/remote/repositories/Api.kt +++ b/mbw/src/main/java/com/mycelium/bequant/remote/repositories/Api.kt @@ -1,5 +1,6 @@ package com.mycelium.bequant.remote.repositories + object Api { val accountRepository by lazy { AccountApiRepository() } val publicRepository by lazy { PublicApiRepository() } diff --git a/mbw/src/main/java/com/mycelium/wallet/UserKeysManager.kt b/mbw/src/main/java/com/mycelium/wallet/UserKeysManager.kt new file mode 100644 index 0000000000..9bc82aeed8 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/UserKeysManager.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2013, 2014 Megion Research and Development GmbH + * + * Licensed under the Microsoft Reference Source License (MS-RSL) + * + * This license governs use of the accompanying software. If you use the software, you accept this license. + * If you do not accept the license, do not use the software. + * + * 1. Definitions + * The terms "reproduce," "reproduction," and "distribution" have the same meaning here as under U.S. copyright law. + * "You" means the licensee of the software. + * "Your company" means the company you worked for when you downloaded the software. + * "Reference use" means use of the software within your company as a reference, in read only form, for the sole purposes + * of debugging your products, maintaining your products, or enhancing the interoperability of your products with the + * software, and specifically excludes the right to distribute the software outside of your company. + * "Licensed patents" means any Licensor patent claims which read directly on the software as distributed by the Licensor + * under this license. + * + * 2. Grant of Rights + * (A) Copyright Grant- Subject to the terms of this license, the Licensor grants you a non-transferable, non-exclusive, + * worldwide, royalty-free copyright license to reproduce the software for reference use. + * (B) Patent Grant- Subject to the terms of this license, the Licensor grants you a non-transferable, non-exclusive, + * worldwide, royalty-free patent license under licensed patents for reference use. + * + * 3. Limitations + * (A) No Trademark License- This license does not grant you any rights to use the Licensor’s name, logo, or trademarks. + * (B) If you begin patent litigation against the Licensor over patents that you think may apply to the software + * (including a cross-claim or counterclaim in a lawsuit), your license to the software ends automatically. + * (C) The software is licensed "as-is." You bear the risk of using it. The Licensor gives no express warranties, + * guarantees or conditions. You may have additional consumer rights under your local laws which this license cannot + * change. To the extent permitted under your local laws, the Licensor excludes the implied warranties of merchantability, + * fitness for a particular purpose and non-infringement. + */ +package com.mycelium.wallet + +import com.mycelium.wapi.wallet.AesKeyCipher +import org.bouncycastle.crypto.AsymmetricCipherKeyPair +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.generators.ECKeyPairGenerator +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.params.ECDomainParameters +import org.bouncycastle.crypto.params.ECKeyGenerationParameters +import org.bouncycastle.crypto.params.HKDFParameters +import org.bouncycastle.crypto.prng.FixedSecureRandom +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.Security + + +object UserKeysManager { + private val mbwManger = MbwManager.getInstance(WalletApplication.getInstance()) + val userSignKeys = getDeterministicECKeyPair() + private fun getDeterministicECKeyPair(): AsymmetricCipherKeyPair { + val keyCipher = AesKeyCipher.defaultKeyCipher() + val accountManager = mbwManger.masterSeedManager.getIdentityAccountKeyManager(keyCipher) + val accountPublicKey = accountManager.getPrivateKeyForWebsite(WEBSITE, keyCipher).publicKey + val seed = accountPublicKey.publicKeyBytes + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(BouncyCastleProvider()) + } + // generate determine asymmetric keys using account seed as random seed + val hkdfGenerator = HKDFBytesGenerator(SHA256Digest()) + hkdfGenerator.init(HKDFParameters(seed, null, ByteArray(0))) + val privateKeyBytes = ByteArray(32) + hkdfGenerator.generateBytes(privateKeyBytes, 0, privateKeyBytes.size) + + val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1") + val domainParameters = ECDomainParameters(ecSpec.curve, ecSpec.g, ecSpec.n, ecSpec.h) + val random = FixedSecureRandom(privateKeyBytes) + + val keyGenParams = ECKeyGenerationParameters(domainParameters, random) + val keyPairGenerator = ECKeyPairGenerator() + keyPairGenerator.init(keyGenParams) + return keyPairGenerator.generateKeyPair() + } + + private const val WEBSITE = "changelly-viper.mycelium.com" +} \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/WalletConfiguration.kt b/mbw/src/main/java/com/mycelium/wallet/WalletConfiguration.kt index 1b5b946c1c..d931a03c96 100644 --- a/mbw/src/main/java/com/mycelium/wallet/WalletConfiguration.kt +++ b/mbw/src/main/java/com/mycelium/wallet/WalletConfiguration.kt @@ -597,7 +597,7 @@ class WalletConfiguration(private val prefs: SharedPreferences, TokenData("yearn.finance", "YFI", 18, "0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e"), TokenData("The Graph", "GRT", 18, "0xc944E90C64B2c07662A292be6244BDf05Cda44a7"), TokenData("Civic", "CVC", 8, "0x41e5560054824eA6B0732E656E3Ad64E20e94E45"), - TokenData("Gala", "GALA", 8, "0x15D4c048F83bd7e37d49eA4C83a07267Ec4203dA"), + TokenData("Gala", "GALA", 8, "0xd1d2eb1b1e90b638588728b4130137d262c87cae"), TokenData("Illuvium", "ILV", 18, "0x767FE9EDC9E0dF98E07454847909b5E959D7ca0E"), TokenData("SushiToken", "SUSHI", 18, "0x6B3595068778DD592e39A122f4f5a5cF09C90fE2"), TokenData("1INCH Token", "1INCH", 18, "0x111111111117dC0aa78b770fA6A738034120C302"), @@ -611,7 +611,9 @@ class WalletConfiguration(private val prefs: SharedPreferences, TokenData("Request", "REQ", 18, "0x8f8221afbb33998d8584a2b05749ba73c37a938a"), TokenData("UMA", "UMA", 18, "0x04Fa0d235C4abf4BcF4787aF4CF447DE572eF828"), TokenData("Viberate", "vib", 18, "0x2C974B2d0BA1716E644c1FC59982a89DDD2fF724"), + TokenData("PancakeSwap Token", "CAKE", 18, "0x152649eA73beAb28c5b49B26eb48f7EAD6d4c898"), TokenData("district0x", "dnt", 18, "0x0abdace70d3790235af448c88547603b945604ea") + ) } } diff --git a/mbw/src/main/java/com/mycelium/wallet/activity/main/BuySellFragment.kt b/mbw/src/main/java/com/mycelium/wallet/activity/main/BuySellFragment.kt index a0ef7f06d1..a7ca017e8b 100644 --- a/mbw/src/main/java/com/mycelium/wallet/activity/main/BuySellFragment.kt +++ b/mbw/src/main/java/com/mycelium/wallet/activity/main/BuySellFragment.kt @@ -98,7 +98,12 @@ class BuySellFragment : Fragment(), ButtonClickListener { binding?.quadList?.adapter = quadAdapter binding?.quadList?.addOnScrollListener(ItemCentralizer()) recreateActions() - quadAdapter.submitList(getBalanceContent()?.quads?.sortedBy { it.index }) + quadAdapter.submitList(getBalanceContent() + ?.quads + ?.filter { + it.isActive() && isContentEnabled(it.parentId ?: "") + && it.filter.check(mbwManager.selectedAccount) + }?.sortedBy { it.index }) quadAdapter.clickListener = { startContentLink(it.link) } } diff --git a/mbw/src/main/java/com/mycelium/wallet/activity/main/adapter/TransactionArrayAdapter.java b/mbw/src/main/java/com/mycelium/wallet/activity/main/adapter/TransactionArrayAdapter.java index 9c3823fbfa..bcdad28412 100644 --- a/mbw/src/main/java/com/mycelium/wallet/activity/main/adapter/TransactionArrayAdapter.java +++ b/mbw/src/main/java/com/mycelium/wallet/activity/main/adapter/TransactionArrayAdapter.java @@ -36,10 +36,11 @@ import java.util.Set; import static android.content.Context.MODE_PRIVATE; -import static com.mycelium.wallet.external.changelly.bch.ExchangeFragment.BCH_EXCHANGE; -import static com.mycelium.wallet.external.changelly.bch.ExchangeFragment.BCH_EXCHANGE_TRANSACTIONS; +import static com.mycelium.wallet.activity.send.SendCoinsActivity.BATCH_HASH_PREFIX; public class TransactionArrayAdapter extends ArrayAdapter { + public static final String BCH_EXCHANGE = "bch_exchange"; + public static final String BCH_EXCHANGE_TRANSACTIONS = "bch_exchange_transactions"; private final MetadataStorage _storage; protected Context _context; private DateFormat _dateFormat; @@ -134,6 +135,9 @@ public View getView(final int position, View convertView, ViewGroup parent) { TextView tvFiatTimed = rowView.findViewById(R.id.tvFiatAmountTimed); String value = transactionFiatValuePref.getString(record.getIdHex(), null); + if (value == null && !record.isIncoming()) { + value = transactionFiatValuePref.getString(BATCH_HASH_PREFIX + record.getIdHex(), null); + } boolean showFiatTimed = value != null && !TransactionSummaryKt.isMinerFeeTx(record, _mbwManager.getSelectedAccount()); tvFiatTimed.setVisibility(showFiatTimed ? View.VISIBLE : View.GONE); if (value != null) { diff --git a/mbw/src/main/java/com/mycelium/wallet/activity/modern/ModernMain.kt b/mbw/src/main/java/com/mycelium/wallet/activity/modern/ModernMain.kt index 532940e176..ae33d1a3fa 100644 --- a/mbw/src/main/java/com/mycelium/wallet/activity/modern/ModernMain.kt +++ b/mbw/src/main/java/com/mycelium/wallet/activity/modern/ModernMain.kt @@ -36,11 +36,11 @@ import com.mycelium.wallet.activity.modern.event.BackListener import com.mycelium.wallet.activity.modern.event.RemoveTab import com.mycelium.wallet.activity.modern.event.SelectTab import com.mycelium.wallet.activity.modern.helper.MainActions +import com.mycelium.wallet.activity.modern.vip.VipFragment import com.mycelium.wallet.activity.news.NewsActivity import com.mycelium.wallet.activity.news.NewsUtils import com.mycelium.wallet.activity.send.InstantWalletActivity import com.mycelium.wallet.activity.settings.SettingsActivity -import com.mycelium.wallet.activity.settings.SettingsPreference import com.mycelium.wallet.activity.settings.SettingsPreference.getMainMenuContent import com.mycelium.wallet.activity.settings.SettingsPreference.isContentEnabled import com.mycelium.wallet.activity.settings.SettingsPreference.mediaFlowEnabled @@ -51,6 +51,7 @@ import com.mycelium.wallet.event.* import com.mycelium.wallet.external.changelly.ChangellyConstants import com.mycelium.wallet.external.changelly2.ExchangeFragment import com.mycelium.wallet.external.changelly2.HistoryFragment +import com.mycelium.wallet.external.changelly2.remote.Api import com.mycelium.wallet.external.mediaflow.NewsConstants import com.mycelium.wallet.fio.FioRequestNotificator import com.mycelium.wallet.modularisation.ModularisationVersionHelper @@ -62,6 +63,8 @@ import com.mycelium.wapi.wallet.fio.FioModule import com.mycelium.wapi.wallet.manager.State import com.squareup.otto.Subscribe import info.guardianproject.netcipher.proxy.OrbotHelper +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import java.util.* import java.util.concurrent.TimeUnit @@ -73,6 +76,7 @@ class ModernMain : AppCompatActivity(), BackHandler { private var mNewsTab: TabLayout.Tab? = null private var mAccountsTab: TabLayout.Tab? = null private var mTransactionsTab: TabLayout.Tab? = null + private var mVipTab: TabLayout.Tab? = null private var mRecommendationsTab: TabLayout.Tab? = null private var mFioRequestsTab: TabLayout.Tab? = null private var refreshItem: MenuItem? = null @@ -88,6 +92,8 @@ class ModernMain : AppCompatActivity(), BackHandler { lateinit var binding: ModernMainBinding + private val userRepository = Api.statusRepository + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mbwManager = MbwManager.getInstance(this) @@ -118,6 +124,8 @@ class ModernMain : AppCompatActivity(), BackHandler { mTabsAdapter!!.addTab(mBalanceTab!!, BalanceMasterFragment::class.java, null, TAB_BALANCE) mTransactionsTab = binding.pagerTabs.newTab().setText(getString(R.string.tab_transactions)) mTabsAdapter!!.addTab(mTransactionsTab!!, TransactionHistoryFragment::class.java, null, TAB_HISTORY) + mVipTab = binding.pagerTabs.newTab().setText(getString(R.string.tab_vip)) + mTabsAdapter!!.addTab(mVipTab!!, VipFragment::class.java, null, TAB_VIP) mRecommendationsTab = binding.pagerTabs.newTab().setText(getString(R.string.tab_partners)) mTabsAdapter!!.addTab(mRecommendationsTab!!, RecommendationsFragment::class.java, null, TAB_RECOMMENDATIONS) @@ -153,6 +161,14 @@ class ModernMain : AppCompatActivity(), BackHandler { lifecycleScope.launchWhenResumed { ChangeLog.showIfNewVersion(this@ModernMain, supportFragmentManager) } + lifecycleScope.launch { + userRepository.statusFlow.collect { status -> + val icon = + if (status.isVIP()) R.drawable.action_bar_logo_vip + else R.drawable.action_bar_logo + supportActionBar?.setIcon(icon) + } + } } fun selectTab(tabTag: String) { @@ -598,6 +614,7 @@ class ModernMain : AppCompatActivity(), BackHandler { const val TAB_BALANCE = "tab_balance" const val TAB_EXCHANGE = "tab_exchange" private const val TAB_HISTORY = "tab_history" + private const val TAB_VIP = "tab_vip" const val TAB_FIO_REQUESTS = "tab_fio_requests" private const val TAB_ADS = "tab_ads" private const val TAB_RECOMMENDATIONS = "tab_recommendations" diff --git a/mbw/src/main/java/com/mycelium/wallet/activity/modern/vip/VipFragment.kt b/mbw/src/main/java/com/mycelium/wallet/activity/modern/vip/VipFragment.kt new file mode 100644 index 0000000000..6d31991f93 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/activity/modern/vip/VipFragment.kt @@ -0,0 +1,135 @@ +package com.mycelium.wallet.activity.modern.vip + +import android.app.Activity +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.viewpager.widget.ViewPager +import com.mycelium.wallet.R +import com.mycelium.wallet.databinding.FragmentVipBinding +import kotlinx.coroutines.flow.collect + +class VipFragment : Fragment() { + + private lateinit var binding: FragmentVipBinding + private val viewModel by viewModels() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + binding = FragmentVipBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupInputs() + setupObservers() + setupPageObserver() + } + + private fun setupInputs() { + binding.vipCodeInput.doOnTextChanged { text, _, _, _ -> + text?.let { + viewModel.updateVipText(it.toString()) + } + } + } + + private fun setupObservers() = lifecycleScope.launchWhenStarted { + viewModel.stateFlow.collect { state -> + binding.vipProgress.isVisible = state.progress + updateButtons(state) + handleError(state.error) + handleSuccess(state.isVip) + } + } + + private fun setupPageObserver() { + val pager = requireActivity().findViewById(R.id.pager) + pager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener { + override fun onPageSelected(position: Int) { + if (position == VIP_FRAGMENT_POSITION) { + binding.icon.playAnimation() + } + } + + override fun onPageScrolled(p: Int, po: Float, pop: Int) {} + + override fun onPageScrollStateChanged(state: Int) {} + }) + } + + private fun updateButtons(state: VipViewModel.State) { + binding.vipApplyButton.apply { + isEnabled = state.text.isNotEmpty() && !state.progress && state.error == null + text = if (state.progress) "" else getString(R.string.apply_vip_code) + setOnClickListener { viewModel.applyCode() } + } + } + + private fun handleError(error: VipViewModel.ErrorType?) = binding.apply { + when (error) { + null -> { + errorText.isVisible = false + vipCodeInput.setBackgroundResource(R.drawable.bg_input_text_filled) + } + + VipViewModel.ErrorType.BAD_REQUEST -> { + errorText.isVisible = true + vipCodeInput.setBackgroundResource(R.drawable.bg_input_text_filled_error) + } + + else -> { + hideKeyBoard() + showViperUnexpectedErrorDialog() + } + } + } + + private fun handleSuccess(success: Boolean) { + binding.apply { + vipApplyButton.isVisible = !success + vipInputGroup.isVisible = !success + vipSuccessGroup.isVisible = success + vipTitle.setText(if (success) R.string.vip_title_success else R.string.vip_title) + if (success) { + vipCodeInput.apply { + hint = null + text = null + clearFocus() + } + hideKeyBoard() + } else { + vipCodeInput.hint = getString(R.string.vip_code_hint) + } + } + } + + private fun showViperUnexpectedErrorDialog() { + AlertDialog.Builder(requireContext()) + .setTitle(getString(R.string.vip_unexpected_alert_title)) + .setMessage(getString(R.string.vip_unexpected_alert_message)) + .setPositiveButton(R.string.button_ok, null) + .setOnDismissListener { viewModel.resetState() } + .show() + } + + private fun hideKeyBoard() { + val imm = context?.getSystemService(Activity.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(requireView().windowToken, 0) + } + + private companion object { + const val VIP_FRAGMENT_POSITION = 6 + } +} diff --git a/mbw/src/main/java/com/mycelium/wallet/activity/modern/vip/VipViewModel.kt b/mbw/src/main/java/com/mycelium/wallet/activity/modern/vip/VipViewModel.kt new file mode 100644 index 0000000000..3fe28483b8 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/activity/modern/vip/VipViewModel.kt @@ -0,0 +1,68 @@ +package com.mycelium.wallet.activity.modern.vip + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mycelium.wallet.external.changelly2.remote.Api +import com.mycelium.wallet.update +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import retrofit2.HttpException + +class VipViewModel : ViewModel() { + + private val userRepository = Api.statusRepository + + data class State( + val isVip: Boolean = false, + val error: ErrorType? = null, + val progress: Boolean = true, + val text: String = "", + ) + + private val _stateFlow = MutableStateFlow(State()) + val stateFlow = _stateFlow.asStateFlow() + + init { + viewModelScope.launch { + userRepository.statusFlow.collect { status -> + val isVip = status.isVIP() + _stateFlow.update { state -> state.copy(progress = false, isVip = isVip) } + } + } + } + + fun updateVipText(text: String) { + _stateFlow.update { s -> s.copy(text = text, error = null) } + } + + private val exceptionHandler = CoroutineExceptionHandler { _, e -> + var errorType = ErrorType.UNEXPECTED + if (e is HttpException) { + if (e.code() == 404 || e.code() == 409 || e.code() == 401) { + errorType = ErrorType.BAD_REQUEST + } + } + _stateFlow.update { s -> s.copy(progress = false, error = errorType, isVip = false) } + } + + fun applyCode() { + viewModelScope.launch(exceptionHandler) { + _stateFlow.update { s -> s.copy(progress = true, error = null, isVip = false) } + val status = userRepository.applyVIPCode(_stateFlow.value.text) + val error = if (status.isVIP()) null else ErrorType.BAD_REQUEST + _stateFlow.update { s -> s.copy(progress = false, error = error) } + } + } + + fun resetState() { + _stateFlow.update { s -> s.copy(progress = false, error = null, isVip = false) } + } + + enum class ErrorType { + UNEXPECTED, + BAD_REQUEST, + } +} \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/activity/send/SendCoinsActivity.kt b/mbw/src/main/java/com/mycelium/wallet/activity/send/SendCoinsActivity.kt index 7d7212902c..8e3298ef4a 100644 --- a/mbw/src/main/java/com/mycelium/wallet/activity/send/SendCoinsActivity.kt +++ b/mbw/src/main/java/com/mycelium/wallet/activity/send/SendCoinsActivity.kt @@ -11,7 +11,6 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.text.Html -import android.text.TextUtils import android.view.MenuItem import android.view.MotionEvent import android.view.View @@ -35,6 +34,7 @@ import com.mycelium.wallet.* import com.mycelium.wallet.activity.GetAmountActivity import com.mycelium.wallet.activity.ScanActivity import com.mycelium.wallet.activity.modern.GetFromAddressBookActivity +import com.mycelium.wallet.activity.send.adapter.BatchAdapter import com.mycelium.wallet.activity.send.adapter.FeeLvlViewAdapter import com.mycelium.wallet.activity.send.adapter.FeeViewAdapter import com.mycelium.wallet.activity.send.event.AmountListener @@ -43,6 +43,7 @@ import com.mycelium.wallet.activity.send.model.* import com.mycelium.wallet.activity.util.collapse import com.mycelium.wallet.activity.util.expand import com.mycelium.wallet.activity.util.toStringFriendlyWithUnit +import com.mycelium.wallet.activity.view.VerticalSpaceItemDecoration import com.mycelium.wallet.content.HandleConfigFactory import com.mycelium.wallet.databinding.SendCoinsActivityBinding import com.mycelium.wallet.databinding.SendCoinsActivityBtcBinding @@ -82,6 +83,38 @@ class SendCoinsActivity : AppCompatActivity(), BroadcastResultListener, AmountLi private lateinit var viewModel: SendCoinsViewModel private lateinit var mbwManager: MbwManager private lateinit var senderFioNamesMenu: PopupMenu + private val batchAdapter = BatchAdapter().apply { + clipboardListener = { position, item -> + viewModel.onClickClipboard(item) + } + contactListener = { position, item -> + startActivityForResult( + Intent(this@SendCoinsActivity, GetFromAddressBookActivity::class.java) + .putExtra(ACCOUNT, viewModel.getAccount().id) + .putExtra(IS_COLD_STORAGE, viewModel.isColdStorage()), + (position + 1).shl(10) or ADDRESS_BOOK_RESULT_CODE + ) + } + qrScanListener = { position, item -> + val config = HandleConfigFactory.returnKeyOrAddressOrUriOrKeynode() + ScanActivity.callMe( + this@SendCoinsActivity, + (position + 1).shl(10) or SCAN_RESULT_CODE, config + ) + } + amountListener = { position, item -> + val account = viewModel.getAccount() + GetAmountActivity.callMeToSend( + this@SendCoinsActivity, + (position + 1).shl(10) or GET_AMOUNT_RESULT_CODE, account.id, + item.crypto, viewModel.getSelectedFee().value, + viewModel.isColdStorage(), item.address, viewModel.getTransactionData().value + ) + } + closeListener = { position, item -> + viewModel.removeOutput(item) + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -89,7 +122,7 @@ class SendCoinsActivity : AppCompatActivity(), BroadcastResultListener, AmountLi mbwManager = MbwManager.getInstance(application) val accountId = checkNotNull(intent.getSerializableExtra(ACCOUNT) as UUID) val rawPaymentRequest = intent.getByteArrayExtra(RAW_PAYMENT_REQUEST) - val crashHint = TextUtils.join(", ", intent.extras!!.keySet()) + " (account id was $accountId)" + val crashHint = intent.extras!!.keySet().joinToString() + " (account id was $accountId)" val isColdStorage = intent.getBooleanExtra(IS_COLD_STORAGE, false) val account = mbwManager.getWalletManager(isColdStorage).getAccount(accountId) ?: throw IllegalStateException(crashHint) @@ -159,6 +192,9 @@ class SendCoinsActivity : AppCompatActivity(), BroadcastResultListener, AmountLi root.postDelayed({ root.smoothScrollBy(0, root.maxScrollAmount) }, 500) } } + viewModel.outputList.observe(this) { + batchAdapter.submitList(it) + } } private fun updateMemoVisibility() { @@ -220,6 +256,11 @@ class SendCoinsActivity : AppCompatActivity(), BroadcastResultListener, AmountLi .also { it.viewModel = viewModel as SendBtcViewModel it.activity = this + it.batch.adapter = batchAdapter + it.batch.addItemDecoration(VerticalSpaceItemDecoration(resources.getDimensionPixelOffset(R.dimen.size_x4))) + it.addBatchAddress.setOnClickListener { + viewModel.addEmptyOutput() + } } } is EthAccount, is ERC20Account -> { @@ -522,6 +563,13 @@ class SendCoinsActivity : AppCompatActivity(), BroadcastResultListener, AmountLi startActivityForResult(intent, MANUAL_ENTRY_RESULT_CODE) } + fun onClickBatch() { + (viewModel as? SendBtcViewModel)?.run { + isBatch.value = true + addEmptyOutput() + } + } + fun onClickSenderFioNames() { senderFioNamesMenu.show() } @@ -649,7 +697,13 @@ class SendCoinsActivity : AppCompatActivity(), BroadcastResultListener, AmountLi val hash = HexUtils.toHex(signedTransaction.id) val fiat = viewModel.getFiatValue() fiat?.run { - getSharedPreferences(TRANSACTION_FIAT_VALUE, Context.MODE_PRIVATE).edit().putString(hash, fiat).apply() + val pref = getSharedPreferences(TRANSACTION_FIAT_VALUE, Context.MODE_PRIVATE).edit() + if (viewModel.isBatch.value == true) { + pref.putString(BATCH_HASH_PREFIX + hash, fiat) + } else { + pref.putString(hash, fiat) + } + pref.apply() } result.putExtra(Constants.TRANSACTION_FIAT_VALUE_KEY, fiat) .putExtra(Constants.TRANSACTION_ID_INTENT_KEY, hash) @@ -687,6 +741,7 @@ class SendCoinsActivity : AppCompatActivity(), BroadcastResultListener, AmountLi internal const val ASSET_URI = "assetUri" const val SIGNED_TRANSACTION = "signedTransaction" const val TRANSACTION_FIAT_VALUE = "transaction_fiat_value" + const val BATCH_HASH_PREFIX = "batch_" @JvmStatic fun getIntent(currentActivity: Activity, account: UUID, isColdStorage: Boolean): Intent = diff --git a/mbw/src/main/java/com/mycelium/wallet/activity/send/adapter/BatchAdapter.kt b/mbw/src/main/java/com/mycelium/wallet/activity/send/adapter/BatchAdapter.kt new file mode 100644 index 0000000000..e7df2b479b --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/activity/send/adapter/BatchAdapter.kt @@ -0,0 +1,82 @@ +package com.mycelium.wallet.activity.send.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.mycelium.wallet.R +import com.mycelium.wallet.activity.util.toStringWithUnit +import com.mycelium.wallet.databinding.ItemSendCoinsBatchBinding +import com.mycelium.wapi.wallet.Address +import com.mycelium.wapi.wallet.coins.Value + +data class BatchItem( + val id: Int, val label: String, val address: Address?, + val crypto: Value?, val fiat: Value? +) + +class BatchAdapter : ListAdapter(ItemDiffCallback()) { + var clipboardListener: ((Int, BatchItem) -> Unit)? = null + var contactListener: ((Int, BatchItem) -> Unit)? = null + var qrScanListener: ((Int, BatchItem) -> Unit)? = null + var amountListener: ((Int, BatchItem) -> Unit)? = null + var closeListener: ((Int, BatchItem) -> Unit)? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + ViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.item_send_coins_batch, parent, false) + ) + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + holder as ViewHolder + val item = getItem(position) + holder.binding.label.text = item.label + holder.binding.address.text = item.address?.toString() + holder.binding.cryptoAmount.text = item.crypto?.toStringWithUnit() + holder.binding.fiatAmount.text = item.fiat?.toStringWithUnit() + + holder.binding.close.isVisible = position != 0 + holder.binding.closeDivider.isVisible = position != 0 + + holder.binding.clipboard.setOnClickListener { + val position = holder.absoluteAdapterPosition + clipboardListener?.invoke(position, getItem(position)) + } + holder.binding.contacts.setOnClickListener { + val position = holder.absoluteAdapterPosition + contactListener?.invoke(position, getItem(position)) + } + holder.binding.qrCode.setOnClickListener { + val position = holder.absoluteAdapterPosition + qrScanListener?.invoke(position, getItem(position)) + } + holder.binding.cryptoAmount.setOnClickListener { + val position = holder.absoluteAdapterPosition + amountListener?.invoke(position, getItem(position)) + } + holder.binding.fiatAmount.setOnClickListener { + val position = holder.absoluteAdapterPosition + amountListener?.invoke(position, getItem(position)) + } + holder.binding.close.setOnClickListener { + val position = holder.absoluteAdapterPosition + closeListener?.invoke(position, getItem(position)) + } + } + + class ViewHolder(val itemView: View) : RecyclerView.ViewHolder(itemView) { + val binding = ItemSendCoinsBatchBinding.bind(itemView) + } + + class ItemDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: BatchItem, newItem: BatchItem): Boolean = + oldItem == newItem + + override fun areContentsTheSame(oldItem: BatchItem, newItem: BatchItem): Boolean = + oldItem == newItem + } +} \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendBtcViewModel.kt b/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendBtcViewModel.kt index dadb44287a..0eeeccad3d 100644 --- a/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendBtcViewModel.kt +++ b/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendBtcViewModel.kt @@ -31,8 +31,12 @@ import java.util.regex.Pattern open class SendBtcViewModel(application: Application) : SendCoinsViewModel(application) { + + override val isBatchable: Boolean + get() = true override val uriPattern = Pattern.compile("[a-zA-Z0-9]+")!! + override fun init(account: WalletAccount<*>, intent: Intent) { super.init(account, intent) model = SendBtcModel(context, account, intent) diff --git a/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendCoinsModel.kt b/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendCoinsModel.kt index 515fe9eb84..512660f101 100644 --- a/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendCoinsModel.kt +++ b/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendCoinsModel.kt @@ -14,6 +14,7 @@ import com.mycelium.wallet.MinerFee import com.mycelium.wallet.R import com.mycelium.wallet.activity.send.SendCoinsActivity import com.mycelium.wallet.activity.send.SignTransactionActivity +import com.mycelium.wallet.activity.send.adapter.BatchItem import com.mycelium.wallet.activity.send.helper.FeeItemsBuilder import com.mycelium.wallet.activity.util.get import com.mycelium.wallet.activity.util.toStringWithUnit @@ -67,6 +68,9 @@ abstract class SendCoinsModel( val fioMemo: MutableLiveData = MutableLiveData() val payerFioName: MutableLiveData = MutableLiveData() + val outputList = MutableLiveData(listOf()) + val isBatch = MutableLiveData(false) + val transactionData: MutableLiveData = object : MutableLiveData() { override fun setValue(value: TransactionData?) { if (value != this.value) { @@ -215,7 +219,9 @@ abstract class SendCoinsModel( showStaleWarning.value = feeEstimation.lastCheck < System.currentTimeMillis() - FEE_EXPIRATION_TIME MbwManager.getEventBus().register(eventListener) - + outputList.observeForever { + txRebuildPublisher.onNext(Unit) + } /** * This observes different events, which causes tx being rebuilt. * All events are merged, and only last event/result is used. @@ -483,6 +489,19 @@ abstract class SendCoinsModel( try { return when { + isBatch.value == true && transactionDataStatus.value != TransactionDataStatus.TYPING -> { + transaction = account.createTx(outputList.value?.map { + val amount = it.crypto ?: Value.zeroValue(account.coinType) + val value = mbwManager.exchangeRateManager.get( + mbwManager.getWalletManager(false), + amount, + account.coinType + ) ?: amount + (it.address ?: account.dummyAddress) to value + }.orEmpty(), FeePerKbFee(selectedFee.value!!), transactionData.value) + spendingUnconfirmed.postValue(account.isSpendingUnconfirmed(transaction!!)) + TransactionStatus.OK + } paymentRequestHandler.value?.hasValidPaymentRequest() == true -> { handlePaymentRequest(toSend) } diff --git a/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendCoinsViewModel.kt b/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendCoinsViewModel.kt index 127172cf49..ed337414c3 100644 --- a/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendCoinsViewModel.kt +++ b/mbw/src/main/java/com/mycelium/wallet/activity/send/model/SendCoinsViewModel.kt @@ -25,6 +25,7 @@ import com.mycelium.wallet.activity.send.BroadcastDialog import com.mycelium.wallet.activity.send.ManualAddressEntry import com.mycelium.wallet.activity.send.SendCoinsActivity import com.mycelium.wallet.activity.send.VerifyPaymentRequestActivity +import com.mycelium.wallet.activity.send.adapter.BatchItem import com.mycelium.wallet.activity.util.* import com.mycelium.wallet.content.ResultType import com.mycelium.wallet.event.SyncFailed @@ -54,6 +55,9 @@ import com.squareup.otto.Subscribe import org.bitcoin.protocols.payments.PaymentACK import java.util.* import java.util.regex.Pattern +import kotlin.reflect.jvm.internal.ReflectProperties.Val + + abstract class SendCoinsViewModel(application: Application) : AndroidViewModel(application) { val context: Context = application @@ -70,6 +74,8 @@ abstract class SendCoinsViewModel(application: Application) : AndroidViewModel(a private var receivingAcc: UUID? = null private var xpubSyncing: Boolean = false + open val isBatchable = false + // As ottobus does not support inheritance listener should be incapsulated into an object private val eventListener = object : Any() { @Subscribe @@ -120,7 +126,7 @@ abstract class SendCoinsViewModel(application: Application) : AndroidViewModel(a MbwManager.getEventBus().register(eventListener) } - abstract fun sendTransaction(activity: Activity) + abstract fun sendTransaction(activity: Activity) protected fun sendFioObtData() { // TODO: 10/7/20 redesign the whole process to have the viewModel around until after the @@ -187,6 +193,9 @@ abstract class SendCoinsViewModel(application: Application) : AndroidViewModel(a val fioMemo get() = model.fioMemo + val isBatch get() = model.isBatch + val outputList get() = model.outputList + fun getRecipientRepresentation() = model.recipientRepresentation enum class RecipientRepresentation { @@ -243,8 +252,14 @@ abstract class SendCoinsViewModel(application: Application) : AndroidViewModel(a fun getGenericUri() = model.genericUri fun getFiatValue(): String? { - val fiat = mbwManager.exchangeRateManager.get(model.amount.value, - mbwManager.currencySwitcher.getCurrentFiatCurrency(model.account.coinType)) + val fiat = mbwManager.exchangeRateManager.get( + if (isBatch.value == true) { + model.outputList.value?.mapNotNull { it.crypto }?.sumOf() + } else { + model.amount.value + }, + mbwManager.currencySwitcher.getCurrentFiatCurrency(model.account.coinType) + ) return fiat?.toStringWithUnit() } @@ -304,31 +319,47 @@ abstract class SendCoinsViewModel(application: Application) : AndroidViewModel(a } } - fun onClickClipboard() { + @JvmOverloads + fun onClickClipboard(item: BatchItem? = null) { val uri = model.clipboardUri.value ?: return activity?.let { makeText(it, context.getString(R.string.using_address_from_clipboard), LENGTH_SHORT).show() } - model.receivingAddress.value = uri.address - if (uri.value != null && !uri.value!!.isNegative()) { - model.amount.value = uri.value + if (item != null) { + updateItem(item.copy(address = uri.address, crypto = uri.value)) + } else { + model.receivingAddress.value = uri.address + if (uri.value != null && !uri.value!!.isNegative()) { + model.amount.value = uri.value + } } } open fun processReceivedResults(requestCode: Int, resultCode: Int, data: Intent?, activity: Activity) { - if (requestCode == SendCoinsActivity.GET_AMOUNT_RESULT_CODE && resultCode == Activity.RESULT_OK) { + if (0x0000ff and requestCode == SendCoinsActivity.GET_AMOUNT_RESULT_CODE && resultCode == Activity.RESULT_OK) { if (data?.getBooleanExtra(GetAmountActivity.EXIT_TO_MAIN_SCREEN, false) == true) { activity.setResult(Activity.RESULT_CANCELED) activity.finish() } else { // Get result from AmountEntry val enteredAmount = data?.getSerializableExtra(GetAmountActivity.AMOUNT) as Value? - model.amount.value = enteredAmount ?: Value.zeroValue(model.account.coinType) + val batchIndex = requestCode.shr(10) + val value = enteredAmount ?: Value.zeroValue(model.account.coinType) + if (batchIndex != 0) { + val item = model.outputList.value?.get(batchIndex - 1)!! + updateItem(item.copy(crypto = value)) + } else { + model.amount.value = enteredAmount ?: Value.zeroValue(model.account.coinType) + } } - } else if (requestCode == SendCoinsActivity.SCAN_RESULT_CODE) { - handleScanResults(resultCode, data, activity) - } else if (requestCode == SendCoinsActivity.ADDRESS_BOOK_RESULT_CODE && resultCode == Activity.RESULT_OK) { - handleAddressBookResults(data) + } else if (0x0000ff and requestCode == SendCoinsActivity.SCAN_RESULT_CODE) { + val batchIndex = requestCode.shr(10) + val item = model.outputList.value?.getOrNull(batchIndex - 1) + handleScanResults(resultCode, data, activity, item) + } else if (0x0000ff and requestCode == SendCoinsActivity.ADDRESS_BOOK_RESULT_CODE && resultCode == Activity.RESULT_OK) { + val batchIndex = requestCode.shr(10) + val item = model.outputList.value?.getOrNull(batchIndex - 1) + handleAddressBookResults(data, item) } else if (requestCode == SendCoinsActivity. MANUAL_ENTRY_RESULT_CODE && resultCode == Activity.RESULT_OK) { model.receivingAddress.value = @@ -352,7 +383,7 @@ abstract class SendCoinsViewModel(application: Application) : AndroidViewModel(a } } - private fun handleScanResults(resultCode: Int, data: Intent?, activity: Activity) { + private fun handleScanResults(resultCode: Int, data: Intent?, activity: Activity, item: BatchItem? = null) { if (resultCode != Activity.RESULT_OK) { val error = data?.getStringExtra(StringHandlerActivity.RESULT_ERROR) if (error != null) { @@ -364,18 +395,40 @@ abstract class SendCoinsViewModel(application: Application) : AndroidViewModel(a throw NotImplementedError("Private key must be implemented per currency") } ResultType.ADDRESS -> { - if (data.getAddress().coinType == getAccount().basedOnCoinType) { - model.receivingAddress.value = data.getAddress() + if (item != null) { + updateItem(item.copy(address = data.getAddress())) } else { - makeText(activity, context.getString(R.string.not_correct_address_type), LENGTH_LONG).show() + if (data.getAddress().coinType == getAccount().basedOnCoinType) { + model.receivingAddress.value = data.getAddress() + } else { + makeText( + activity, + context.getString(R.string.not_correct_address_type), + LENGTH_LONG + ).show() + } } } ResultType.ASSET_URI -> { val uri = data.getAssetUri() - if (uri.address?.coinType == getAccount().basedOnCoinType) { - processAssetUri(uri) + if (item != null) { + updateItem( + item.copy( + label = uri.label ?: item.label, + address = uri.address, + crypto = uri.value + ) + ) } else { - makeText(activity, context.getString(R.string.not_correct_address_type), LENGTH_LONG).show() + if (uri.address?.coinType == getAccount().basedOnCoinType) { + processAssetUri(uri) + } else { + makeText( + activity, + context.getString(R.string.not_correct_address_type), + LENGTH_LONG + ).show() + } } } ResultType.HD_NODE -> { @@ -409,13 +462,26 @@ abstract class SendCoinsViewModel(application: Application) : AndroidViewModel(a } } - private fun handleAddressBookResults(data: Intent?) { + private fun handleAddressBookResults(data: Intent?, item: BatchItem? = null) { // Get result from address chooser val address = data?.getSerializableExtra(AddressBookFragment.ADDRESS_RESULT_NAME) as Address? ?: return - model.receivingAddress.value = address - if (data?.extras!!.containsKey(AddressBookFragment.ADDRESS_RESULT_LABEL)) { - model.receivingLabel.postValue(data.getStringExtra(AddressBookFragment.ADDRESS_RESULT_LABEL)) + if (item != null) { + if (data?.extras!!.containsKey(AddressBookFragment.ADDRESS_RESULT_LABEL)) { + updateItem( + item.copy( + label = data.getStringExtra(AddressBookFragment.ADDRESS_RESULT_LABEL)!!, + address = address + ) + ) + } else { + updateItem(item.copy(address = address)) + } + } else { + model.receivingAddress.value = address + if (data?.extras!!.containsKey(AddressBookFragment.ADDRESS_RESULT_LABEL)) { + model.receivingLabel.postValue(data.getStringExtra(AddressBookFragment.ADDRESS_RESULT_LABEL)) + } } // this is where colusend is calling tryCreateUnsigned // why is amountToSend not set ? @@ -444,5 +510,35 @@ abstract class SendCoinsViewModel(application: Application) : AndroidViewModel(a MbwManager.getEventBus().post(SyncFailed(receivingAcc)) } } + + private fun updateItem(item:BatchItem) { + val newList = model.outputList.value.orEmpty().toMutableList() + newList[newList.indexOfFirst { it.id == item.id }] = item + model.outputList.value = newList + } + + var lastAdded = 0 + fun addEmptyOutput() { + model.outputList.postValue( + model.outputList.value.orEmpty() + + BatchItem(lastAdded, "Address ${lastAdded + 1}", null, null, null) + ) + lastAdded++ + } + + fun removeOutput(it: BatchItem) { + val newList = model.outputList.value.orEmpty().toMutableList() + newList.remove(it) + model.outputList.value = newList + } + +} + +private fun List.sumOf(): Value? { + var result: Value? = null + forEach { + result = result?.plus(it) ?: it + } + return result } diff --git a/mbw/src/main/java/com/mycelium/wallet/ext.kt b/mbw/src/main/java/com/mycelium/wallet/ext.kt index e10d46f279..94fe730839 100644 --- a/mbw/src/main/java/com/mycelium/wallet/ext.kt +++ b/mbw/src/main/java/com/mycelium/wallet/ext.kt @@ -2,6 +2,7 @@ package com.mycelium.wallet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch inline fun startCoroutineTimer( @@ -22,4 +23,19 @@ inline fun startCoroutineTimer( } } -fun List.randomOrNull(): E? = if (size > 0) random() else null \ No newline at end of file +fun List.randomOrNull(): E? = if (size > 0) random() else null + +/** + * Updates the [MutableStateFlow.value] atomically using the specified [function] of its value. + * + * [function] may be evaluated multiple times, if [value] is being concurrently updated. + */ +inline fun MutableStateFlow.update(function: (T) -> T) { + while (true) { + val prevValue = value + val nextValue = function(prevValue) + if (compareAndSet(prevValue, nextValue)) { + return + } + } +} \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/external/DefaultJsonRpcRequest.kt b/mbw/src/main/java/com/mycelium/wallet/external/DefaultJsonRpcRequest.kt new file mode 100644 index 0000000000..26b303f6d4 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/DefaultJsonRpcRequest.kt @@ -0,0 +1,8 @@ +package com.mycelium.wallet.external + +data class DefaultJsonRpcRequest( + val jsonrpc: String = "2.0", + val id: String = "test", + val method: String? = null, + val params: Any? = null, +) diff --git a/mbw/src/main/java/com/mycelium/wallet/external/DigitalSignatureInterceptor.kt b/mbw/src/main/java/com/mycelium/wallet/external/DigitalSignatureInterceptor.kt new file mode 100644 index 0000000000..e4fc754861 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/DigitalSignatureInterceptor.kt @@ -0,0 +1,57 @@ +package com.mycelium.wallet.external + +import android.util.Base64 +import android.util.Base64.NO_WRAP +import okhttp3.Interceptor +import okhttp3.Response +import okio.Buffer +import org.bouncycastle.crypto.AsymmetricCipherKeyPair +import org.bouncycastle.crypto.params.ECPublicKeyParameters +import org.bouncycastle.crypto.signers.ECDSASigner + +class DigitalSignatureInterceptor(private val keyPair: AsymmetricCipherKeyPair) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val requestBody = originalRequest.body() ?: return chain.proceed(originalRequest) + val requestBodyJson = try { + val buffer = Buffer() + requestBody.writeTo(buffer) + buffer.readUtf8() + } catch (e: Exception) { + return chain.proceed(originalRequest) + } + + val signedRequest = originalRequest.newBuilder() + .addHeader(HEADER_API_KEY, encodePublicKey()) + .addHeader(HEADER_SIGN_KEY, signMessage(requestBodyJson)) + .build() + return chain.proceed(signedRequest) + } + + private fun encodePublicKey(): String { + val publicKeyParams = keyPair.public as ECPublicKeyParameters + val encodedPublicKey = publicKeyParams.q.getEncoded(false) + return Base64.encodeToString(encodedPublicKey, NO_WRAP) + } + + private fun signMessage(message: String): String { + val signedBytes = signData(message.toByteArray()) + return Base64.encodeToString(signedBytes, NO_WRAP) + } + + private fun signData(data: ByteArray): ByteArray { + val signer = ECDSASigner() + signer.init(true, keyPair.private) + val signatureComponents = signer.generateSignature(data) + // skip first 0 byte if appears + val leftPart = signatureComponents[0].toByteArray().takeLast(32) + val rightPart = signatureComponents[1].toByteArray().takeLast(32) + val signature = leftPart + rightPart + return signature.toByteArray() + } + + private companion object { + const val HEADER_API_KEY = "X-Client-Api-Key" + const val HEADER_SIGN_KEY = "X-Client-Api-Signature" + } +} \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyAPIService.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyAPIService.kt index 26608a6668..f0d7d23c39 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyAPIService.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyAPIService.kt @@ -1,12 +1,14 @@ package com.mycelium.wallet.external.changelly -import com.mycelium.wallet.external.changelly.model.* -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor +import com.mycelium.wallet.external.changelly.model.ChangellyCurrency +import com.mycelium.wallet.external.changelly.model.ChangellyGetExchangeAmountResponse +import com.mycelium.wallet.external.changelly.model.ChangellyListResponse +import com.mycelium.wallet.external.changelly.model.ChangellyResponse +import com.mycelium.wallet.external.changelly.model.ChangellyTransaction +import com.mycelium.wallet.external.changelly.model.ChangellyTransactionOffer +import com.mycelium.wallet.external.changelly.model.FixRate import retrofit2.Call import retrofit2.Response -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.POST import retrofit2.http.Query import java.math.BigDecimal @@ -20,15 +22,23 @@ interface ChangellyAPIService { @POST("getCurrencies") fun getCurrencies(): Call>> - @POST("getCurrenciesFull") - fun getCurrenciesFull(): List - // {"jsonrpc":"2.0","id":"test","result":"0.03595702"} + @Deprecated( + "Use getFixRateForAmount for limits. A full-fledged replacement of these methods is also coming soon", + ReplaceWith("getFixRateForAmount") + ) @POST("getMinAmount") - fun getMinAmount(@Query("from") from: String?, @Query("to") to: String?): Call> + fun getMinAmount( + @Query("from") from: String, + @Query("to") to: String, + ): Call> @POST("getExchangeAmount") - fun getExchangeAmount(@Query("from") from: String?, @Query("to") to: String?, @Query("amount") amount: Double): Call> + fun getExchangeAmount( + @Query("from") from: String, + @Query("to") to: String, + @Query("amountFrom") amount: Double, + ): Call> //{ // "jsonrpc":"2.0", @@ -36,46 +46,53 @@ interface ChangellyAPIService { // "result":{"id":"39526c0eb6ba","apiExtraFee":"0","changellyFee":"0.5","payinExtraId":null,"status":"new","currencyFrom":"eth","currencyTo":"BTC","amountTo":0,"payinAddress":"0xdd0a917944efc6a371829053ad318a6a20ee1090","payoutAddress":"1J3cP281yiy39x3gcPaErDR6CSbLZZKzGz","createdAt":"2017-11-22T18:47:19.000Z"} // } @POST("createTransaction") - fun createTransaction(@Query("from") from: String?, - @Query("to") to: String?, - @Query("amount") amount: Double, - @Query("address") address: String?): Call> - - // @POST("getStatus") - // Call getStatus(@Query("transaction") String transaction); - @POST("getTransactions") - fun getTransactions(): Call>> - - @POST("getCurrencies") - suspend fun currencies(): Response>> + fun createTransaction( + @Query("from") from: String, + @Query("to") to: String, + @Query("amountFrom") amount: Double, + @Query("address") address: String, + ): Call> @POST("getCurrenciesFull") - suspend fun currenciesFull(): Response>> + suspend fun getCurrenciesFull(): Response>> @POST("getFixRateForAmount") - suspend fun exchangeAmountFix(@Query("from") from: String, - @Query("to") to: String, - @Query("amountFrom") amount: BigDecimal): Response> - + suspend fun getFixRateForAmount( + @Query("from") from: String, + @Query("to") to: String, + @Query("amountFrom") amount: BigDecimal = BigDecimal.ONE, + ): Response> + + @Deprecated( + "To get the fixed rate, you need to use getFixRateForAmount, but the transaction amount must be within limits", + ReplaceWith("getFixRateForAmount") + ) @POST("getFixRate") - suspend fun fixRate(@Query("from") from: String, - @Query("to") to: String): Response> + suspend fun getFixRate( + @Query("from") from: String, + @Query("to") to: String, + ): Response> @POST("createFixTransaction") - suspend fun createFixTransaction(@Query("from") from: String?, - @Query("to") to: String?, - @Query("amountFrom") amount: String, - @Query("address") address: String?, - @Query("rateId") rateId: String?, - @Query("refundAddress") refundAddress: String?): Response> + suspend fun createFixTransaction( + @Query("from") from: String, + @Query("to") to: String, + @Query("amountFrom") amount: String, + @Query("address") address: String, + @Query("rateId") rateId: String, + @Query("refundAddress") refundAddress: String, + ): ChangellyResponse @POST("getTransactions") - suspend fun getTransaction(@Query("id") id: String, - @Query("limit") limit: Int = 1): Response>> + suspend fun getTransaction( + @Query("id") id: String, + @Query("limit") limit: Int = 1, + ): ChangellyResponse> @POST("getTransactions") - suspend fun getTransactions(@Query("id") id: List): Response>> - + suspend fun getTransactions( + @Query("id") id: List, + ): ChangellyResponse> companion object { const val BCH = "BCH" @@ -84,21 +101,5 @@ interface ChangellyAPIService { const val TO = "TO" const val AMOUNT = "AMOUNT" const val DESTADDRESS = "DESTADDRESS" - - val logging = HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY); - val changellyHeader = ChangellyHeaderInterceptor() - - //public static final OkHttpClient httpClient = new OkHttpClient.Builder().addInterceptor(changellyHeader).addInterceptor(logging).build(); - val httpClient = OkHttpClient.Builder() - .addInterceptor(changellyHeader) - .addInterceptor(logging) - .build() - - @JvmStatic - val retrofit = Retrofit.Builder() - .baseUrl("https://api.changelly.com/") - .addConverterFactory(GsonConverterFactory.create()) - .client(httpClient) - .build() } } \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyActivity.java b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyActivity.java deleted file mode 100644 index e2f9e7a7e0..0000000000 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyActivity.java +++ /dev/null @@ -1,458 +0,0 @@ -package com.mycelium.wallet.external.changelly; - -import android.content.Intent; -import android.os.Bundle; -import android.text.Editable; -import android.util.Log; -import android.view.View; -import android.widget.Button; -import android.widget.ScrollView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.recyclerview.widget.RecyclerView; - -import com.mycelium.wallet.MbwManager; -import com.mycelium.wallet.R; -import com.mycelium.wallet.activity.modern.Toaster; -import com.mycelium.wallet.activity.send.event.SelectListener; -import com.mycelium.wallet.activity.send.view.SelectableRecyclerView; -import com.mycelium.wallet.activity.view.ValueKeyboard; -import com.mycelium.wallet.external.changelly.model.ChangellyResponse; -import com.mycelium.wapi.wallet.WalletAccount; -import com.mycelium.wapi.wallet.WalletManager; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import butterknife.BindView; -import butterknife.ButterKnife; -import butterknife.OnClick; -import butterknife.OnTextChanged; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; - -import static butterknife.OnTextChanged.Callback.AFTER_TEXT_CHANGED; -import static com.mycelium.wallet.activity.util.WalletManagerExtensionsKt.getActiveBTCSingleAddressAccounts; -import static com.mycelium.wallet.external.changelly.ChangellyConstants.decimalFormat; -import static com.mycelium.wapi.wallet.btc.bip44.BitcoinHDModuleKt.getActiveHDAccounts; -import static com.mycelium.wapi.wallet.currency.CurrencyValue.BTC; - -public class ChangellyActivity extends AppCompatActivity { - public static final int REQUEST_OFFER = 100; - private static String TAG = "ChangellyActivity"; - private ChangellyAPIService changellyAPIService = ChangellyAPIService.getRetrofit().create(ChangellyAPIService.class); - - public enum ChangellyUITypes { - Loading, - RetryLater, - Main - } - - @BindView(R.id.tvMinAmountValue) - TextView tvMinAmountValue; - - @BindView(R.id.fromLayout) - View fromLayout; - - @BindView(R.id.fromValue) - TextView fromValue; - - @BindView(R.id.fromCurrency) - TextView fromCurrency; - - @BindView(R.id.toLayout) - View toLayout; - - @BindView(R.id.toValue) - TextView toValue; - - @BindView(R.id.btChangellyCreateTransaction) - Button btTakeOffer; - - @BindView(R.id.currencySelector) - SelectableRecyclerView currencySelector; - - @BindView(R.id.accountSelector) - SelectableRecyclerView accountSelector; - - @BindView(R.id.numeric_keyboard) - ValueKeyboard valueKeyboard; - - @BindView(R.id.title) - View titleView; - - @BindView(R.id.subtitle) - View subtitleView; - - @BindView(R.id.llChangellyErrorWrapper) - View llChangellyErrorWrapper; - - @BindView(R.id.llChangellyLoadingProgress) - View llChangellyLoadingProgress; - - @BindView(R.id.llChangellyMain) - ScrollView llChangellyMain; - - @BindView(R.id.llChangellyValidationWait) - View llChangellyValidationWait; - - private CurrencyAdapter currencyAdapter; - private AccountAdapter accountAdapter; - - private Double minAmount; - - private void requestOfferFunction(String amount, String fromCurrency, String toCurrency) { - double dblAmount; - try { - dblAmount = Double.parseDouble(amount); - } catch (NumberFormatException e) { - toast("Error parsing double values"); - return; - } - changellyAPIService.getExchangeAmount(fromCurrency, toCurrency, dblAmount).enqueue(new GetOfferCallback(fromCurrency, toCurrency, dblAmount)); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.changelly_activity); - setTitle(getString(R.string.exchange_altcoins_to_btc)); - ButterKnife.bind(this); - MbwManager mbwManager = MbwManager.getInstance(this); - - tvMinAmountValue.setVisibility(View.GONE); // cannot edit field before selecting a currency - - valueKeyboard.setMaxDecimals(8); - valueKeyboard.setInputListener(new ValueKeyboard.SimpleInputListener() { - @Override - public void done() { - titleView.setVisibility(View.VISIBLE); - subtitleView.setVisibility(View.VISIBLE); - fromLayout.setAlpha(ChangellyConstants.INACTIVE_ALPHA); - toLayout.setAlpha(ChangellyConstants.INACTIVE_ALPHA); - } - }); - fromLayout.setAlpha(ChangellyConstants.INACTIVE_ALPHA); - toLayout.setAlpha(ChangellyConstants.INACTIVE_ALPHA); - - int senderFinalWidth = getWindowManager().getDefaultDisplay().getWidth(); - int firstItemWidth = (senderFinalWidth - getResources().getDimensionPixelSize(R.dimen.item_dob_width)) / 2; - - currencyAdapter = new CurrencyAdapter(firstItemWidth); - currencySelector.setAdapter(currencyAdapter); - currencySelector.setSelectListener(new SelectListener() { - @Override - public void onSelect(RecyclerView.Adapter adapter, int position) { - CurrencyAdapter.Item item = currencyAdapter.getItem(position); - selectCurrencyItem(item); - } - }); - List> toAccounts = new ArrayList<>(); - WalletManager walletManager = mbwManager.getWalletManager(false); - toAccounts.addAll(getActiveHDAccounts(walletManager)); - toAccounts.addAll(getActiveBTCSingleAddressAccounts(walletManager)); - accountAdapter = new AccountAdapter(mbwManager, toAccounts, firstItemWidth); - accountSelector.setAdapter(accountAdapter); - accountSelector.setSelectedItem(mbwManager.getSelectedAccount()); - View view = getLayoutInflater().inflate(AccountAdapter.AccountUseType.IN.paddingLayout, accountSelector, false); - view.setBackground(null); - accountSelector.setHeader(view); - accountSelector.setFooter(view); - - //display the loading spinner - setLayout(ChangellyActivity.ChangellyUITypes.Loading); - changellyAPIService.getCurrencies().enqueue(new Callback>>() { - @Override - public void onResponse(Call>> call, Response>> response) { - if (response.body() == null || response.body().getResult() == null) { - toast("Can't load currencies."); - return; - } - Log.d(TAG, "currencies=" + response.body().getResult()); - Collections.sort(response.body().getResult()); - List itemList = new ArrayList<>(); - String[] skipCurrencies = getResources().getStringArray(R.array.changelly_skip_currencies); - for (String curr : response.body().getResult()) { - if (!curr.equalsIgnoreCase("btc") && - !containsCaseInsensitive(curr, skipCurrencies)) { - itemList.add(new CurrencyAdapter.Item(curr.toUpperCase(), CurrencyAdapter.VIEW_TYPE_ITEM)); - } - } - currencyAdapter.setItems(itemList); - if (!itemList.isEmpty()) { - selectCurrencyItem(itemList.get(0)); - } - setLayout(ChangellyUITypes.Main); - } - - @Override - public void onFailure(Call>> call, Throwable t) { - toast("Can't load currencies: " + t); - } - }); - } - - private void selectCurrencyItem(CurrencyAdapter.Item item) { - if (item != null) { - fromCurrency.setText(item.currency); - fromValue.setText(null); - minAmount = 0.0; - toValue.setText(""); - - // load min amount - changellyAPIService.getMinAmount(item.currency, BTC) - .enqueue(new GetMinCallback(item.currency)); - } - } - - private void toast(String msg) { - new Toaster(this).toast(msg, true); - } - - /* Activity UI logic Start */ - private void setLayout(ChangellyUITypes uiType) { - llChangellyValidationWait.setVisibility(View.GONE); - llChangellyLoadingProgress.setVisibility(View.GONE); // always gone - llChangellyErrorWrapper.setVisibility(View.GONE); - llChangellyMain.setVisibility(View.GONE); - switch (uiType) { - case Loading: - llChangellyValidationWait.setVisibility(View.VISIBLE); - break; - case RetryLater: - llChangellyErrorWrapper.setVisibility(View.VISIBLE); - case Main: - llChangellyMain.setVisibility(View.VISIBLE); - } - } - - @Override - public void onBackPressed() { - if (valueKeyboard.getVisibility() == View.VISIBLE) { - valueKeyboard.done(); - } else if (getFragmentManager().getBackStackEntryCount() > 1) { - getFragmentManager().popBackStack(); - } else { - finish(); - } - } - - private boolean avoidTextChangeEvent = false; - - @OnTextChanged(value = R.id.fromValue, callback = AFTER_TEXT_CHANGED) - public void afterEditTextInputFrom(Editable editable) { - if (!avoidTextChangeEvent && isValueForOfferOk()) { - requestOfferFunction(fromValue.getText().toString() - , currencyAdapter.getItem(currencySelector.getSelectedItem()).currency - , BTC); - } - if (!avoidTextChangeEvent && fromValue.getText().toString().isEmpty()) { - avoidTextChangeEvent = true; - toValue.setText(null); - avoidTextChangeEvent = false; - } - } - - @OnTextChanged(value = R.id.toValue, callback = AFTER_TEXT_CHANGED) - public void afterEditTextInputTo(Editable editable) { - if (!avoidTextChangeEvent && !toValue.getText().toString().isEmpty()) { - requestOfferFunction(toValue.getText().toString() - , BTC - , currencyAdapter.getItem(currencySelector.getSelectedItem()).currency); - } - if (!avoidTextChangeEvent && toValue.getText().toString().isEmpty()) { - avoidTextChangeEvent = true; - fromValue.setText(null); - avoidTextChangeEvent = false; - } - } - - @OnClick(R.id.fromLayout) - void clickFromValue() { - valueKeyboard.setVisibility(View.VISIBLE); - valueKeyboard.setInputTextView(fromValue); - valueKeyboard.setEntry(fromValue.getText().toString()); - fromLayout.setAlpha(ChangellyConstants.ACTIVE_ALPHA); - toLayout.setAlpha(ChangellyConstants.INACTIVE_ALPHA); - - llChangellyMain.post(new Runnable() { - @Override - public void run() { - llChangellyMain.smoothScrollTo(0, fromLayout.getTop()); - } - }); - } - - @OnClick(R.id.toLayout) - void clickToValue() { - valueKeyboard.setVisibility(View.VISIBLE); - valueKeyboard.setInputTextView(toValue); - valueKeyboard.setEntry(toValue.getText().toString()); - fromLayout.setAlpha(ChangellyConstants.INACTIVE_ALPHA); - toLayout.setAlpha(ChangellyConstants.ACTIVE_ALPHA); - - llChangellyMain.post(new Runnable() { - @Override - public void run() { - llChangellyMain.smoothScrollTo(0, toLayout.getTop()); - } - }); - } - - @OnClick(R.id.btChangellyCreateTransaction) - void offerClick() { - String txtAmount = fromValue.getText().toString(); - double dblAmount; - try { - dblAmount = Double.parseDouble(txtAmount); - } catch (NumberFormatException e) { - toast("Error exchanging value"); - btTakeOffer.setEnabled(false); - return; - } - - CurrencyAdapter.Item item = currencyAdapter.getItem(currencySelector.getSelectedItem()); - WalletAccount walletAccount = accountAdapter.getItem(accountSelector.getSelectedItem()).account; - startActivityForResult(new Intent(ChangellyActivity.this, ChangellyOfferActivity.class) - .putExtra(ChangellyAPIService.FROM, item.currency) - .putExtra(ChangellyAPIService.TO, BTC) - .putExtra(ChangellyAPIService.AMOUNT, dblAmount) - .putExtra(ChangellyAPIService.DESTADDRESS, walletAccount.getReceiveAddress().toString()), REQUEST_OFFER); - - } - - boolean isValueForOfferOk() { - tvMinAmountValue.setVisibility(View.GONE); - String txtAmount = fromValue.getText().toString(); - if (txtAmount.isEmpty()) { - btTakeOffer.setEnabled(false); - return false; - } - Double dblAmount; - try { - dblAmount = Double.parseDouble(txtAmount); - } catch (NumberFormatException e) { - toast("Error exchanging value"); - btTakeOffer.setEnabled(false); - return false; - } - - if (minAmount == null || minAmount == 0) { - btTakeOffer.setEnabled(false); - toast("Please wait while loading minimum amount information."); - return false; - } else if (dblAmount.compareTo(minAmount) < 0) { - btTakeOffer.setEnabled(false); - tvMinAmountValue.setVisibility(View.VISIBLE); - return false; - } // TODO: compare with maximum - btTakeOffer.setEnabled(true); - return true; - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == REQUEST_OFFER) { - if (resultCode == ChangellyOfferActivity.RESULT_FINISH) { - finish(); - } - } - } - - public boolean containsCaseInsensitive(String str, String[] strings) { - for (String string : strings) { - if (string.equalsIgnoreCase(str)) { - return true; - } - } - return false; - } - - class GetMinCallback implements Callback> { - String from; - - GetMinCallback(String from) { - this.from = from; - } - - @Override - public void onResponse(@NonNull Call> call, - @NonNull Response> response) { - ChangellyResponse result = response.body(); - if(result == null || result.getResult() == -1) { - Log.e("MyceliumChangelly", "Minimum amount could not be retrieved"); - toast("Service unavailable"); - return; - } - double min = result.getResult(); - // service available - CurrencyAdapter.Item item = currencyAdapter.getItem(currencySelector.getSelectedItem()); - if (item != null && from != null - && from.equalsIgnoreCase(item.currency)) { - Log.d(TAG, "Received minimum amount: " + min + " " + from); - minAmount = min; - tvMinAmountValue.setText(getString(R.string.exchange_minimum_amount - , decimalFormat.format(minAmount), item.currency)); - } - } - - @Override - public void onFailure(@NonNull Call> call, - @NonNull Throwable t) { - toast("Service unavailable"); - } - } - - class GetOfferCallback implements Callback> { - final String from; - final String to; - final double fromAmount; - - GetOfferCallback(@NonNull String from, @NonNull String to, double fromAmount) { - this.from = from; - this.to = to; - this.fromAmount = fromAmount; - } - - @Override - public void onResponse(@NonNull Call> call, - @NonNull Response> response) { - ChangellyResponse result = response.body(); - if(result != null) { - double amount = result.getResult(); - Log.d("MyceliumChangelly", "You will receive the following " + to + " amount: " + result.getResult()); - CurrencyAdapter.Item item = currencyAdapter.getItem(currencySelector.getSelectedItem()); - // check if the user still needs this reply or navigated to different amounts/currencies - if (item != null) { - Log.d(TAG, "Received offer: " + amount + " " + to); - avoidTextChangeEvent = true; - try { - if (to.equalsIgnoreCase(BTC) - && from.equalsIgnoreCase(item.currency) - && fromAmount == Double.parseDouble(fromValue.getText().toString())) { - toValue.setText(decimalFormat.format(amount)); - } else if (from.equalsIgnoreCase(BTC) - && to.equalsIgnoreCase(item.currency) - && fromAmount == Double.parseDouble(toValue.getText().toString())) { - fromValue.setText(decimalFormat.format(amount)); - } - isValueForOfferOk(); - } catch (NumberFormatException ignore) { - } - avoidTextChangeEvent = false; - } - } - } - - @Override - public void onFailure(@NonNull Call> call, - @NonNull Throwable t) { - toast("Service unavailable " + t); - } - } -} diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyHeaderInterceptor.java b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyHeaderInterceptor.java deleted file mode 100644 index 5452e51010..0000000000 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyHeaderInterceptor.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.mycelium.wallet.external.changelly; - -import androidx.annotation.NonNull; - -import com.mrd.bitlib.crypto.Hmac; -import com.mrd.bitlib.util.HexUtils; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.IOException; -import java.nio.charset.Charset; -import java.util.List; - -import okhttp3.Interceptor; -import okhttp3.MediaType; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - -/** - * This Interceptor is necessary to comply with changelly's authentication scheme and follows - * roughly their example implementation in JS: - * https://github.com/changelly/api-changelly#authentication - * - * It wraps the parameters passed in, in a params object and signs the request with the api key secret. - */ -public class ChangellyHeaderInterceptor implements Interceptor { - private static final String API_KEY_DATA_BCH_TO_BTC = "4397e419ed0140ee81d28f66bd72a118"; - private static final byte[] API_SECRET_BCH_TO_BTC = "6ff5e3e4956b7c87213650babf977a56deab9b4ae37ea133a389dc997a9a3cae".getBytes(Charset.forName("US-ASCII")); - - private static final String API_KEY_DATA_ELSE = "8fb168fe8b6b4656867c846be47dccce"; - private static final byte[] API_SECRET_ELSE = "ec97042bcfba5d43f4741dbb3da9861cc59fb7c8d6123333d7823e4c7810d6c0".getBytes(Charset.forName("US-ASCII")); - - @Override - public Response intercept(@NonNull Chain chain) throws IOException { - Request request = chain.request(); - byte[] messageBytes; - String apiKeyData; - byte[] apiSecret; - try { - JSONObject params = getParamsFromRequest(request); - if("BCH2BTC".equalsIgnoreCase(params.optString("from") + "2" + params.optString("to"))) { - apiKeyData = API_KEY_DATA_BCH_TO_BTC; - apiSecret = API_SECRET_BCH_TO_BTC; - } else { - apiKeyData = API_KEY_DATA_ELSE; - apiSecret = API_SECRET_ELSE; - } - JSONObject requestBodyJson = new JSONObject() - .put("id", "test") - .put("jsonrpc", "2.0") - .put("method", getMethodFromRequest(request)) - .put("params", params); - messageBytes = requestBodyJson.toString().getBytes(); - } catch (JSONException e) { - e.printStackTrace(); - return null; - } - byte[] sha512bytes = Hmac.hmacSha512(apiSecret, messageBytes); - String signData = HexUtils.toHex(sha512bytes); - request = request.newBuilder() - .delete() - .addHeader("api-key", apiKeyData) - .addHeader("sign", signData) - .post(RequestBody.create(MediaType.parse("application/json; charset=UTF-8"), messageBytes)) - .build(); - return chain.proceed(request); - } - - private String getMethodFromRequest(Request request) { - List pathSegments = request.url().pathSegments(); - return pathSegments.get(pathSegments.size() - 1); - } - - @NonNull - private JSONObject getParamsFromRequest(Request request) throws JSONException { - JSONObject params = new JSONObject(); - for(String name : request.url().queryParameterNames()) { - List values = request.url().queryParameterValues(name); - if (values.size() > 1) { - params.put(name, new JSONArray(values)); - } else { - String value = request.url().queryParameter(name); - params.put(name, value); - } - } - return params; - } -} diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyHeaderInterceptor.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyHeaderInterceptor.kt new file mode 100644 index 0000000000..7c596460fc --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyHeaderInterceptor.kt @@ -0,0 +1,127 @@ +package com.mycelium.wallet.external.changelly + +import com.mrd.bitlib.lambdaworks.crypto.Base64 +import com.mrd.bitlib.util.HashUtils +import com.mrd.bitlib.util.HexUtils + +import okhttp3.* +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.security.* +import java.security.spec.InvalidKeySpecException +import java.security.spec.PKCS8EncodedKeySpec +import java.util.* +import javax.crypto.BadPaddingException +import javax.crypto.IllegalBlockSizeException +import javax.crypto.NoSuchPaddingException + +/** + * This Interceptor is necessary to comply with changelly's authentication scheme and follows + * roughly their example implementation in JS: + * https://github.com/changelly/api-changelly#authentication + * + * It wraps the parameters passed in, in a params object and signs the request with the api key secret. + */ +class ChangellyHeaderInterceptor : Interceptor { + + init { + Security.addProvider(BouncyCastleProvider()) + } + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response? { + var request = chain.request() + val messageBytes: ByteArray = try { + val params = getParamsFromRequest(request) + val requestBodyJson = JSONObject() + .put("id", UUID.randomUUID().toString()) + .put("jsonrpc", "2.0") + .put("method", getMethodFromRequest(request)) + .put("params", params) + val dd = requestBodyJson.toString() + .replace(",", ", ") + .replace(":", ": ") + dd.toByteArray() + } catch (e: JSONException) { + e.printStackTrace() + return null + } + request = try { + val privateKey = getPrivateKeys(PRIVATE_KEY) + val signData = encrypt(messageBytes, privateKey) + request.newBuilder() + .delete() + .url(CHANGELLY_URL) + .addHeader(X_API_KEY, API_KEY) + .addHeader(X_API_SIGNATURE, Base64.encodeToString(signData, false)) + .addHeader(CONTENT_TYPE, "application/json") + .post( + RequestBody.create( + MediaType.parse("application/json; charset=UTF-8"), + messageBytes + ) + ) + .build() + } catch (e: Exception) { + throw RuntimeException(e) + } + return chain.proceed(request) + } + + @Throws( + InvalidKeySpecException::class, + NoSuchAlgorithmException::class + ) + fun getPrivateKeys(privateKey: String): PrivateKey { + val spec = PKCS8EncodedKeySpec(HexUtils.toBytes(privateKey)) + val keyFactory = KeyFactory.getInstance("RSA") + return keyFactory.generatePrivate(spec) + } + + @Throws( + NoSuchPaddingException::class, + NoSuchAlgorithmException::class, + InvalidKeyException::class, + IllegalBlockSizeException::class, + BadPaddingException::class + ) + fun encrypt(data: ByteArray?, privateKey: PrivateKey): ByteArray { + val signer = Signature.getInstance("SHA256withRSA", "BC") + signer.initSign(privateKey) + signer.update(data) + return signer.sign() + } + + private fun getMethodFromRequest(request: Request): String { + val pathSegments = request.url().pathSegments() + return pathSegments[pathSegments.size - 1] + } + + @Throws(JSONException::class) + private fun getParamsFromRequest(request: Request): JSONObject { + val params = JSONObject() + for (name in request.url().queryParameterNames()) { + val values = request.url().queryParameterValues(name) + if (values.size > 1) { + params.put(name, JSONArray(values)) + } else { + val value = request.url().queryParameter(name) + params.put(name, value) + } + } + return params + } + + companion object { + const val X_API_KEY = "X-Api-Key" + const val X_API_SIGNATURE = "X-Api-Signature" + const val CONTENT_TYPE = "Content-Type" + const val CHANGELLY_URL = "https://api.changelly.com/v2" + const val PRIVATE_KEY = + "308204be020100300d06092a864886f70d0101010500048204a8308204a40201000282010100bc855a39f64f8cfc2f843504e7a09a686b6f97933d408308d2ff78a2a27456090c248b5f35ea00c3a528f2771fa23a5c3db12e9f30c7321da6331fd43d78a76ebb26bc9f4ef8dbaff459f0a12cb1d8011eb745529cb321f0ba92b1ec82d0e5a132d1d7453387ff329c4be0f1ddc1fabd7a656f886a936b50d123c2fcc454d2c689a748f01617662015fea8895b8c00ca42dc1b0e922e54141dc31ef9eef4e32e81d39f526de5412d77d4902a91976904616e7b1efff15b8984fb625108ca8c2ae54621fd7b0c4e8a887d85a132504d34faa3d3060043ddef002db9e1640e1acac6356d9d333b34ed8791e9926a3558d351e37810f5df518c6ed1d6f36ed68007020301000102820100014d96ab11e5c8deb16163906e1d7113c9b252c4e4c67e61603bfdd479f4fde7401b3c8f62eb0428560aeb6a2160d8b06c88bdfec1b28ec91fadf8c959c76cb8da385153749349c97491ee94de9f381401e7586652c8f63218c80ccccab6b0efa54f4802a5718a350a5987eb8411e42ecd1ac863940102dbe3263121d82591f364547b47ded4670ad8099e5370bd7de59f573edaddfbec538c00cd1da1723eb3d467fbe76f6e4a3632cbe56b9177bec81422fdaf223f0f7f12365084cedc1ecb366a07657feaae2ece8aa5d60d431dd9d5a390025d5d6dece9f24b5cffdad427b5600449d6dd1cf21f4bda7b10285351ca29b7e51ca52247e7d592ef4d51471102818100e11a9106f8179743adba0f2671b296441417e889be2cdc3862e07ad0bbe0517cfce9d503a485f0aff7f1773340c3af4e18f9330c4e5b6cc2e572500d4a3ae99af2224091aa0274eccb31fbc4edb05d25d5cd380e1bf1b4b5b838fbb7102d92b089162fb618a55a03c8d20d1c04177f0c56b2429ba14fb6a8c9889ad70bbd329f02818100d665613f84b2756eba83aff53e173829eefb77f712da4ca4ba1f9b70a700240487d8189f68e451e8de357b9991a38c9a12bb753655ed1b5896d735cc4a2fc2960c6f2611385a9b2d6f9dd530fb10553e5658378c4eae1c98dbe67093e1d1d53874a8c1a84a151d76e37eff8c13f453c74610f6d3668184d3c1d6149f5a4b61990281801808731570656c63f0675df8b7c8de5c345cfd19bfb1206df0b890c43a5acfb86d7435a6e6e8d9f29fa12b1dd0bb53bb1dd5754aca0edec4cc24714189fc523695c56c6960e2544377ca455c18186d497dd32439f567cfe85adbd29c0fe11db93559a60c66033962100dc51289a94c8a2fb36683212cd68e9cbdb5f261b1787702818100a47b3cc385638052860757ac37898ace290985fce8dacfa8251ef09ad994730d82c698055c6ca62698abc17a8cd043a344b1ca77f82e2327b0f9c4cd493121010ae30efa71189a2a9e92212825c55f10a71fa0e624cad127b8b52f3355312d7ad58d4e9d74d0843d5cc566faa9a86dc9d90854c4d4c49309fe90e65b66e3a42902818100d69b3969324b22f4f62ebd281d15e05aec884899c6534bfa3604e50505a5f276c74630d9b942cd4cc36be69b8d1e37ad06f24b4034eefdee28533686e58b8df9751380ac0f6d46730b8457fcd5c3a95eb4dc459f2b03b357da9d52fd2ab68726a2001999e630a47f101246e68d5f193e834fe97ae0001973de58e7d8e8285541" + const val API_KEY = "j1XlBr4+61GC0vyQXjgDw0mCWjei5SODgurlfwYTfzI=" + } +} \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyInterceptor.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyInterceptor.kt new file mode 100644 index 0000000000..806686e3e4 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyInterceptor.kt @@ -0,0 +1,94 @@ +package com.mycelium.wallet.external.changelly + +import android.util.Base64 +import okhttp3.Interceptor +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONObject +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Locale + +/** + * This Interceptor is necessary to comply with changelly's authentication scheme and follows + * roughly their example implementation in JS: + * https://github.com/changelly/api-changelly#authentication + * + * It wraps the parameters passed in, in a params object and signs the request with the api key secret. + */ +class ChangellyInterceptor : Interceptor { + private companion object { + const val API_HEADER_KEY = "X-Api-Key" + const val SIGN_HEADER_KEY = "X-Api-Signature" + const val PRIVATE_KEY_BASE64 = + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCFfDtC0NMgamE/SnBfqoiMS2bn75z8J+48vJubFGws6WKugv6M4CB0cg+u7Y5jUuxJxC2oX9e9UkXBx+OvKUIHufidr9uTtG0GHJEmQ11oP+teubX047nTqBROuY9gs+NPksAkcZfbQNsfdef9ZVEOea1ApnXah0GmTsftCZOSSpbAufsUhoxirK6uedNGs6VpUZrajSf26W07Bq7bQi59H630adyt70W4A6JktCVZWvWLGQ8rmUG8vPpFGd5pM37HVzuB0BigB9AG7IVH+RyVcdJTnw0jn9O1M1p0csi9QvbC5na1mRoIKd8fsCXxEK3yrEO3RT9G6vqye8A0WRPjAgMBAAECggEAOxSJrCB+GY5L/XXGd+kkJ6gl40j/+/D2dmZqHsDywgwIE8JBxPtcEf376AoXp+lnUJzmMmw9MfusiUCeCwRhR8ctfSl9L4o/aOGS8tMFECOeWt4qZTm3oTD20AM8LOphlPIYXejy8+VoNqv6YoKJ1jTPlFo4tmCAE4ox3b2L1cbO+SFJSb9VovsArwO7eWtuas+qOX1OHf0mXjEhEscrYyVQDzlvDzRWsGTtKsUdfVpkHStvt9M6g/diIE4d2VbB5fk1oGVLfk0TjNiUipBfzvwdx+8aoxobMRvcWs1rOJzArGM/PGBOyVCxXRhFnUqSDQ79TcG2+bHBOCcG2m8uxQKBgQC8UyC48Zzus2paD6Cjr2lM6Y572JXkT3ta+DAZv+wcRLv0lVx2PuwJmbZU0NfnFbyOb0cXY8/mp4dBcqDMHXPbOmnj7A6prAbbOKUOYSi94LurOXGcaA/GrZXSpht3qbBPikhULGMqGPS/Q1zDzQfrWylFxubJEBfFu+j12h6mLwKBgQC1dCrDMEO+fJKJRiASwsMD6GuBLG4WP4KvT0zMk5ih6D8xbz40B4Uhjj61mC6wk/6fzunUM7unyxbAiLlY1b/QM52MhkNP4bmsU38DinNGLkfRKre7i6e39TNEHdgaq7RbDg+zpUISLlv9qKwysZhoOIw7y6L7U8MdOR3GrCo0jQKBgCEywkT4CsMli6z+rkHMrVJqpbx9TMcnn8ZElC4l4BiHoV6XaepKY0+58iN3gWfyNAAj67Na3A58H+LQsznoQ0E1Re9w8JDGi5rfnHExfX4jfNHNWZLJ4WYTuaKdt5/boQIUjXWRMZX9Oj/xPwwhO7Eoq9jqHEr7dEVeP83/OoHvAoGBAKUOseNx4P3C1Y03k+9c6QaCAmCzaMSmKxuLeCHT1RDacblnJt8vRAQdH6ASec44IXN/Raa5FGdyzxR+ipNrhJtAiH0OmOZuP3apUS2IYImjicKUKCPayssEqgi5WR4RuPLnHJNerXZaY2WfbFyEvk13uuCdwXj7Xc4UaaiSbaX1AoGAJokaFWPJ4eciKgUhbp5aJ8SC0GdvfyXNHV6d4tcz4OyKCh9fmkMciQghu8gRa4VrsqgQ3rg/pi+BMCO9Zrc6emjOjpIsr5y3bFK3j8yvexocikn8vr/Jqoh/qrm0SE4/KKPdS77/evqqSukP8UWNcDDVNAJj9GHcMLH9w7g05tg=" + const val PUBLIC_KEY_BASE64 = "BREhI4nAIIHctkxs9s2sXSWjhW+RPqbN5sY7Ua6797I=" + + val privateKey = getChangellyApiKey() + fun getChangellyApiKey(): PrivateKey { + val privateKeyBytes = Base64.decode(PRIVATE_KEY_BASE64, Base64.NO_WRAP) + val keySpec = PKCS8EncodedKeySpec(privateKeyBytes) + val keyFactory = KeyFactory.getInstance("RSA") + return keyFactory.generatePrivate(keySpec) + } + } + + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + val params = getParamsFromRequest(request) + val method = getMethodFromRequest(request) + val baseUrl = request.url().toString().substringBefore(method) + + val requestBodyJson = JSONObject().apply { + put("id", "test") + put("jsonrpc", "2.0") + put("method", method) + put("params", params) + } + + val messageBytes = requestBodyJson.toString().toByteArray() + + val mediaType = MediaType.parse("application/json; charset=UTF-8") + val requestBody = RequestBody.create(mediaType, messageBytes) + val newRequest = request.newBuilder() + .url("$baseUrl#$method") + .addHeader(API_HEADER_KEY, PUBLIC_KEY_BASE64) + .addHeader(SIGN_HEADER_KEY, getSignature(messageBytes)) + .post(requestBody) + .build() + + return chain.proceed(newRequest) + } + + private fun getMethodFromRequest(request: Request) = request.url().pathSegments().last() + + private fun getParamsFromRequest(request: Request): JSONObject = JSONObject().apply { + request.url().queryParameterNames().forEach { name -> + val values = request.url().queryParameterValues(name) + if (values.size > 1) { + put(name, JSONArray(values)) + } else { + val param = request.url().queryParameter(name) + val value = param?.let { + if (name == "from" || name == "to") it.toLowerCase(Locale.ROOT) else it + } + put(name, value) + } + } + } + + private fun getSignature(data: ByteArray): String { + val signature = Signature.getInstance("SHA256withRSA") + signature.initSign(privateKey) + signature.update(data) + val signedData = signature.sign() + return Base64.encodeToString(signedData, Base64.NO_WRAP) + } +} diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyOfferActivity.java b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyOfferActivity.java index 8e92b5f2ce..3fa726d1b9 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyOfferActivity.java +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyOfferActivity.java @@ -156,7 +156,7 @@ private void createOffer() { amount = getIntent().getDoubleExtra(ChangellyAPIService.AMOUNT, 0); currency = getIntent().getStringExtra(ChangellyAPIService.FROM); receivingAddress = getIntent().getStringExtra(ChangellyAPIService.DESTADDRESS); - ChangellyAPIService.getRetrofit().create(ChangellyAPIService.class) + ChangellyRetrofitFactory.INSTANCE.getChangellyApi() .createTransaction(currency, BTC, amount, receivingAddress) .enqueue(new GetOfferCallback(amount)); progressDialog = new ProgressDialog(this); diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyRetrofitFactory.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyRetrofitFactory.kt new file mode 100644 index 0000000000..e15262eda7 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly/ChangellyRetrofitFactory.kt @@ -0,0 +1,63 @@ +package com.mycelium.wallet.external.changelly + +import com.mycelium.wallet.BuildConfig +import com.mycelium.wallet.UserKeysManager +import com.mycelium.wallet.external.DigitalSignatureInterceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext + +object ChangellyRetrofitFactory { + private const val VIPER_BASE_URL = "https://changelly-viper.mycelium.com/v2/" + private const val CHANGELLY_BASE_URL = "https://api.changelly.com/v2/" + + private val userKeyPair by lazy { UserKeysManager.userSignKeys } + + private fun getViperHttpClient(): OkHttpClient { + val sslContext = SSLContext.getInstance("TLSv1.3") + sslContext.init(null, null, null) + return OkHttpClient.Builder().apply { + connectTimeout(3, TimeUnit.SECONDS) + // sslSocketFactory uses system defaults X509TrustManager, so deprecation suppressed + // referring to sslSocketFactory(SSLSocketFactory, X509TrustManager) docs: + /** + * Most applications should not call this method, and instead use the system defaults. + * Those classes include special optimizations that can be lost + * if the implementations are decorated. + */ + @Suppress("DEPRECATION") sslSocketFactory(sslContext.socketFactory) + addInterceptor(ChangellyInterceptor()) + addInterceptor(DigitalSignatureInterceptor(userKeyPair)) + if (!BuildConfig.DEBUG) return@apply + addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + }.build() + } + + private fun getChangellyHttpClient(): OkHttpClient { + return OkHttpClient.Builder().apply { + addInterceptor(ChangellyInterceptor()) + if (!BuildConfig.DEBUG) return@apply + addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + }.build() + } + + val viperApi: ChangellyAPIService by lazy { + Retrofit.Builder() + .baseUrl(VIPER_BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .client(getViperHttpClient()) + .build() + .create(ChangellyAPIService::class.java) + } + + val changellyApi: ChangellyAPIService = + Retrofit.Builder() + .baseUrl(CHANGELLY_BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .client(getChangellyHttpClient()) + .build() + .create(ChangellyAPIService::class.java) +} diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/bch/ConfirmExchangeFragment.java b/mbw/src/main/java/com/mycelium/wallet/external/changelly/bch/ConfirmExchangeFragment.java deleted file mode 100644 index 57ca33d05b..0000000000 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/bch/ConfirmExchangeFragment.java +++ /dev/null @@ -1,439 +0,0 @@ -package com.mycelium.wallet.external.changelly.bch; - - -import android.app.DownloadManager; -import android.app.Fragment; -import android.app.ProgressDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; -import android.graphics.Paint; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import android.text.Html; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ProgressBar; -import android.widget.TextView; - -import com.megiontechnologies.BitcoinCash; -import com.mycelium.wallet.BuildConfig; -import com.mycelium.wallet.MbwManager; -import com.mycelium.wallet.R; -import com.mycelium.wallet.Utils; -import com.mycelium.wallet.activity.modern.Toaster; -import com.mycelium.wallet.activity.util.ValueExtensionsKt; -import com.mycelium.wallet.event.SpvSendFundsResult; -import com.mycelium.wallet.external.changelly.ChangellyAPIService; -import com.mycelium.wallet.external.changelly.ChangellyConstants; -import com.mycelium.wallet.external.changelly.ExchangeLoggingService; -import com.mycelium.wallet.external.changelly.model.ChangellyResponse; -import com.mycelium.wallet.external.changelly.model.ChangellyTransactionOffer; -import com.mycelium.wallet.external.changelly.model.Order; -import com.mycelium.wallet.pdf.BCHExchangeReceiptBuilder; -import com.mycelium.wapi.wallet.WalletAccount; -import com.mycelium.wapi.wallet.btc.WalletBtcAccount; -import com.mycelium.wapi.wallet.coins.Value; -import com.mycelium.wapi.wallet.currency.CurrencyValue; -import com.mycelium.wapi.wallet.currency.ExactBitcoinCashValue; -import com.squareup.otto.Subscribe; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.math.BigDecimal; -import java.net.URISyntaxException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.TimeUnit; - -import butterknife.BindView; -import butterknife.ButterKnife; -import butterknife.OnClick; -import retrofit.RetrofitError; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; - -import static android.content.Context.DOWNLOAD_SERVICE; -import static android.os.Environment.DIRECTORY_DOWNLOADS; -import static com.mycelium.wallet.external.changelly.ChangellyAPIService.BCH; -import static com.mycelium.wallet.external.changelly.ChangellyAPIService.BTC; -import static com.mycelium.wallet.external.changelly.ChangellyConstants.ABOUT; -import static com.mycelium.wallet.external.changelly.ChangellyConstants.decimalFormat; -import static com.mycelium.wallet.external.changelly.bch.ExchangeFragment.BCH_EXCHANGE; -import static com.mycelium.wallet.external.changelly.bch.ExchangeFragment.BCH_EXCHANGE_TRANSACTIONS; - -public class ConfirmExchangeFragment extends Fragment { - public static final String TAG = "BCHExchange"; - public static final int UPDATE_TIME = 60; - public static final String BLOCKTRAIL_TRANSACTION = "https://www.blocktrail.com/_network_/tx/_id_"; - private ChangellyAPIService changellyAPIService = ChangellyAPIService.getRetrofit().create(ChangellyAPIService.class); - - @BindView(R.id.fromAddress) - TextView fromAddress; - - @BindView(R.id.toAddress) - TextView toAddress; - - @BindView(R.id.fromLabel) - TextView fromLabel; - - @BindView(R.id.toLabel) - TextView toLabel; - - @BindView(R.id.fromAmount) - TextView fromAmount; - - @BindView(R.id.toAmount) - TextView toAmount; - - @BindView(R.id.toFiat) - TextView toFiat; - - @BindView(R.id.buttonContinue) - Button buttonContinue; - - @BindView(R.id.progress_bar) - ProgressBar progressBar; - - @BindView(R.id.offer_update_text) - TextView offerUpdateText; - - MbwManager mbwManager; - WalletAccount fromAccount; - WalletAccount toAccount; - Double amount; - Double sentAmount; - - private ChangellyTransactionOffer offer; - private ProgressDialog progressDialog; - - private String lastOperationId; - private String toValue; - - private AlertDialog downloadConfirmationDialog; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setRetainInstance(true); - UUID toAddress = (UUID) getArguments().getSerializable(ChangellyConstants.DESTADDRESS); - UUID fromAddress = (UUID) getArguments().getSerializable(ChangellyConstants.FROM_ADDRESS); - toValue = getArguments().getString(ChangellyConstants.TO_AMOUNT); - amount = getArguments().getDouble(ChangellyConstants.FROM_AMOUNT); - mbwManager = MbwManager.getInstance(getActivity()); - mbwManager.getEventBus().register(this); - fromAccount = mbwManager.getWalletManager(false).getAccount(fromAddress); - toAccount = mbwManager.getWalletManager(false).getAccount(toAddress); - BigDecimal txFee = UtilsKt.estimateFeeFromTransferrableAmount( - fromAccount, mbwManager, BitcoinCash.valueOf(amount).getLongValue()); - sentAmount = amount - txFee.doubleValue(); - createOffer(); - } - - @OnClick(R.id.buttonContinue) - void createAndSignTransaction() { - mbwManager.runPinProtectedFunction(getActivity(), new Runnable() { - @Override - public void run() { - buttonContinue.setEnabled(false); - long fromValue = ExactBitcoinCashValue.from(BigDecimal.valueOf(sentAmount)).getLongValue(); - - lastOperationId = UUID.randomUUID().toString(); - - String payAddress = null; - try { - payAddress = BCHBechAddress.bchBechDecode(offer.payinAddress).constructLegacyAddress(mbwManager.getNetwork()).toString(); - } catch (Exception e) { - e.printStackTrace(); - } - - /* - Intent service; - if(fromAccount instanceof Bip44BCHAccount){ - Bip44BCHAccount bip44BCHAccount = (Bip44BCHAccount) fromAccount; - if (bip44BCHAccount.getAccountType() == ACCOUNT_TYPE_FROM_MASTERSEED) { - service = IntentContract.SendFunds.createIntent(lastOperationId, bip44BCHAccount.getAccountIndex(), - payAddress, fromValue, TransactionFee.NORMAL, 1.0f); - } else { - service = IntentContract.SendFundsUnrelated.createIntent(lastOperationId, bip44BCHAccount.getId().toString(), payAddress, fromValue, TransactionFee.NORMAL, 1.0f, IntentContract.UNRELATED_ACCOUNT_TYPE_HD); - } - WalletApplication.sendToSpv(service, Bip44BCHAccount.class); - } - if(fromAccount instanceof SingleAddressBCHAccount) { - SingleAddressBCHAccount singleAddressAccount = (SingleAddressBCHAccount) fromAccount; - service = IntentContract.SendFundsUnrelated.createIntent(lastOperationId, singleAddressAccount.getId().toString(), payAddress, fromValue, TransactionFee.NORMAL, 1.0f, IntentContract.UNRELATED_ACCOUNT_TYPE_SA); - WalletApplication.sendToSpv(service, SingleAddressBCHAccount.class); - } - progressDialog = new ProgressDialog(getActivity()); - progressDialog.setIndeterminate(true); - progressDialog.setMessage(getString(R.string.sending, "...")); - progressDialog.show(); - */ - } - }); - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_exchage_confirm, container, false); - ButterKnife.bind(this, view); - updateUI(); - if (offer == null) { - progressBar.setVisibility(View.VISIBLE); - offerUpdateText.setText(R.string.updating_offer); - buttonContinue.setEnabled(false); - } - return view; - } - - @Override - public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - fromAddress.setText(((WalletBtcAccount)fromAccount).getReceivingAddress().get().getShortAddress()); - toAddress.setText((((WalletBtcAccount)toAccount)).getReceivingAddress().get().getShortAddress()); - - fromLabel.setText(mbwManager.getMetadataStorage().getLabelByAccount(fromAccount.getId())); - toLabel.setText(mbwManager.getMetadataStorage().getLabelByAccount(toAccount.getId())); - } - - @Override - public void onResume() { - super.onResume(); - getRate(); - } - - private void updateRate() { - if (offer != null) { - Value currencyValueTo = null; - try { - Value btcValue = Utils.getBtcCoinType().value(toValue); - currencyValueTo = mbwManager.getCurrencySwitcher().getAsFiatValue(btcValue); - } catch (NumberFormatException ignore) { - } - if (currencyValueTo != null) { - toFiat.setText(ABOUT + ValueExtensionsKt.toStringWithUnit(currencyValueTo)); - toFiat.setVisibility(View.VISIBLE); - } else { - toFiat.setVisibility(View.INVISIBLE); - } - } - } - - private void createOffer() { - changellyAPIService.createTransaction(BCH, BTC, sentAmount, toAccount.getReceiveAddress().toString()) - .enqueue(new GetOfferCallback()); - } - - private void getRate() { - changellyAPIService.getExchangeAmount(BCH, BTC, sentAmount).enqueue(new GetAmountCallback(sentAmount)); - } - - private void updateUI() { - if (isAdded()) { - fromAmount.setText(getString(R.string.value_currency, decimalFormat.format(amount), BCH)); - toAmount.setText(getString(R.string.value_currency, toValue, BTC)); - updateRate(); - } - } - - int autoUpdateTime; - private Runnable updateOffer = new Runnable() { - @Override - public void run() { - if (!isAdded()) { - return; - } - autoUpdateTime++; - offerUpdateText.setText(getString(R.string.offer_auto_updated, UPDATE_TIME - autoUpdateTime)); - if (autoUpdateTime < UPDATE_TIME) { - offerUpdateText.postDelayed(this, TimeUnit.SECONDS.toMillis(1)); - } else { - getRate(); - } - } - }; - - @Subscribe - public void spvSendFundsResult(SpvSendFundsResult event) { - if (progressDialog != null && progressDialog.isShowing()) { - progressDialog.dismiss(); - } - if (!event.operationId.equals(lastOperationId)) { - return; - } - - if (!event.isSuccess) { - new AlertDialog.Builder(getActivity()) - .setTitle(Html.fromHtml("" + getString(R.string.error) + "")) - .setMessage("Send funds failed: " + event.message) - .setNegativeButton(R.string.close, null) - .setOnDismissListener(new DialogInterface.OnDismissListener() { - @Override - public void onDismiss(DialogInterface dialogInterface) { - getActivity().finish(); - } - }) - .create().show(); - return; - } - final Order order = new Order(); - order.transactionId = event.txHash; - order.order_id = offer.id; - order.exchangingAmount = decimalFormat.format(amount); - order.exchangingCurrency = CurrencyValue.BCH; - - order.receivingAddress = ((WalletBtcAccount)(toAccount)).getReceivingAddress().get().toString(); - order.receivingAmount = toValue; - order.receivingCurrency = CurrencyValue.BTC; - order.timestamp = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.LONG, SimpleDateFormat.LONG, Locale.ENGLISH) - .format(new Date()); - - View view = LayoutInflater.from(getActivity()).inflate(R.layout.dialog_exchange_download_confirmation, null); - ((TextView) view.findViewById(R.id.title)).setText(R.string.success); - ((TextView) view.findViewById(R.id.date_time)).setText(getString(R.string.exchange_order_date, order.timestamp)); - ((TextView) view.findViewById(R.id.exchanging)).setText(getString(R.string.exchange_order_exchanging, order.exchangingAmount)); - ((TextView) view.findViewById(R.id.receiving)).setText(getString(R.string.exchange_order_receiving, order.receivingAmount)); - TextView transactionId = view.findViewById(R.id.transaction_id); - transactionId.setPaintFlags(transactionId.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); - transactionId.setText(order.transactionId); - transactionId.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - try { - String uri = BLOCKTRAIL_TRANSACTION - .replaceAll("_network_", (BuildConfig.FLAVOR.equals("btctestnet") ? "tBCC" : "BCC")) - .replaceAll("_id_", order.transactionId); - startActivity(Intent.parseUri(uri, Intent.URI_INTENT_SCHEME)); - } catch (URISyntaxException e) { - Log.e(TAG, "look transaction on blocktrail ", e); - } - } - }); - view.findViewById(R.id.download).setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - String filePart = new SimpleDateFormat("yyMMddHHmmss", Locale.US).format(new Date()); - File pdfFile = new File(getActivity().getExternalFilesDir(DIRECTORY_DOWNLOADS), "exchange_bch_order_" + filePart + ".pdf"); - try { - try (OutputStream pdfStream = new FileOutputStream(pdfFile)) { - new BCHExchangeReceiptBuilder() - .setTransactionId(order.transactionId) - .setDate(order.timestamp) - .setReceivingAmount(order.receivingAmount + " " + order.receivingCurrency) - .setReceivingAddress(order.receivingAddress) - .setReceivingAccountLabel(mbwManager.getMetadataStorage().getLabelByAccount(toAccount.getId())) - .setSpendingAmount(order.exchangingAmount + " " + order.exchangingCurrency) - .setSpendingAccountLabel(mbwManager.getMetadataStorage().getLabelByAccount(fromAccount.getId())) - .build(pdfStream); - } - } catch (IOException e) { - Log.e(TAG, "", e); - } - DownloadManager downloadManager = (DownloadManager) getActivity().getSystemService(DOWNLOAD_SERVICE); - downloadManager.addCompletedDownload(pdfFile.getName(), pdfFile.getName() - , true, "application/pdf" - , pdfFile.getAbsolutePath(), pdfFile.length(), true); - downloadConfirmationDialog.dismiss(); - } - }); - view.findViewById(R.id.close).setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - downloadConfirmationDialog.dismiss(); - } - }); - downloadConfirmationDialog = new AlertDialog.Builder(getActivity(), R.style.MyceliumModern_Dialog) - .setView(view) - .setOnDismissListener(new DialogInterface.OnDismissListener() { - @Override - public void onDismiss(DialogInterface dialogInterface) { - getActivity().finish(); - } - }) - .create(); - downloadConfirmationDialog.show(); - - SharedPreferences sharedPreferences = getActivity().getSharedPreferences(BCH_EXCHANGE, Context.MODE_PRIVATE); - Set exchangeTransactions = sharedPreferences.getStringSet(BCH_EXCHANGE_TRANSACTIONS, new HashSet()); - exchangeTransactions.add(order.transactionId); - sharedPreferences.edit() - .putStringSet(BCH_EXCHANGE_TRANSACTIONS, exchangeTransactions).apply(); - - try { - ExchangeLoggingService.exchangeLoggingService.saveOrder(order).enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - Log.d(TAG, "logging success "); - } - - @Override - public void onFailure(Call call, Throwable t) { - Log.d(TAG, "logging failure", t); - } - }); - } catch (RetrofitError e) { - Log.e(TAG, "Excange logging error", e); - } - } - - class GetAmountCallback implements Callback> { - double fromAmount; - - GetAmountCallback(double fromAmount) { - this.fromAmount = fromAmount; - } - - @Override - public void onResponse(@NonNull Call> call, - @NonNull Response> response) { - ChangellyResponse result = response.body(); - if(result != null) { - double amount = result.getResult(); - progressBar.setVisibility(View.INVISIBLE); - toValue = decimalFormat.format(amount); - offerUpdateText.removeCallbacks(updateOffer); - autoUpdateTime = 0; - offerUpdateText.post(updateOffer); - updateUI(); - } - } - - @Override - public void onFailure(@NonNull Call> call, - @NonNull Throwable t) { - new Toaster(getActivity()).toast("Service unavailable", true); - } - } - - class GetOfferCallback implements Callback> { - @Override - public void onResponse(@NonNull Call> call, - @NonNull Response> response) { - ChangellyResponse result = response.body(); - if(result != null) { - buttonContinue.setEnabled(true); - offer = result.getResult(); - updateUI(); - } - } - - @Override - public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - } - } -} diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/bch/ExchangeActivity.java b/mbw/src/main/java/com/mycelium/wallet/external/changelly/bch/ExchangeActivity.java deleted file mode 100644 index 58b6a47b96..0000000000 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/bch/ExchangeActivity.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.mycelium.wallet.external.changelly.bch; - - -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import android.view.View; - -import com.mycelium.wallet.R; -import com.mycelium.wallet.activity.view.ValueKeyboard; - -public class ExchangeActivity extends AppCompatActivity { - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_exchange); - setTitle(getString(R.string.excange_title)); - ActionBar bar = getSupportActionBar(); - bar.setDisplayShowHomeEnabled(true); - bar.setIcon(R.drawable.action_bar_logo); - - getWindow().setBackgroundDrawableResource(R.drawable.background_witherrors_centered); - - if (getFragmentManager().findFragmentById(R.id.fragment_container) == null) { - getFragmentManager().beginTransaction() - .add(R.id.fragment_container, new ExchangeFragment(), "ExchangeFragment") - .addToBackStack("ExchangeFragment") - .commitAllowingStateLoss(); - } - } - - @Override - public void onBackPressed() { - ValueKeyboard valueKeyboard = findViewById(R.id.numeric_keyboard); - if (valueKeyboard != null && valueKeyboard.getVisibility() == View.VISIBLE) { - valueKeyboard.done(); - } else if (getFragmentManager().getBackStackEntryCount() > 1) { - getFragmentManager().popBackStack(); - } else { - finish(); - } - } -} diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/bch/ExchangeFragment.java b/mbw/src/main/java/com/mycelium/wallet/external/changelly/bch/ExchangeFragment.java deleted file mode 100644 index 8c0eaf19f2..0000000000 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/bch/ExchangeFragment.java +++ /dev/null @@ -1,583 +0,0 @@ -package com.mycelium.wallet.external.changelly.bch; - - -import android.app.Fragment; -import android.content.Context; -import android.content.SharedPreferences; -import android.graphics.drawable.AnimationDrawable; -import android.os.Bundle; -import android.text.Editable; -import android.util.Log; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ScrollView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import com.megiontechnologies.BitcoinCash; -import com.mycelium.wallet.MbwManager; -import com.mycelium.wallet.R; -import com.mycelium.wallet.Utils; -import com.mycelium.wallet.activity.modern.Toaster; -import com.mycelium.wallet.activity.send.event.SelectListener; -import com.mycelium.wallet.activity.send.view.SelectableRecyclerView; -import com.mycelium.wallet.activity.util.ValueExtensionsKt; -import com.mycelium.wallet.activity.view.ValueKeyboard; -import com.mycelium.wallet.event.ExchangeRatesRefreshed; -import com.mycelium.wallet.external.changelly.AccountAdapter; -import com.mycelium.wallet.external.changelly.ChangellyAPIService; -import com.mycelium.wallet.external.changelly.ChangellyConstants; -import com.mycelium.wallet.external.changelly.model.ChangellyResponse; -import com.mycelium.wapi.wallet.WalletAccount; -import com.mycelium.wapi.wallet.WalletManager; -import com.mycelium.wapi.wallet.coins.Value; -import com.squareup.otto.Subscribe; - -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import butterknife.BindView; -import butterknife.ButterKnife; -import butterknife.OnClick; -import butterknife.OnTextChanged; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; - -import static butterknife.OnTextChanged.Callback.AFTER_TEXT_CHANGED; -import static com.mycelium.wallet.activity.util.WalletManagerExtensionsKt.getActiveBTCSingleAddressAccounts; -import static com.mycelium.wallet.external.changelly.ChangellyConstants.ABOUT; -import static com.mycelium.wallet.external.changelly.ChangellyConstants.decimalFormat; -import static com.mycelium.wapi.wallet.bch.bip44.Bip44BCHHDModuleKt.getBCHBip44Accounts; -import static com.mycelium.wapi.wallet.bch.single.BitcoinCashSingleAddressModuleKt.getBCHSingleAddressAccounts; -import static com.mycelium.wapi.wallet.btc.bip44.BitcoinHDModuleKt.getActiveHDAccounts; -import static com.mycelium.wapi.wallet.currency.CurrencyValue.BCH; -import static com.mycelium.wapi.wallet.currency.CurrencyValue.BTC; - -public class ExchangeFragment extends Fragment { - public static final BigDecimal MAX_BITCOIN_VALUE = BigDecimal.valueOf(20999999); - public static final String BCH_EXCHANGE = "bch_exchange"; - public static final String BCH_EXCHANGE_TRANSACTIONS = "bch_exchange_transactions"; - public static final String BCH_MIN_EXCHANGE_VALUE = "bch_min_exchange_value"; - public static final float NOT_LOADED = -1f; - public static final String TO_ACCOUNT = "toAccount"; - public static final String FROM_ACCOUNT = "fromAccount"; - public static final String FROM_VALUE = "fromValue"; - private static final String TAG = "ChangellyActivity"; - private ChangellyAPIService changellyAPIService = ChangellyAPIService.getRetrofit().create(ChangellyAPIService.class); - - @BindView(R.id.scrollView) - ScrollView scrollView; - - @BindView(R.id.from_account_list) - SelectableRecyclerView fromRecyclerView; - - @BindView(R.id.to_account_list) - SelectableRecyclerView toRecyclerView; - - @BindView(R.id.numeric_keyboard) - ValueKeyboard valueKeyboard; - - @BindView(R.id.fromValue) - TextView fromValue; - - @BindView(R.id.toValue) - TextView toValue; - - @BindView(R.id.toValueLayout) - View toLayout; - - @BindView(R.id.tvErrorFrom) - TextView tvErrorFrom; - - @BindView(R.id.tvErrorTo) - TextView tvErrorTo; - - @BindView(R.id.buttonContinue) - Button buttonContinue; - - @BindView(R.id.exchange_rate) - TextView exchangeRate; - - @BindView(R.id.exchange_fiat_rate) - TextView exchangeFiatRate; - - @BindView(R.id.use_all_funds) - View useAllFunds; - - private MbwManager mbwManager; - private AccountAdapter toAccountAdapter; - private AccountAdapter fromAccountAdapter; - - private double minAmount = NOT_LOADED; - private boolean avoidTextChangeEvent = false; - private SharedPreferences sharedPreferences; - - private double bchToBtcRate = 0; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - mbwManager = MbwManager.getInstance(getActivity()); - setRetainInstance(true); - sharedPreferences = getActivity().getSharedPreferences(BCH_EXCHANGE, Context.MODE_PRIVATE); - minAmount = (double) sharedPreferences.getFloat(BCH_MIN_EXCHANGE_VALUE, NOT_LOADED); - changellyAPIService.getMinAmount(BCH, BTC).enqueue(new GetMinCallback()); - requestExchangeRate("1"); - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_exchage, container, false); - ButterKnife.bind(this, view); - int senderFinalWidth = getActivity().getWindowManager().getDefaultDisplay().getWidth(); - int firstItemWidth = (senderFinalWidth - getResources().getDimensionPixelSize(R.dimen.item_dob_width)) / 2; - - WalletManager walletManager = mbwManager.getWalletManager(false); - List> toAccounts = new ArrayList<>(); - toAccounts.addAll(getActiveHDAccounts(walletManager)); - toAccounts.addAll(getActiveBTCSingleAddressAccounts(walletManager)); - toAccountAdapter = new AccountAdapter(mbwManager, toAccounts, firstItemWidth); - toAccountAdapter.setAccountUseType(AccountAdapter.AccountUseType.IN); - toRecyclerView.setAdapter(toAccountAdapter); - View toHeader = inflater.inflate(AccountAdapter.AccountUseType.IN.paddingLayout, toRecyclerView, false); - toHeader.setBackground(null); - toRecyclerView.setHeader(toHeader); - toRecyclerView.setFooter(toHeader); - - List> fromAccounts = new ArrayList<>(); - for (WalletAccount walletAccount : getBCHBip44Accounts(walletManager)) { - if (walletAccount.canSpend() && !walletAccount.getAccountBalance().confirmed.isZero()) { - fromAccounts.add(walletAccount); - } - } - - for (WalletAccount walletAccount : getBCHSingleAddressAccounts(walletManager)) { - if (walletAccount.canSpend() && !walletAccount.getAccountBalance().confirmed.isZero()) { - fromAccounts.add(walletAccount); - } - } - - if (fromAccounts.isEmpty()) { - toast(getString(R.string.no_spendable_accounts)); - getActivity().finish(); - } - fromAccountAdapter = new AccountAdapter(mbwManager, fromAccounts, firstItemWidth); - fromAccountAdapter.setAccountUseType(AccountAdapter.AccountUseType.OUT); - fromRecyclerView.setAdapter(fromAccountAdapter); - fromRecyclerView.setSelectedItem(mbwManager.getSelectedAccount()); - fromRecyclerView.setSelectListener(new SelectListener() { - @Override - public void onSelect(RecyclerView.Adapter adapter, int position) { - WalletAccount fromAccount = fromAccountAdapter.getItem(fromRecyclerView.getSelectedItem()).account; - valueKeyboard.setSpendableValue(getMaxSpend(fromAccount)); - isValueForOfferOk(true); - } - }); - View fromHeader = inflater.inflate(AccountAdapter.AccountUseType.OUT.paddingLayout, fromRecyclerView, false); - fromHeader.setBackground(null); - fromRecyclerView.setHeader(fromHeader); - fromRecyclerView.setFooter(fromHeader); - - valueKeyboard.setMaxDecimals(8); - valueKeyboard.setInputListener(new ValueKeyboard.SimpleInputListener() { - @Override - public void done() { - stopCursor(fromValue); - stopCursor(toValue); - useAllFunds.setVisibility(View.VISIBLE); - fromValue.setHint(R.string.zero); - toValue.setHint(R.string.zero); - isValueForOfferOk(true); - } - }); - valueKeyboard.setMaxText(getString(R.string.use_all_funds), 14); - valueKeyboard.setPasteVisibility(false); - - valueKeyboard.setVisibility(View.GONE); - buttonContinue.setEnabled(false); - return view; - } - - private void startCursor(final TextView textView) { - textView.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.input_cursor, 0); - textView.post(new Runnable() { - @Override - public void run() { - AnimationDrawable animationDrawable = (AnimationDrawable) textView.getCompoundDrawables()[2]; - if (!animationDrawable.isRunning()) { - animationDrawable.start(); - } - } - }); - } - - private void stopCursor(final TextView textView) { - textView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); - } - - @Override - public void onResume() { - super.onResume(); - MbwManager.getEventBus().register(this); - } - - @Override - public void onPause() { - MbwManager.getEventBus().unregister(this); - super.onPause(); - } - - @OnClick(R.id.buttonContinue) - void continueClick() { - String txtAmount = fromValue.getText().toString(); - double dblAmount; - try { - dblAmount = Double.parseDouble(txtAmount); - } catch (NumberFormatException e) { - toast("Error exchanging value"); - buttonContinue.setEnabled(false); - return; - } - Fragment fragment = new ConfirmExchangeFragment(); - Bundle bundle = new Bundle(); - bundle.putDouble(ChangellyConstants.FROM_AMOUNT, dblAmount); - WalletAccount toAccount = toAccountAdapter.getItem(toRecyclerView.getSelectedItem()).account; - bundle.putSerializable(ChangellyConstants.DESTADDRESS, toAccount.getId()); - WalletAccount fromAccount = fromAccountAdapter.getItem(fromRecyclerView.getSelectedItem()).account; - bundle.putSerializable(ChangellyConstants.FROM_ADDRESS, fromAccount.getId()); - bundle.putString(ChangellyConstants.TO_AMOUNT, toValue.getText().toString()); - - fragment.setArguments(bundle); - getFragmentManager().beginTransaction() - .hide(this) - .add(R.id.fragment_container, fragment, "ConfirmExchangeFragment") - .addToBackStack("ConfirmExchangeFragment") - .commitAllowingStateLoss(); - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putString(FROM_VALUE, fromValue.getText().toString()); - outState.putSerializable(FROM_ACCOUNT, fromAccountAdapter.getItem(fromRecyclerView.getSelectedItem()).account.getId()); - outState.putSerializable(TO_ACCOUNT, toAccountAdapter.getItem(toRecyclerView.getSelectedItem()).account.getId()); - } - - @Override - public void onViewStateRestored(Bundle savedInstanceState) { - super.onViewStateRestored(savedInstanceState); - if (savedInstanceState != null) { - fromValue.setText(savedInstanceState.getString(FROM_VALUE)); - fromRecyclerView.setSelectedItem(mbwManager.getWalletManager(false) - .getAccount((UUID) savedInstanceState.getSerializable(FROM_ACCOUNT))); - toRecyclerView.setSelectedItem(mbwManager.getWalletManager(false) - .getAccount((UUID) savedInstanceState.getSerializable(TO_ACCOUNT))); - requestExchangeRate(getFromExcludeFee().toPlainString()); - } - } - - @OnClick(R.id.toValueLayout) - void toValueClick() { - valueKeyboard.setInputTextView(toValue); - valueKeyboard.setVisibility(View.VISIBLE); - useAllFunds.setVisibility(View.GONE); - valueKeyboard.setEntry(toValue.getText().toString()); - toValue.setHint(""); - fromValue.setHint(R.string.zero); - startCursor(toValue); - stopCursor(fromValue); - valueKeyboard.setSpendableValue(BigDecimal.ZERO); - valueKeyboard.setMaxValue(MAX_BITCOIN_VALUE); - isValueForOfferOk(true); - scrollTo(toLayout.getBottom()); - } - - private void scrollTo(final int to) { - scrollView.post(new Runnable() { - @Override - public void run() { - scrollView.smoothScrollTo(0, to); - } - }); - } - - @OnClick(R.id.fromValueLayout) - void fromValueClick() { - valueKeyboard.setInputTextView(fromValue); - valueKeyboard.setVisibility(View.VISIBLE); - useAllFunds.setVisibility(View.GONE); - valueKeyboard.setEntry(fromValue.getText().toString()); - startCursor(fromValue); - stopCursor(toValue); - fromValue.setHint(""); - toValue.setHint(R.string.zero); - AccountAdapter.Item item = fromAccountAdapter.getItem(fromRecyclerView.getSelectedItem()); - valueKeyboard.setSpendableValue(getMaxSpend(item.account)); - valueKeyboard.setMaxValue(MAX_BITCOIN_VALUE); - isValueForOfferOk(true); - } - - @OnClick(R.id.use_all_funds) - void useAllFundsClick() { - AccountAdapter.Item item = fromAccountAdapter.getItem(fromRecyclerView.getSelectedItem()); - fromValue.setText(getMaxSpend(item.account).stripTrailingZeros().toPlainString()); - } - - //TODO call getMaxFundsTransferrable need refactoring, we should call account object - private BigDecimal getMaxSpend(WalletAccount account) { - return BigDecimal.valueOf(0); - } - - - @OnTextChanged(value = R.id.fromValue, callback = AFTER_TEXT_CHANGED) - public void afterEditTextInputFrom(Editable editable) { - isValueForOfferOk(true); - if (!avoidTextChangeEvent && !fromValue.getText().toString().isEmpty()) { - try { - requestExchangeRate(getFromExcludeFee().toPlainString()); - } catch (IllegalArgumentException e) { - Log.e(TAG, e.getMessage(), e); - } - } - if (!avoidTextChangeEvent && fromValue.getText().toString().isEmpty()) { - avoidTextChangeEvent = true; - toValue.setText(null); - avoidTextChangeEvent = false; - } - resizeTextView(fromValue); - updateUi(); - } - - private void resizeTextView(TextView textView) { - textView.setTextSize(TypedValue.COMPLEX_UNIT_SP - , textView.getText().toString().length() < 11 ? 36 : 22); - } - - private BigDecimal getFromExcludeFee() { - BigDecimal val = new BigDecimal(fromValue.getText().toString()); - if (val.compareTo(MAX_BITCOIN_VALUE) > 0) { - val = MAX_BITCOIN_VALUE; - fromValue.setText(val.toPlainString()); - } - BigDecimal txFee = UtilsKt.estimateFeeFromTransferrableAmount( - fromAccountAdapter.getItem(fromRecyclerView.getSelectedItem()).account, - mbwManager, BitcoinCash.nearestValue(val).getLongValue()); - return val.add(txFee.negate()); - } - - @OnTextChanged(value = R.id.toValue, callback = AFTER_TEXT_CHANGED) - public void afterEditTextInputTo(Editable editable) { - if (!avoidTextChangeEvent && !toValue.getText().toString().isEmpty()) { - BigDecimal val = new BigDecimal(toValue.getText().toString()); - if (val.compareTo(MAX_BITCOIN_VALUE) > 0) { - val = MAX_BITCOIN_VALUE; - toValue.setText(val.toPlainString()); - } - avoidTextChangeEvent = true; - fromValue.setText(decimalFormat.format(calculateBTCtoBHC(val.toPlainString()))); - avoidTextChangeEvent = false; - } - if (!avoidTextChangeEvent && toValue.getText().toString().isEmpty()) { - avoidTextChangeEvent = true; - fromValue.setText(null); - avoidTextChangeEvent = false; - } - resizeTextView(toValue); - updateUi(); - } - - private void requestExchangeRate(String amount) { - double dblAmount; - try { - dblAmount = Double.parseDouble(amount); - } catch (NumberFormatException e) { - new Toaster(getActivity()).toast("Error parsing double values", true); - return; - } - changellyAPIService.getExchangeAmount(BCH, BTC, dblAmount).enqueue(new GetOfferCallback(dblAmount)); - } - - private double calculateBTCtoBHC(String amount) { - double dblAmount; - try { - dblAmount = Double.parseDouble(amount); - } catch (NumberFormatException e) { - new Toaster(getActivity()).toast("Error parsing double values", true); - return 0; - } - if (bchToBtcRate == 0) { - new Toaster(getActivity()).toast("Please wait while loading exchange rate", true); - return 0; - } - return dblAmount / bchToBtcRate; - } - - boolean isValueForOfferOk(boolean checkMin) { - tvErrorFrom.setVisibility(View.INVISIBLE); - tvErrorTo.setVisibility(View.GONE); - exchangeFiatRate.setVisibility(View.VISIBLE); - String txtAmount = fromValue.getText().toString(); - if (txtAmount.isEmpty()) { - buttonContinue.setEnabled(false); - return false; - } - Double dblAmount; - try { - dblAmount = Double.parseDouble(txtAmount); - } catch (NumberFormatException e) { - toast("Error exchanging value"); - buttonContinue.setEnabled(false); - return false; - } - double dblAmountTo = 0.0; - try { - dblAmountTo = Double.parseDouble(toValue.getText().toString()); - } catch (NumberFormatException ignore) { - } - - WalletAccount fromAccount = fromAccountAdapter.getItem(fromRecyclerView.getSelectedItem()).account; - if (checkMin && minAmount == NOT_LOADED) { - buttonContinue.setEnabled(false); - toast("Please wait while loading minimum amount information."); - return false; - } else if (fromAccount.getAccountBalance().confirmed.getValueAsBigDecimal().compareTo(BigDecimal.valueOf(dblAmount)) < 0) { - buttonContinue.setEnabled(false); - TextView tvError = valueKeyboard.getVisibility() == View.VISIBLE - && valueKeyboard.getInputTextView() == toValue - ? tvErrorTo : tvErrorFrom; - tvError.setText(R.string.balance_error); - tvError.setVisibility(View.VISIBLE); - exchangeFiatRate.setVisibility(View.INVISIBLE); - return false; - } else if (checkMin && minAmount != NOT_LOADED - && dblAmount.compareTo(getMinAmountWithFee()) < 0) { - buttonContinue.setEnabled(false); - if (dblAmount != 0 || dblAmountTo != 0) { - TextView tvError = valueKeyboard.getVisibility() == View.VISIBLE - && valueKeyboard.getInputTextView() == toValue - ? tvErrorTo : tvErrorFrom; - tvError.setText(getString(R.string.exchange_minimum_amount - , decimalFormat.format(getMinAmountWithFee()), "BCH")); - tvError.setVisibility(View.VISIBLE); - - exchangeFiatRate.setVisibility(View.INVISIBLE); - } - return false; - } - buttonContinue.setEnabled(true); - return true; - } - - private Map cachedMinAmountWithFee = new HashMap<>(); - - private double getMinAmountWithFee() { - WalletAccount account = fromAccountAdapter.getItem(fromRecyclerView.getSelectedItem()).account; - Double result = cachedMinAmountWithFee.get(account); - if (result == null) { - BigDecimal txFee = UtilsKt.estimateFeeFromTransferrableAmount(account - , mbwManager, BitcoinCash.nearestValue(minAmount).getLongValue()); - result = minAmount + txFee.doubleValue(); - cachedMinAmountWithFee.put(account, result); - } - return result; - } - - private void updateUi() { - Value currencyBTCValue = null; - try { - currencyBTCValue = mbwManager.getCurrencySwitcher().getAsFiatValue( - Utils.getBtcCoinType().value(toValue.getText().toString())); - } catch (IllegalArgumentException ignore) { - } - if (currencyBTCValue != null && tvErrorTo.getVisibility() != View.VISIBLE) { - exchangeFiatRate.setText(ABOUT + ValueExtensionsKt.toStringWithUnit(currencyBTCValue)); - exchangeFiatRate.setVisibility(View.VISIBLE); - } else { - exchangeFiatRate.setVisibility(View.INVISIBLE); - } - } - - private void toast(String msg) { - new Toaster(getActivity()).toast(msg, true); - } - - @Subscribe - public void exchangeRatesRefreshed(ExchangeRatesRefreshed event) { - updateUi(); - } - - class GetMinCallback implements Callback> { - @Override - public void onResponse(@NonNull Call> call, @NonNull Response> response) { - ChangellyResponse result = response.body(); - if(result == null || result.getResult() == NOT_LOADED) { - Log.e("MyceliumChangelly", "Minimum amount could not be retrieved"); - new Toaster(getActivity()).toast("Service unavailable", false); - return; - } - double min = result.getResult(); - Log.d(TAG, "Received minimum amount: " + min); - cachedMinAmountWithFee.clear(); - sharedPreferences.edit() - .putFloat(BCH_MIN_EXCHANGE_VALUE, (float) min) - .apply(); - minAmount = min; - } - - @Override - public void onFailure(@NonNull Call> call, @NonNull Throwable t) { - toast("Service unavailable"); - } - } - - class GetOfferCallback implements Callback> { - double fromAmount; - - GetOfferCallback(double fromAmount) { - this.fromAmount = fromAmount; - } - - @Override - public void onResponse(@NonNull Call> call, - @NonNull Response> response) { - ChangellyResponse result = response.body(); - if(result != null) { - double amount = result.getResult(); - avoidTextChangeEvent = true; - try { - if (fromAmount == getFromExcludeFee().doubleValue()) { - toValue.setText(decimalFormat.format(amount)); - } - } catch (NumberFormatException ignore) { - } - if (fromAmount != 0 && amount != 0) { - bchToBtcRate = amount / fromAmount; - exchangeRate.setText("1 BCH ~ " + decimalFormat.format(bchToBtcRate) + " BTC"); - exchangeRate.setVisibility(View.VISIBLE); - } - isValueForOfferOk(true); - - avoidTextChangeEvent = false; - updateUi(); - } - } - - @Override - public void onFailure(@NonNull Call> call, - @NonNull Throwable t) { - toast("Service unavailable"); - } - } -} diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyGetExchangeAmountResponse.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyGetExchangeAmountResponse.kt new file mode 100644 index 0000000000..c5449b54de --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyGetExchangeAmountResponse.kt @@ -0,0 +1,25 @@ +package com.mycelium.wallet.external.changelly.model + +data class ChangellyGetExchangeAmountResponse( + val from: String, + val to: String, + val networkFee: String, + val amountFrom: String, + val amountTo: String, + val max: String, + val maxFrom: String, + val maxTo: String, + val min: String, + val minFrom: String, + val minTo: String, + val visibleAmount: String, + val rate: String, + val fee: String, +) { + val receiveAmount: Double + get() { + val fee = networkFee.toDoubleOrNull() ?: return .0 + val to = amountTo.toDoubleOrNull() ?: return .0 + return to - fee + } +} \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyResponse.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyResponse.kt index 138bddd8df..2b1b27802e 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyResponse.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyResponse.kt @@ -1,8 +1,15 @@ package com.mycelium.wallet.external.changelly.model -class ChangellyResponse(var result: T?, - val error: Error? = null) +class ChangellyResponse( + var result: T?, + val error: Error? = null +) + +class ChangellyListResponse( + var result: List?, + val error: Error? = null +) data class Error(val code: Int, val message: String) diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyTransaction.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyTransaction.kt index ba84489249..1385d3efc2 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyTransaction.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyTransaction.kt @@ -3,15 +3,17 @@ package com.mycelium.wallet.external.changelly.model import java.io.Serializable import java.math.BigDecimal - -class ChangellyTransaction(val id: String, - val status: String, - val moneySent: String, - val amountExpectedFrom: BigDecimal? = null, - val amountExpectedTo: BigDecimal? = null, - val currencyFrom: String, - val moneyReceived: String, - val currencyTo: String, - val trackUrl: String, - val payoutAddress:String, - val createdAt: Long) : Serializable \ No newline at end of file +class ChangellyTransaction( + val id: String, + val status: String, + val moneySent: String, + val amountExpectedFrom: BigDecimal? = null, + val amountExpectedTo: BigDecimal? = null, + val networkFee: BigDecimal? = null, + val currencyFrom: String, + val moneyReceived: String, + val currencyTo: String, + val trackUrl: String, + val payoutAddress: String, + val createdAt: Long, +) : Serializable diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyTransactionOffer.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyTransactionOffer.kt index 3a7b20809a..9a9cce4d62 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyTransactionOffer.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/ChangellyTransactionOffer.kt @@ -7,8 +7,6 @@ import java.math.BigDecimal class ChangellyTransactionOffer : Serializable { @JvmField var id: String? = null - var apiExtraFee = 0.0 - var changellyFee = 0.0 @JvmField var payinExtraId: String? = null var status: String? = null @@ -24,4 +22,10 @@ class ChangellyTransactionOffer : Serializable { var createdAt: String? = null val amountExpectedFrom:BigDecimal = BigDecimal.ZERO + val amountExpectedTo:BigDecimal = BigDecimal.ZERO + + var trackUrl: String? = null + var type: String? = null + var refundAddress: String? = null + var refundExtraId: String? = null } \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/FixRate.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/FixRate.kt index 37974edc2f..dbace016de 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/FixRate.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/FixRate.kt @@ -2,21 +2,16 @@ package com.mycelium.wallet.external.changelly.model import java.math.BigDecimal - -//"id": "f4dd43106d63b65b88955a0b362645ce960987c7ffb7a8480dd32e799431177f", -//"result": "0.02556948", -//"from": "eth", -//"to": "btc", -//"maxFrom": "50.000000000000000000", -//"maxTo": "1.27847400", -//"minFrom": "0.148414210000000000", -//"minTo": "0.00379488" - -class FixRate(val id: String, - val result: BigDecimal, - val from: String, - val to: String, - val maxFrom: BigDecimal, - val maxTo: BigDecimal, - val minFrom: BigDecimal, - val minTo: BigDecimal) \ No newline at end of file +data class FixRate( + val id: String, + val result: BigDecimal, + val from: String, + val to: String, + val maxFrom: BigDecimal, + val maxTo: BigDecimal, + val minFrom: BigDecimal, + val minTo: BigDecimal, + val amountFrom: BigDecimal?, + val amountTo: BigDecimal?, + val networkFee: BigDecimal?, +) diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/FixRateForAmount.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/FixRateForAmount.kt deleted file mode 100644 index c540550339..0000000000 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly/model/FixRateForAmount.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.mycelium.wallet.external.changelly.model - -import java.math.BigDecimal - - -data class FixRateForAmount(val id: String, - val result: BigDecimal, - val from: String, - val to: String, - val amountFrom: BigDecimal, - val amountTo: BigDecimal) - diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/ExchangeFragment.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/ExchangeFragment.kt index e821955dd0..462aefd0a0 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/ExchangeFragment.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/ExchangeFragment.kt @@ -22,7 +22,10 @@ import com.bumptech.glide.load.resource.bitmap.CircleCrop import com.bumptech.glide.request.RequestOptions import com.mrd.bitlib.model.BitcoinAddress import com.mycelium.view.RingDrawable -import com.mycelium.wallet.* +import com.mycelium.wallet.BuildConfig +import com.mycelium.wallet.MbwManager +import com.mycelium.wallet.R +import com.mycelium.wallet.Utils import com.mycelium.wallet.activity.modern.ModernMain import com.mycelium.wallet.activity.modern.event.BackHandler import com.mycelium.wallet.activity.modern.event.BackListener @@ -36,13 +39,20 @@ import com.mycelium.wallet.activity.util.toStringWithUnit import com.mycelium.wallet.activity.view.ValueKeyboard import com.mycelium.wallet.activity.view.loader import com.mycelium.wallet.databinding.FragmentChangelly2ExchangeBinding -import com.mycelium.wallet.event.* +import com.mycelium.wallet.event.ExchangeRatesRefreshed +import com.mycelium.wallet.event.ExchangeSourceChanged +import com.mycelium.wallet.event.PageSelectedEvent +import com.mycelium.wallet.event.SelectedAccountChanged +import com.mycelium.wallet.event.SelectedCurrencyChanged +import com.mycelium.wallet.event.TransactionBroadcasted import com.mycelium.wallet.external.changelly.model.ChangellyResponse import com.mycelium.wallet.external.changelly.model.ChangellyTransactionOffer -import com.mycelium.wallet.external.changelly.model.FixRate import com.mycelium.wallet.external.changelly2.remote.Changelly2Repository +import com.mycelium.wallet.external.changelly2.remote.ViperStatusException +import com.mycelium.wallet.external.changelly2.remote.ViperUnexpectedException import com.mycelium.wallet.external.changelly2.viewmodel.ExchangeViewModel import com.mycelium.wallet.external.partner.openLink +import com.mycelium.wallet.startCoroutineTimer import com.mycelium.wapi.wallet.AesKeyCipher import com.mycelium.wapi.wallet.BroadcastResultType import com.mycelium.wapi.wallet.Transaction @@ -50,7 +60,6 @@ import com.mycelium.wapi.wallet.Util import com.mycelium.wapi.wallet.btc.AbstractBtcAccount import com.mycelium.wapi.wallet.btc.BtcAddress import com.mycelium.wapi.wallet.coins.CryptoCurrency -import com.mycelium.wapi.wallet.coins.Value import com.mycelium.wapi.wallet.erc20.ERC20Account import com.mycelium.wapi.wallet.eth.EthAccount import com.mycelium.wapi.wallet.eth.EthAddress @@ -59,6 +68,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import retrofit2.HttpException import java.math.BigDecimal import java.math.RoundingMode import java.util.concurrent.TimeUnit @@ -260,51 +270,7 @@ class ExchangeFragment : Fragment(), BackListener { } } binding?.exchangeButton?.setOnClickListener { - loader(true) - Changelly2Repository.createFixTransaction(lifecycleScope, - viewModel.exchangeInfo.value?.id!!, - Util.trimTestnetSymbolDecoration(viewModel.fromCurrency.value?.symbol!!), - Util.trimTestnetSymbolDecoration(viewModel.toCurrency.value?.symbol!!), - viewModel.sellValue.value!!, - viewModel.toAddress.value!!, - viewModel.fromAddress.value!!, - { result -> - if (result?.result != null) { - viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Default) { - val unsignedTx = prepareTx( - if (BuildConfig.FLAVOR == "btctestnet") - viewModel.fromAddress.value!! - else - result.result!!.payinAddress!!, - result.result!!.amountExpectedFrom.toPlainString()) - if(unsignedTx != null) { - launch(Dispatchers.Main) { - loader(false) - acceptDialog(unsignedTx, result) { - sendTx(result.result!!.id!!, unsignedTx) - } - } - } - } - } else { - loader(false) - AlertDialog.Builder(requireContext()) - .setMessage(if (result?.error?.message?.startsWith("rateId was expired") == true) - getString(R.string.changelly_error_rate_expired) - else result?.error?.message) - .setPositiveButton(R.string.button_ok, null) - .setOnDismissListener { updateAmount() } - .show() - } - }, - { _, msg -> - loader(false) - AlertDialog.Builder(requireContext()) - .setMessage(msg) - .setPositiveButton(R.string.button_ok, null) - .setOnDismissListener { updateAmount() } - .show() - }) + createFixTransaction() } viewModel.fromCurrency.observe(viewLifecycleOwner) { coin -> binding?.sellLayout?.coinIcon?.let { @@ -356,15 +322,94 @@ class ExchangeFragment : Fragment(), BackListener { } } + private fun createFixTransaction(changellyOnly: Boolean = false){ + loader(true) + lifecycleScope.launch { + try { + val response = Changelly2Repository.createFixTransaction( + viewModel.exchangeInfo.value?.id!!, + Util.trimTestnetSymbolDecoration(viewModel.fromCurrency.value?.symbol!!), + Util.trimTestnetSymbolDecoration(viewModel.toCurrency.value?.symbol!!), + viewModel.sellValue.value!!, + viewModel.toAddress.value!!, + viewModel.fromAddress.value!!, + changellyOnly, + ) + val result = response.result + if (result != null) { + withContext(Dispatchers.Default) { + val addressTo = + if (BuildConfig.FLAVOR == "btctestnet") viewModel.fromAddress.value!! + else result.payinAddress!! + val amount = result.amountExpectedFrom.toPlainString() + val unsignedTx = prepareTx(addressTo, amount) + withContext(Dispatchers.Main) { + loader(false) + if (unsignedTx != null) { + acceptDialog(unsignedTx, response) { + sendTx(result.id!!, unsignedTx) + } + } + } + } + } else { + loader(false) + showErrorNotificationDialog(response.error?.message) + } + } catch (e: Exception) { + loader(false) + when (e) { + is HttpException -> showErrorNotificationDialog(e.message()) + is ViperStatusException -> showViperErrorDialog( + getString(R.string.vip_exchange_unexpected_alert_title), + getString(R.string.vip_exchange_status_expired_alert_message), + ) + is ViperUnexpectedException -> showViperErrorDialog( + getString(R.string.vip_exchange_unexpected_alert_title), + getString(R.string.vip_exchange_unexpected_alert_message), + ) + else -> showErrorNotificationDialog(e.message) + } + } + } + } + private fun showErrorNotificationDialog(message: String?) { + val localizedMessage = if (message?.startsWith("rateId was expired") == true) { + getString(R.string.changelly_error_rate_expired) + } else { + message ?: "Something went wrong." + } + AlertDialog.Builder(requireContext()) + .setMessage(localizedMessage) + .setPositiveButton(R.string.button_ok, null) + .setOnDismissListener { updateAmount() } + .show() + } + + private fun showViperErrorDialog(title: String, message: String) { + AlertDialog.Builder(requireContext()) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.vip_alert_proceed) { _, _ -> + updateAmount() + createFixTransaction(true) + } + .setNegativeButton(R.string.vip_alert_cancel, null) + .show() + } + private fun computeBuyValue() { val amount = viewModel.sellValue.value - viewModel.buyValue.value = if (amount?.isNotEmpty() == true - && viewModel.exchangeInfo.value?.result != null) { + val info = viewModel.exchangeInfo.value + val rate = info?.result + viewModel.buyValue.value = if (amount?.isNotEmpty() == true && rate != null) { try { - (amount.toBigDecimal() * viewModel.exchangeInfo.value?.result!!) - .setScale(viewModel.toCurrency.value?.friendlyDigits!!, RoundingMode.HALF_UP) - .stripTrailingZeros() - .toPlainString() + val result = amount.toBigDecimal() * rate + if (result <= BigDecimal.ZERO) null + else result + .setScale(viewModel.toCurrency.value?.friendlyDigits!!, RoundingMode.HALF_UP) + .stripTrailingZeros() + .toPlainString() } catch (e: NumberFormatException) { "N/A" } @@ -385,7 +430,7 @@ class ExchangeFragment : Fragment(), BackListener { result.result?.amountExpectedFrom?.stripTrailingZeros()?.toPlainString(), result.result?.currencyFrom?.toUpperCase(), unsignedTx?.totalFee()?.toStringWithUnit(), - result.result?.amountTo?.stripTrailingZeros()?.toPlainString(), + result.result?.amountExpectedTo?.stripTrailingZeros()?.toPlainString(), result.result?.currencyTo?.toUpperCase())) .setPositiveButton(R.string.button_ok) { _, _ -> viewModel.mbwManager.runPinProtectedFunction(activity) { @@ -437,8 +482,14 @@ class ExchangeFragment : Fragment(), BackListener { Util.trimTestnetSymbolDecoration(viewModel.fromCurrency.value?.symbol!!), Util.trimTestnetSymbolDecoration(viewModel.toCurrency.value?.symbol!!), { result -> - if (result?.result != null) { - viewModel.exchangeInfo.value = result.result + val data = result?.result?.firstOrNull() + if (data != null) { + val info = viewModel.exchangeInfo.value + viewModel.exchangeInfo.value = if (info == null) data else data.copy( + amountFrom = info.amountFrom, + amountTo = info.amountTo, + networkFee = info.networkFee, + ) viewModel.errorRemote.value = "" } else { viewModel.errorRemote.value = result?.error?.message ?: "" @@ -478,16 +529,13 @@ class ExchangeFragment : Fragment(), BackListener { if (fromAmount > BigDecimal.ZERO) { amountJob?.cancel() viewModel.rateLoading.value = true - amountJob = Changelly2Repository.exchangeAmount(lifecycleScope, + amountJob = Changelly2Repository.getFixRateForAmount(lifecycleScope, Util.trimTestnetSymbolDecoration(viewModel.fromCurrency.value?.symbol!!), Util.trimTestnetSymbolDecoration(viewModel.toCurrency.value?.symbol!!), fromAmount, { result -> - result?.result?.let { - val info = viewModel.exchangeInfo.value - viewModel.exchangeInfo.postValue( - FixRate(it.id, it.result, it.from, it.to, - info!!.maxFrom, info.maxTo, info.minFrom, info.minTo)) + result?.result?.firstOrNull()?.let { + viewModel.exchangeInfo.value = it viewModel.errorRemote.value = "" } ?: run { viewModel.errorRemote.value = result?.error?.message ?: "" diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/ExchangeResultFragment.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/ExchangeResultFragment.kt index 7ca6b36b3f..7583d32f13 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/ExchangeResultFragment.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/ExchangeResultFragment.kt @@ -3,7 +3,12 @@ package com.mycelium.wallet.external.changelly2 import android.app.AlertDialog import android.graphics.Color import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import androidx.core.view.forEach import androidx.fragment.app.DialogFragment import androidx.fragment.app.viewModels @@ -21,8 +26,10 @@ import com.mycelium.wallet.startCoroutineTimer import com.mycelium.wapi.wallet.AddressUtils import com.mycelium.wapi.wallet.TransactionSummary import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import java.text.DateFormat -import java.util.* +import java.util.Date +import java.util.UUID import java.util.concurrent.TimeUnit @@ -82,31 +89,31 @@ class ExchangeResultFragment : DialogFragment() { private fun update(txId: String) { loader(true) - Changelly2Repository.getTransaction(lifecycleScope, txId, - { response -> - response?.result?.first()?.let { result -> - binding?.toolbar?.title = result.getReadableStatus("exchange") - viewModel.setTransaction(result) - } ?: let { - AlertDialog.Builder(requireContext()) - .setMessage(response?.error?.message) - .setPositiveButton(R.string.button_ok) { _, _ -> - dismissAllowingStateLoss() - } - .show() - } - }, - { _, msg -> - AlertDialog.Builder(requireContext()) - .setMessage(msg) - .setPositiveButton(R.string.button_ok) { _, _ -> - dismissAllowingStateLoss() - } - .show() - }, - { - loader(false) - }) + lifecycleScope.launch { + try { + val transaction = Changelly2Repository.getTransaction(txId) + val result = transaction.result?.firstOrNull { it.id == txId } + if (transaction.error != null || result == null) { + showErrorDialog(transaction.error?.message) + return@launch + } + binding?.toolbar?.title = result.getReadableStatus("exchange") + viewModel.setTransaction(result) + } catch (e: Exception) { + showErrorDialog(e.message) + } finally { + loader(false) + } + } + } + + private fun showErrorDialog(message: String?) { + AlertDialog.Builder(requireContext()) + .setMessage(message) + .setPositiveButton(R.string.button_ok) { _, _ -> + dismissAllowingStateLoss() + } + .show() } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/HistoryFragment.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/HistoryFragment.kt index f83153d772..1ee53a4bb6 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/HistoryFragment.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/HistoryFragment.kt @@ -2,11 +2,18 @@ package com.mycelium.wallet.external.changelly2 import android.content.Context import android.os.Bundle -import android.view.* +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import android.widget.LinearLayout import androidx.core.view.forEach import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope +import com.mycelium.wallet.Constants.TAG import com.mycelium.wallet.R import com.mycelium.wallet.activity.view.DividerItemDecoration import com.mycelium.wallet.activity.view.loader @@ -16,12 +23,15 @@ import com.mycelium.wallet.external.adapter.TxItem import com.mycelium.wallet.external.changelly2.remote.Changelly2Repository import com.mycelium.wallet.external.changelly2.remote.fixedCurrencyFrom import com.mycelium.wallet.external.changelly2.remote.fixedCurrencyTo +import kotlinx.coroutines.launch import java.text.DateFormat -import java.util.* +import java.util.Date +import java.util.UUID class HistoryFragment : DialogFragment() { + private val historyDateFormat = DateFormat.getDateInstance(DateFormat.LONG) var binding: FragmentChangelly2HistoryBinding? = null val pref by lazy { requireContext().getSharedPreferences(ExchangeFragment.PREF_FILE, Context.MODE_PRIVATE) } val adapter = TxHistoryAdapter() @@ -29,7 +39,7 @@ class HistoryFragment : DialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setStyle(STYLE_NORMAL, R.style.Dialog_Changelly) - setHasOptionsMenu(true) + setHasOptionsMenu(false) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = @@ -64,27 +74,29 @@ class HistoryFragment : DialogFragment() { val txIds = (pref.getStringSet(ExchangeFragment.KEY_HISTORY, null) ?: setOf()).toList() .filterNotNull() .filterNot { it.isEmpty() } - if (txIds.isNotEmpty()) { - loader(true) - Changelly2Repository.getTransactions(lifecycleScope, txIds, - { - it?.result?.let { - adapter.submitList(it.map { - TxItem(it.id, - it.amountExpectedFrom.toString(), it.amountExpectedTo.toString(), - it.fixedCurrencyFrom(), it.fixedCurrencyTo(), - DateFormat.getDateInstance(DateFormat.LONG).format(Date(it.createdAt * 1000L)), - it.getReadableStatus()) - }) - } - }, - { _, _ -> + if (txIds.isEmpty()) return + loader(true) + lifecycleScope.launch { + try { + val result = Changelly2Repository.getTransactions(txIds).result ?: return@launch + adapter.submitList(result.map { + TxItem( + it.id, + it.amountExpectedFrom.toString(), + it.amountExpectedTo.toString(), + it.fixedCurrencyFrom(), + it.fixedCurrencyTo(), + historyDateFormat.format(Date(it.createdAt * 1000L)), + it.getReadableStatus() + ) + }) - }, - { - updateEmpty() - loader(false) - }) + } catch (e: Exception) { + Log.e(TAG, "${e.message}"); + } finally { + updateEmpty() + loader(false) + } } } diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/remote/Api.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/remote/Api.kt new file mode 100644 index 0000000000..b3d3e75ba5 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/remote/Api.kt @@ -0,0 +1,6 @@ +package com.mycelium.wallet.external.changelly2.remote + + +object Api { + val statusRepository by lazy { StatusRepository() } +} \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/remote/Changelly2Repository.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/remote/Changelly2Repository.kt index 489fc459e1..a591fbda90 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/remote/Changelly2Repository.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/remote/Changelly2Repository.kt @@ -1,99 +1,157 @@ package com.mycelium.wallet.external.changelly2.remote -import androidx.lifecycle.LifecycleCoroutineScope import com.mycelium.bequant.remote.doRequest -import com.mycelium.wallet.external.changelly.ChangellyAPIService -import com.mycelium.wallet.external.changelly.model.* +import com.mycelium.wallet.external.changelly.ChangellyRetrofitFactory +import com.mycelium.wallet.external.changelly.model.ChangellyCurrency +import com.mycelium.wallet.external.changelly.model.ChangellyListResponse +import com.mycelium.wallet.external.changelly.model.ChangellyResponse +import com.mycelium.wallet.external.changelly.model.ChangellyTransaction +import com.mycelium.wallet.external.changelly.model.ChangellyTransactionOffer +import com.mycelium.wallet.external.changelly.model.Error +import com.mycelium.wallet.external.changelly.model.FixRate import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import retrofit2.HttpException import java.math.BigDecimal object Changelly2Repository { - private val api = ChangellyAPIService.retrofit.create(ChangellyAPIService::class.java) + private val userRepository by lazy { Api.statusRepository } + private val viperApi by lazy { ChangellyRetrofitFactory.viperApi } + private val changellyApi = ChangellyRetrofitFactory.changellyApi - fun supportCurrencies(scope: CoroutineScope, - success: (ChangellyResponse>?) -> Unit, - error: ((Int, String) -> Unit)? = null, - finally: (() -> Unit)? = null) { + fun supportCurrenciesFull( + scope: CoroutineScope, + success: (ChangellyResponse>?) -> Unit, + error: ((Int, String) -> Unit)? = null, + finally: (() -> Unit)? = null + ) { doRequest(scope, { - api.currencies() + changellyApi.getCurrenciesFull() }, success, error, finally) } - fun supportCurrenciesFull(scope: CoroutineScope, - success: (ChangellyResponse>?) -> Unit, - error: ((Int, String) -> Unit)? = null, - finally: (() -> Unit)? = null) { + fun getFixRateForAmount( + scope: CoroutineScope, + from: String, + to: String, + amount: BigDecimal, + success: (ChangellyListResponse?) -> Unit, + error: (Int, String) -> Unit, + finally: (() -> Unit)? = null + ) = doRequest(scope, { - api.currenciesFull() + val isVip = userRepository.statusFlow.value.isVIP() + val api = if (isVip) viperApi else changellyApi + api.getFixRateForAmount(exportSymbol(from), exportSymbol(to), amount) }, success, error, finally) - } - - fun exchangeAmount(scope: CoroutineScope, - from: String, - to: String, - amount: BigDecimal, - success: (ChangellyResponse?) -> Unit, - error: (Int, String) -> Unit, - finally: (() -> Unit)? = null) = - doRequest(scope, { - api.exchangeAmountFix(exportSymbol(from), exportSymbol(to), amount) - }, success, error, finally) - - fun fixRate(scope: CoroutineScope, - from: String, - to: String, - success: (ChangellyResponse?) -> Unit, - error: (Int, String) -> Unit, - finally: (() -> Unit)? = null) = - doRequest(scope, { - api.fixRate(exportSymbol(from), exportSymbol(to)) - }, success, error, finally) - fun createFixTransaction(scope: CoroutineScope, - rateId: String, - from: String, - to: String, - amount: String, - addressTo: String, - refundAddress: String, - success: (ChangellyResponse?) -> Unit, - error: (Int, String) -> Unit, - finally: (() -> Unit)? = null) { + fun fixRate( + scope: CoroutineScope, + from: String, + to: String, + success: (ChangellyListResponse?) -> Unit, + error: (Int, String) -> Unit, + finally: (() -> Unit)? = null + ) = doRequest(scope, { - api.createFixTransaction(exportSymbol(from), exportSymbol(to), amount, addressTo, rateId, refundAddress) + changellyApi.getFixRate(exportSymbol(from), exportSymbol(to)) }, success, error, finally) + + suspend fun createFixTransaction( + rateId: String, + from: String, + to: String, + amount: String, + addressTo: String, + refundAddress: String, + changellyOnly: Boolean, + ): ChangellyResponse { + val isVip = userRepository.statusFlow.value.isVIP() + val fromSymbol = exportSymbol(from) + val toSymbol = exportSymbol(to) + if (!isVip || changellyOnly) { + return changellyApi.createFixTransaction( + fromSymbol, + toSymbol, + amount, + addressTo, + rateId, + refundAddress, + ) + } + try { + return viperApi.createFixTransaction( + fromSymbol, + toSymbol, + amount, + addressTo, + rateId, + refundAddress, + ) + } catch (e: Exception) { + // Http exception with 401 unauthorized code means that user isn't vip anymore + if (e is HttpException && e.code() == 401) { + userRepository.dropStatus() + throw ViperStatusException(e) + } + throw ViperUnexpectedException(e) + } } - fun getTransaction(scope: CoroutineScope, - id: String, - success: (ChangellyResponse>?) -> Unit, - error: (Int, String) -> Unit, - finally: (() -> Unit)? = null) { - doRequest(scope, { - api.getTransaction(id) - }, success, error, finally) + suspend fun getTransaction(id: String): ChangellyResponse> { + val isVip = userRepository.statusFlow.value.isVIP() + val changellyTransactions = changellyApi.getTransaction(id) + if (!isVip) return changellyTransactions + if (changellyTransactions.result?.any { it.id == id } == true) return changellyTransactions + return try { + viperApi.getTransaction(id) + } catch (e: HttpException) { + ChangellyResponse(null, Error(e.code(), e.message())) + } catch (e: Exception) { + ChangellyResponse(null, Error(500, e.message ?: "")) + } } - fun getTransactions(scope: LifecycleCoroutineScope, ids: List, - success: (ChangellyResponse>?) -> Unit, - error: (Int, String) -> Unit, - finally: (() -> Unit)? = null) { - doRequest(scope, { - api.getTransactions(ids) - }, success, error, finally) + suspend fun getTransactions(ids: List): ChangellyResponse> { + val isVip = userRepository.statusFlow.value.isVIP() + if (!isVip) return changellyApi.getTransactions(ids) + val changellyTransactionsDeferred = withContext(Dispatchers.IO) { + async { changellyApi.getTransactions(ids) } + } + val viperTransactionsDeferred = withContext(Dispatchers.IO) { + async { + try { + viperApi.getTransactions(ids) + } catch (e: HttpException) { + ChangellyResponse(null, Error(e.code(), e.message())) + } catch (e: Exception) { + ChangellyResponse(null, Error(500, e.message ?: "")) + } + } + } + val changellyTransactions = changellyTransactionsDeferred.await() + val viperTransactions = viperTransactionsDeferred.await() + val changellyResult = changellyTransactions.result ?: emptyList() + val viperResult = viperTransactions.result ?: emptyList() + return ChangellyResponse(changellyResult + viperResult) } } fun ChangellyTransaction.fixedCurrencyFrom() = - importSymbol(currencyFrom) + importSymbol(currencyFrom) fun ChangellyTransaction.fixedCurrencyTo() = - importSymbol(currencyTo) + importSymbol(currencyTo) private fun importSymbol(currency: String) = - if (currency.equals("USDT20", true)) "USDT" - else currency + if (currency.equals("USDT20", true)) "USDT" + else currency private fun exportSymbol(currency: String) = - if (currency.equals("USDT", true)) "USDT20" - else currency \ No newline at end of file + if (currency.equals("USDT", true)) "USDT20".toLowerCase() + else currency.toLowerCase() + +class ViperUnexpectedException(e: Exception) : Exception(e) +class ViperStatusException(e: Exception) : Exception(e) \ No newline at end of file diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/remote/StatusRepository.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/remote/StatusRepository.kt new file mode 100644 index 0000000000..3284132dbe --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/remote/StatusRepository.kt @@ -0,0 +1,61 @@ +package com.mycelium.wallet.external.changelly2.remote + +import android.app.Activity +import androidx.core.content.edit +import com.mycelium.bequant.remote.model.UserStatus +import com.mycelium.wallet.WalletApplication +import com.mycelium.wallet.external.vip.VipRetrofitFactory +import com.mycelium.wallet.external.vip.model.ActivateVipRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class StatusRepository { + private val vipApi by lazy { VipRetrofitFactory().createApi() } + private val preference = WalletApplication.getInstance() + .getSharedPreferences(PREFERENCES_VIP_FILE, Activity.MODE_PRIVATE) + + private fun getLocalStatus() = UserStatus.fromName(preference.getString(VIP_STATUS_KEY, null)) + + private val _statusFlow = MutableStateFlow(UserStatus.REGULAR) + val statusFlow = _statusFlow.asStateFlow() + + init { + val localStatus = getLocalStatus() + if (localStatus != null) { + _statusFlow.value = localStatus + } else { + GlobalScope.launch(Dispatchers.IO) { + try { + val checkResult = vipApi.check() + // if user is VIP than response contains his code else response contains empty string + val isVIP = checkResult.vipCode.isNotEmpty() + val status = if (isVIP) UserStatus.VIP else UserStatus.REGULAR + preference.edit { putString(VIP_STATUS_KEY, status.name) } + _statusFlow.value = status + } catch (_: Exception) { + } + } + } + } + + suspend fun applyVIPCode(code: String): UserStatus { + val response = vipApi.activate(ActivateVipRequest(code)) + val status = if (response.done) UserStatus.VIP else UserStatus.REGULAR + _statusFlow.value = status + preference.edit { putString(VIP_STATUS_KEY, status.name) } + return status + } + + fun dropStatus() { + preference.edit { putString(VIP_STATUS_KEY, UserStatus.REGULAR.name) } + _statusFlow.value = UserStatus.REGULAR + } + + private companion object { + const val PREFERENCES_VIP_FILE = "VIP_PREFERENCES" + const val VIP_STATUS_KEY = "VIP_STATUS" + } +} diff --git a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/viewmodel/ExchangeViewModel.kt b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/viewmodel/ExchangeViewModel.kt index dd6e992465..447317aad2 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/changelly2/viewmodel/ExchangeViewModel.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/changelly2/viewmodel/ExchangeViewModel.kt @@ -58,7 +58,7 @@ class ExchangeViewModel(application: Application) : AndroidViewModel(application } } } - val swapEnableDelay = MutableLiveData(false) + val swapEnableDelay = MutableLiveData(false) val swapEnabled = MediatorLiveData().apply { value = false fun update() { diff --git a/mbw/src/main/java/com/mycelium/wallet/external/partner/PartnerExt.kt b/mbw/src/main/java/com/mycelium/wallet/external/partner/PartnerExt.kt index c7bcc4a48c..84dcfba1cc 100644 --- a/mbw/src/main/java/com/mycelium/wallet/external/partner/PartnerExt.kt +++ b/mbw/src/main/java/com/mycelium/wallet/external/partner/PartnerExt.kt @@ -6,32 +6,49 @@ import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP import android.net.Uri import android.os.Bundle +import androidx.browser.customtabs.CustomTabsIntent import androidx.fragment.app.Fragment import com.mycelium.wallet.WalletApplication import com.mycelium.wallet.activity.modern.Toaster + fun Fragment.startContentLink(link: String?, data: Bundle? = null) { startContentLink(link) { - data?.apply { it.putExtras(data) } - startActivity(it) + data?.apply { it?.putExtras(data) } + if (it != null) { + startActivity(it) + } else { + CustomTabsIntent.Builder().build() + .launchUrl(this.requireContext(), Uri.parse(link)) + } } } fun Activity.startContentLink(link: String?, data: Bundle? = null) { startContentLink(link) { - data?.apply { it.putExtras(data) } - startActivity(it) + data?.apply { it?.putExtras(data) } + if (it != null) { + startActivity(it) + } else { + CustomTabsIntent.Builder().build() + .launchUrl(this, Uri.parse(link)) + } } } fun Context.startContentLink(link: String?, data: Bundle? = null) { startContentLink(link) { - data?.apply { it.putExtras(data) } - startActivity(it) + data?.apply { it?.putExtras(data) } + if (it != null) { + startActivity(it) + } else { + CustomTabsIntent.Builder().build() + .launchUrl(this, Uri.parse(link)) + } } } -private fun startContentLink(link: String?, startAction: (Intent) -> Unit) { +private fun startContentLink(link: String?, startAction: (Intent?) -> Unit) { if (link != null) { try { if (link.startsWith("mycelium://action.")) { @@ -39,7 +56,8 @@ private fun startContentLink(link: String?, startAction: (Intent) -> Unit) { setPackage(WalletApplication.getInstance().packageName) }.addFlags(FLAG_ACTIVITY_SINGLE_TOP)) } else { - startAction(Intent(Intent.ACTION_VIEW, Uri.parse(link))) + startAction(null) +// startAction(Intent(Intent.ACTION_VIEW, Uri.parse(link))) } } catch (ignored: Exception) { } diff --git a/mbw/src/main/java/com/mycelium/wallet/external/vip/VipAPI.kt b/mbw/src/main/java/com/mycelium/wallet/external/vip/VipAPI.kt new file mode 100644 index 0000000000..8ad176fed6 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/vip/VipAPI.kt @@ -0,0 +1,21 @@ +package com.mycelium.wallet.external.vip + +import com.mycelium.wallet.external.DefaultJsonRpcRequest +import com.mycelium.wallet.external.vip.model.ActivateVipRequest +import com.mycelium.wallet.external.vip.model.ActivateVipResponse +import com.mycelium.wallet.external.vip.model.CheckVipResponse +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Interface to describing VIP mycelium API for retrofit2 library and providing retrofit object intialization. + */ +interface VipAPI { + @POST("activate") + suspend fun activate(@Body body: ActivateVipRequest): ActivateVipResponse + + @POST("check") + suspend fun check( + @Body body: DefaultJsonRpcRequest = DefaultJsonRpcRequest(), + ): CheckVipResponse +} diff --git a/mbw/src/main/java/com/mycelium/wallet/external/vip/VipRetrofitFactory.kt b/mbw/src/main/java/com/mycelium/wallet/external/vip/VipRetrofitFactory.kt new file mode 100644 index 0000000000..34577c1313 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/vip/VipRetrofitFactory.kt @@ -0,0 +1,47 @@ +package com.mycelium.wallet.external.vip + +import com.mycelium.wallet.BuildConfig +import com.mycelium.wallet.UserKeysManager +import com.mycelium.wallet.external.DigitalSignatureInterceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext + +class VipRetrofitFactory { + private companion object { + const val BASE_URL = "https://changelly-viper.mycelium.com" + } + + private val userKeyPair = UserKeysManager.userSignKeys + + private fun getHttpClient(): OkHttpClient { + val sslContext = SSLContext.getInstance("TLSv1.3") + sslContext.init(null, null, null) + return OkHttpClient.Builder() + .apply { + connectTimeout(3, TimeUnit.SECONDS) + // sslSocketFactory uses system defaults X509TrustManager, so deprecation suppressed + // referring to sslSocketFactory(SSLSocketFactory, X509TrustManager) docs: + /** + * Most applications should not call this method, and instead use the system defaults. + * Those classes include special optimizations that can be lost + * if the implementations are decorated. + */ + @Suppress("DEPRECATION") sslSocketFactory(sslContext.socketFactory) + addInterceptor(DigitalSignatureInterceptor(userKeyPair)) + if (!BuildConfig.DEBUG) return@apply + addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + }.build() + } + + private val retrofit: Retrofit = Retrofit.Builder() + .baseUrl("$BASE_URL/api/v1/vip-codes/") + .addConverterFactory(GsonConverterFactory.create()) + .client(getHttpClient()) + .build() + + fun createApi(): VipAPI = retrofit.create(VipAPI::class.java) +} diff --git a/mbw/src/main/java/com/mycelium/wallet/external/vip/model/ActivateVipRequest.kt b/mbw/src/main/java/com/mycelium/wallet/external/vip/model/ActivateVipRequest.kt new file mode 100644 index 0000000000..55b1a36694 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/vip/model/ActivateVipRequest.kt @@ -0,0 +1,8 @@ +package com.mycelium.wallet.external.vip.model + +import com.google.gson.annotations.SerializedName + +data class ActivateVipRequest( + @field:SerializedName("vip_code") + val vipCode: String +) diff --git a/mbw/src/main/java/com/mycelium/wallet/external/vip/model/ActivateVipResponse.kt b/mbw/src/main/java/com/mycelium/wallet/external/vip/model/ActivateVipResponse.kt new file mode 100644 index 0000000000..43334ee467 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/vip/model/ActivateVipResponse.kt @@ -0,0 +1,5 @@ +package com.mycelium.wallet.external.vip.model + +data class ActivateVipResponse( + val done: Boolean +) diff --git a/mbw/src/main/java/com/mycelium/wallet/external/vip/model/CheckVipResponse.kt b/mbw/src/main/java/com/mycelium/wallet/external/vip/model/CheckVipResponse.kt new file mode 100644 index 0000000000..fa2b3a2d99 --- /dev/null +++ b/mbw/src/main/java/com/mycelium/wallet/external/vip/model/CheckVipResponse.kt @@ -0,0 +1,8 @@ +package com.mycelium.wallet.external.vip.model + +import com.google.gson.annotations.SerializedName + +data class CheckVipResponse( + @field:SerializedName("vip_code") + val vipCode: String +) diff --git a/mbw/src/main/res/drawable/action_bar_logo_vip.xml b/mbw/src/main/res/drawable/action_bar_logo_vip.xml new file mode 100644 index 0000000000..2e72232251 --- /dev/null +++ b/mbw/src/main/res/drawable/action_bar_logo_vip.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mbw/src/main/res/drawable/bg_input_text_filled.xml b/mbw/src/main/res/drawable/bg_input_text_filled.xml new file mode 100644 index 0000000000..515d1be21a --- /dev/null +++ b/mbw/src/main/res/drawable/bg_input_text_filled.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mbw/src/main/res/drawable/bg_input_text_filled_error.xml b/mbw/src/main/res/drawable/bg_input_text_filled_error.xml new file mode 100644 index 0000000000..8b163767e6 --- /dev/null +++ b/mbw/src/main/res/drawable/bg_input_text_filled_error.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/mbw/src/main/res/drawable/bg_send_coin_batch.xml b/mbw/src/main/res/drawable/bg_send_coin_batch.xml new file mode 100644 index 0000000000..b39d4f8c27 --- /dev/null +++ b/mbw/src/main/res/drawable/bg_send_coin_batch.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mbw/src/main/res/drawable/bg_vip_icon.xml b/mbw/src/main/res/drawable/bg_vip_icon.xml new file mode 100644 index 0000000000..30d0e18a94 --- /dev/null +++ b/mbw/src/main/res/drawable/bg_vip_icon.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mbw/src/main/res/drawable/bg_vip_input_text_filled.xml b/mbw/src/main/res/drawable/bg_vip_input_text_filled.xml new file mode 100644 index 0000000000..00b8e74faa --- /dev/null +++ b/mbw/src/main/res/drawable/bg_vip_input_text_filled.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mbw/src/main/res/drawable/ic_clipboard_outline.xml b/mbw/src/main/res/drawable/ic_clipboard_outline.xml new file mode 100644 index 0000000000..972d2cca93 --- /dev/null +++ b/mbw/src/main/res/drawable/ic_clipboard_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/mbw/src/main/res/drawable/ic_contacts.xml b/mbw/src/main/res/drawable/ic_contacts.xml new file mode 100644 index 0000000000..234c4d5962 --- /dev/null +++ b/mbw/src/main/res/drawable/ic_contacts.xml @@ -0,0 +1,9 @@ + + + diff --git a/mbw/src/main/res/drawable/ic_plus_nofill.xml b/mbw/src/main/res/drawable/ic_plus_nofill.xml new file mode 100644 index 0000000000..cae16e7247 --- /dev/null +++ b/mbw/src/main/res/drawable/ic_plus_nofill.xml @@ -0,0 +1,9 @@ + + + diff --git a/mbw/src/main/res/drawable/ic_qrcode_scan.xml b/mbw/src/main/res/drawable/ic_qrcode_scan.xml new file mode 100644 index 0000000000..74ccc59603 --- /dev/null +++ b/mbw/src/main/res/drawable/ic_qrcode_scan.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/mbw/src/main/res/drawable/ic_vip_discount.xml b/mbw/src/main/res/drawable/ic_vip_discount.xml new file mode 100644 index 0000000000..78f956af98 --- /dev/null +++ b/mbw/src/main/res/drawable/ic_vip_discount.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mbw/src/main/res/drawable/ic_vip_icon.xml b/mbw/src/main/res/drawable/ic_vip_icon.xml new file mode 100644 index 0000000000..1f0214b4f9 --- /dev/null +++ b/mbw/src/main/res/drawable/ic_vip_icon.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mbw/src/main/res/drawable/ic_vip_limits.xml b/mbw/src/main/res/drawable/ic_vip_limits.xml new file mode 100644 index 0000000000..8903b131c8 --- /dev/null +++ b/mbw/src/main/res/drawable/ic_vip_limits.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + diff --git a/mbw/src/main/res/drawable/ic_vip_menu_text.xml b/mbw/src/main/res/drawable/ic_vip_menu_text.xml new file mode 100644 index 0000000000..f2dff52247 --- /dev/null +++ b/mbw/src/main/res/drawable/ic_vip_menu_text.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + diff --git a/mbw/src/main/res/layout/activity_message_verify.xml b/mbw/src/main/res/layout/activity_message_verify.xml index ea74947980..19a8f7d579 100644 --- a/mbw/src/main/res/layout/activity_message_verify.xml +++ b/mbw/src/main/res/layout/activity_message_verify.xml @@ -12,6 +12,7 @@ android:visibility="invisible"/> + + + + + + + + + + + + + + + + +