From 9e302df75d12fc3a8263171c2bfd6cf8c9ab6533 Mon Sep 17 00:00:00 2001 From: Marcos Rodriguez Date: Tue, 31 Dec 2019 21:31:04 -0600 Subject: [PATCH] ADD: Export/Import PSBTs eipiji --- App.js | 252 ++---------------- BlueComponents.js | 13 +- MainBottomTabs.js | 6 + android/app/src/main/AndroidManifest.xml | 23 +- class/abstract-hd-electrum-wallet.js | 25 +- class/app-storage.js | 1 - class/deeplinkSchemaMatch.js | 221 +++++++++++++++ class/walletImport.js | 20 +- class/watch-only-wallet.js | 3 +- ios/BlueWallet.xcodeproj/project.pbxproj | 4 +- ios/BlueWallet/BlueWalletRelease.entitlements | 18 ++ ios/BlueWallet/Info.plist | 75 +++++- package.json | 3 + screen/lnd/scanLndInvoice.js | 13 +- screen/send/details.js | 45 +++- screen/send/psbtWithHardwareWallet.js | 124 ++++++++- screen/send/scanQrAddress.js | 161 +++++++---- screen/wallets/details.js | 56 +++- screen/wallets/import.js | 10 +- screen/wallets/list.js | 210 ++++++++++----- screen/wallets/transactions.js | 52 +++- tests/integration/deepLinkSchemaMatch.test.js | 50 ++++ 22 files changed, 971 insertions(+), 414 deletions(-) create mode 100644 class/deeplinkSchemaMatch.js create mode 100644 ios/BlueWallet/BlueWalletRelease.entitlements create mode 100644 tests/integration/deepLinkSchemaMatch.test.js diff --git a/App.js b/App.js index 4530804c8..7777220bc 100644 --- a/App.js +++ b/App.js @@ -1,18 +1,17 @@ import React from 'react'; import { Linking, DeviceEventEmitter, AppState, Clipboard, StyleSheet, KeyboardAvoidingView, Platform, View } from 'react-native'; -import AsyncStorage from '@react-native-community/async-storage'; import Modal from 'react-native-modal'; import { NavigationActions } from 'react-navigation'; import MainBottomTabs from './MainBottomTabs'; import NavigationService from './NavigationService'; import { BlueTextCentered, BlueButton } from './BlueComponents'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; -import url from 'url'; -import { AppStorage, LightningCustodianWallet } from './class'; import { Chain } from './models/bitcoinUnits'; import QuickActions from 'react-native-quick-actions'; import * as Sentry from '@sentry/react-native'; import OnAppLaunch from './class/onAppLaunch'; +import DeeplinkSchemaMatch from './class/deeplinkSchemaMatch'; +import BitcoinBIP70TransactionDecode from './bip70/bip70'; const A = require('./analytics'); if (process.env.NODE_ENV !== 'development') { @@ -21,11 +20,9 @@ if (process.env.NODE_ENV !== 'development') { }); } -const bitcoin = require('bitcoinjs-lib'); const bitcoinModalString = 'Bitcoin address'; const lightningModalString = 'Lightning Invoice'; const loc = require('./loc'); -/** @type {AppStorage} */ const BlueApp = require('./BlueApp'); export default class App extends React.Component { @@ -62,7 +59,7 @@ export default class App extends React.Component { } else { const url = await Linking.getInitialURL(); if (url) { - if (this.hasSchema(url)) { + if (DeeplinkSchemaMatch.hasSchema(url)) { this.handleOpenURL({ url }); } } else { @@ -116,12 +113,23 @@ export default class App extends React.Component { const isAddressFromStoredWallet = BlueApp.getWallets().some(wallet => wallet.chain === Chain.ONCHAIN ? wallet.weOwnAddress(clipboard) : wallet.isInvoiceGeneratedByWallet(clipboard), ); + const isBitcoinAddress = + DeeplinkSchemaMatch.isBitcoinAddress(clipboard) || BitcoinBIP70TransactionDecode.matchesPaymentURL(clipboard); + const isLightningInvoice = DeeplinkSchemaMatch.isLightningInvoice(clipboard); + const isLNURL = DeeplinkSchemaMatch.isLnUrl(clipboard); + const isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(clipboard); if ( - (!isAddressFromStoredWallet && - this.state.clipboardContent !== clipboard && - (this.isBitcoinAddress(clipboard) || this.isLightningInvoice(clipboard) || this.isLnUrl(clipboard))) || - this.isBothBitcoinAndLightning(clipboard) + !isAddressFromStoredWallet && + this.state.clipboardContent !== clipboard && + (isBitcoinAddress || isLightningInvoice || isLNURL || isBothBitcoinAndLightning) ) { + if (isBitcoinAddress) { + this.setState({ clipboardContentModalAddressType: bitcoinModalString }); + } else if (isLightningInvoice || isLNURL) { + this.setState({ clipboardContentModalAddressType: lightningModalString }); + } else if (isBothBitcoinAndLightning) { + this.setState({ clipboardContentModalAddressType: bitcoinModalString }); + } this.setState({ isClipboardContentModalVisible: true }); } this.setState({ clipboardContent: clipboard }); @@ -130,94 +138,6 @@ export default class App extends React.Component { } }; - hasSchema(schemaString) { - if (typeof schemaString !== 'string' || schemaString.length <= 0) return false; - const lowercaseString = schemaString.trim().toLowerCase(); - return ( - lowercaseString.startsWith('bitcoin:') || - lowercaseString.startsWith('lightning:') || - lowercaseString.startsWith('blue:') || - lowercaseString.startsWith('bluewallet:') || - lowercaseString.startsWith('lapp:') - ); - } - - isBitcoinAddress(address) { - address = address - .replace('bitcoin:', '') - .replace('bitcoin=', '') - .split('?')[0]; - let isValidBitcoinAddress = false; - try { - bitcoin.address.toOutputScript(address); - isValidBitcoinAddress = true; - this.setState({ clipboardContentModalAddressType: bitcoinModalString }); - } catch (err) { - isValidBitcoinAddress = false; - } - return isValidBitcoinAddress; - } - - isLightningInvoice(invoice) { - let isValidLightningInvoice = false; - if (invoice.toLowerCase().startsWith('lightning:lnb') || invoice.toLowerCase().startsWith('lnb')) { - this.setState({ clipboardContentModalAddressType: lightningModalString }); - isValidLightningInvoice = true; - } - return isValidLightningInvoice; - } - - isLnUrl(text) { - if (text.toLowerCase().startsWith('lightning:lnurl') || text.toLowerCase().startsWith('lnurl')) { - return true; - } - return false; - } - - isBothBitcoinAndLightning(url) { - if (url.includes('lightning') && url.includes('bitcoin')) { - const txInfo = url.split(/(bitcoin:|lightning:|lightning=|bitcoin=)+/); - let bitcoin; - let lndInvoice; - for (const [index, value] of txInfo.entries()) { - try { - // Inside try-catch. We dont wan't to crash in case of an out-of-bounds error. - if (value.startsWith('bitcoin')) { - bitcoin = `bitcoin:${txInfo[index + 1]}`; - if (!this.isBitcoinAddress(bitcoin)) { - bitcoin = false; - break; - } - } else if (value.startsWith('lightning')) { - lndInvoice = `lightning:${txInfo[index + 1]}`; - if (!this.isLightningInvoice(lndInvoice)) { - lndInvoice = false; - break; - } - } - } catch (e) { - console.log(e); - } - if (bitcoin && lndInvoice) break; - } - if (bitcoin && lndInvoice) { - this.setState({ - clipboardContent: { bitcoin, lndInvoice }, - }); - return { bitcoin, lndInvoice }; - } else { - return undefined; - } - } - return undefined; - } - - isSafelloRedirect(event) { - let urlObject = url.parse(event.url, true) // eslint-disable-line - - return !!urlObject.query['safello-state-token']; - } - isBothBitcoinAndLightningWalletSelect = wallet => { const clipboardContent = this.state.clipboardContent; if (wallet.chain === Chain.ONCHAIN) { @@ -246,141 +166,7 @@ export default class App extends React.Component { }; handleOpenURL = event => { - if (event.url === null) { - return; - } - if (typeof event.url !== 'string') { - return; - } - let isBothBitcoinAndLightning; - try { - isBothBitcoinAndLightning = this.isBothBitcoinAndLightning(event.url); - } catch (e) { - console.log(e); - } - if (isBothBitcoinAndLightning) { - this.navigator && - this.navigator.dispatch( - NavigationActions.navigate({ - routeName: 'HandleOffchainAndOnChain', - params: { - onWalletSelect: this.isBothBitcoinAndLightningWalletSelect, - }, - }), - ); - } else if (this.isBitcoinAddress(event.url)) { - this.navigator && - this.navigator.dispatch( - NavigationActions.navigate({ - routeName: 'SendDetails', - params: { - uri: event.url, - }, - }), - ); - } else if (this.isLightningInvoice(event.url)) { - this.navigator && - this.navigator.dispatch( - NavigationActions.navigate({ - routeName: 'ScanLndInvoice', - params: { - uri: event.url, - }, - }), - ); - } else if (this.isLnUrl(event.url)) { - this.navigator && - this.navigator.dispatch( - NavigationActions.navigate({ - routeName: 'LNDCreateInvoice', - params: { - uri: event.url, - }, - }), - ); - } else if (this.isSafelloRedirect(event)) { - let urlObject = url.parse(event.url, true) // eslint-disable-line - - const safelloStateToken = urlObject.query['safello-state-token']; - - this.navigator && - this.navigator.dispatch( - NavigationActions.navigate({ - routeName: 'BuyBitcoin', - params: { - uri: event.url, - safelloStateToken, - }, - }), - ); - } else { - let urlObject = url.parse(event.url, true); // eslint-disable-line - console.log('parsed', urlObject); - (async () => { - if (urlObject.protocol === 'bluewallet:' || urlObject.protocol === 'lapp:' || urlObject.protocol === 'blue:') { - switch (urlObject.host) { - case 'openlappbrowser': - console.log('opening LAPP', urlObject.query.url); - // searching for LN wallet: - let haveLnWallet = false; - for (let w of BlueApp.getWallets()) { - if (w.type === LightningCustodianWallet.type) { - haveLnWallet = true; - } - } - - if (!haveLnWallet) { - // need to create one - let w = new LightningCustodianWallet(); - w.setLabel(this.state.label || w.typeReadable); - - try { - let lndhub = await AsyncStorage.getItem(AppStorage.LNDHUB); - if (lndhub) { - w.setBaseURI(lndhub); - w.init(); - } - await w.createAccount(); - await w.authorize(); - } catch (Err) { - // giving up, not doing anything - return; - } - BlueApp.wallets.push(w); - await BlueApp.saveToDisk(); - } - - // now, opening lapp browser and navigating it to URL. - // looking for a LN wallet: - let lnWallet; - for (let w of BlueApp.getWallets()) { - if (w.type === LightningCustodianWallet.type) { - lnWallet = w; - break; - } - } - - if (!lnWallet) { - // something went wrong - return; - } - - this.navigator && - this.navigator.dispatch( - NavigationActions.navigate({ - routeName: 'LappBrowser', - params: { - fromSecret: lnWallet.getSecret(), - fromWallet: lnWallet, - url: urlObject.query.url, - }, - }), - ); - break; - } - } - })(); - } + DeeplinkSchemaMatch.navigationRouteFor(event, value => this.navigator && this.navigator.dispatch(NavigationActions.navigate(value))); }; renderClipboardContentModal = () => { diff --git a/BlueComponents.js b/BlueComponents.js index 8cb31b112..33b02e375 100644 --- a/BlueComponents.js +++ b/BlueComponents.js @@ -1381,7 +1381,7 @@ export class NewWalletPanel extends Component { style={{ padding: 15, borderRadius: 10, - minHeight: 164, + minHeight: Platform.OS === 'ios' ? 164 : 181, justifyContent: 'center', alignItems: 'center', }} @@ -1837,7 +1837,9 @@ export class WalletsCarousel extends Component { { if (WalletsCarousel.handleClick) { + this.onPressedOut(); WalletsCarousel.handleClick(index); + this.onPressedOut(); } }} /> @@ -1857,7 +1859,9 @@ export class WalletsCarousel extends Component { onPressOut={item.getIsFailure() ? this.onPressedOut : null} onPress={() => { if (item.getIsFailure() && WalletsCarousel.handleClick) { + this.onPressedOut(); WalletsCarousel.handleClick(index); + this.onPressedOut(); } }} > @@ -1925,7 +1929,9 @@ export class WalletsCarousel extends Component { onLongPress={WalletsCarousel.handleLongPress} onPress={() => { if (WalletsCarousel.handleClick) { + this.onPressedOut(); WalletsCarousel.handleClick(index); + this.onPressedOut(); } }} > @@ -2037,7 +2043,8 @@ export class BlueAddressInput extends Component { static propTypes = { isLoading: PropTypes.bool, onChangeText: PropTypes.func, - onBarScanned: PropTypes.func, + onBarScanned: PropTypes.func.isRequired, + launchedBy: PropTypes.string.isRequired, address: PropTypes.string, placeholder: PropTypes.string, }; @@ -2081,7 +2088,7 @@ export class BlueAddressInput extends Component { { - NavigationService.navigate('ScanQrAddress', { onBarScanned: this.props.onBarScanned }); + NavigationService.navigate('ScanQrAddress', { onBarScanned: this.props.onBarScanned, launchedBy: this.props.launchedBy }); Keyboard.dismiss(); }} style={{ diff --git a/MainBottomTabs.js b/MainBottomTabs.js index aadd42e69..4d787de0d 100644 --- a/MainBottomTabs.js +++ b/MainBottomTabs.js @@ -60,6 +60,9 @@ const WalletsStackNavigator = createStackNavigator( Wallets: { screen: WalletsList, path: 'wallets', + navigationOptions: { + header: null, + }, }, WalletTransactions: { screen: WalletTransactions, @@ -157,6 +160,7 @@ const WalletsStackNavigator = createStackNavigator( const CreateTransactionStackNavigator = createStackNavigator({ SendDetails: { + routeName: 'SendDetails', screen: sendDetails, }, Confirm: { @@ -206,6 +210,7 @@ const CreateWalletStackNavigator = createStackNavigator({ }, ImportWallet: { screen: ImportWallet, + routeName: 'ImportWallet', }, PleaseBackup: { screen: PleaseBackup, @@ -290,6 +295,7 @@ const MainBottomTabs = createStackNavigator( }, // SendDetails: { + routeName: 'SendDetails', screen: CreateTransactionStackNavigator, navigationOptions: { header: null, diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 04c6060ef..5c8bfba9a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,8 @@ - + + + + + + + + + + + + + + diff --git a/class/abstract-hd-electrum-wallet.js b/class/abstract-hd-electrum-wallet.js index 7e1bca835..32f1d8515 100644 --- a/class/abstract-hd-electrum-wallet.js +++ b/class/abstract-hd-electrum-wallet.js @@ -8,6 +8,7 @@ const BlueElectrum = require('../BlueElectrum'); const HDNode = require('bip32'); const coinSelectAccumulative = require('coinselect/accumulative'); const coinSelectSplit = require('coinselect/split'); +const reverse = require('buffer-reverse'); const { RNRandomBytes } = NativeModules; @@ -719,7 +720,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { * @param skipSigning {boolean} Whether we should skip signing, use returned `psbt` in that case * @returns {{outputs: Array, tx: Transaction, inputs: Array, fee: Number, psbt: Psbt}} */ - createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false) { + createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) { if (!changeAddress) throw new Error('No change address provided'); sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence; @@ -756,7 +757,13 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { if (!input.address || !this._getWifForAddress(input.address)) throw new Error('Internal error: no address or WIF to sign input'); } let pubkey = this._getPubkeyByAddress(input.address); - let masterFingerprint = Buffer.from([0x00, 0x00, 0x00, 0x00]); + let masterFingerprintBuffer; + if (masterFingerprint) { + const hexBuffer = Buffer.from(Number(masterFingerprint).toString(16), 'hex'); + masterFingerprintBuffer = Buffer.from(reverse(hexBuffer)); + } else { + masterFingerprintBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); + } // this is not correct fingerprint, as we dont know real fingerprint - we got zpub with 84/0, but fingerpting // should be from root. basically, fingerprint should be provided from outside by user when importing zpub let path = this._getDerivationPathByAddress(input.address); @@ -767,7 +774,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { sequence, bip32Derivation: [ { - masterFingerprint, + masterFingerprint: masterFingerprintBuffer, path, pubkey, }, @@ -789,7 +796,15 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { let path = this._getDerivationPathByAddress(output.address); let pubkey = this._getPubkeyByAddress(output.address); - let masterFingerprint = Buffer.from([0x00, 0x00, 0x00, 0x00]); + let masterFingerprintBuffer; + + if (masterFingerprint) { + const hexBuffer = Buffer.from(Number(masterFingerprint).toString(16), 'hex') + masterFingerprintBuffer = Buffer.from(reverse(hexBuffer)); + } else { + masterFingerprintBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); + } + // this is not correct fingerprint, as we dont know realfingerprint - we got zpub with 84/0, but fingerpting // should be from root. basically, fingerprint should be provided from outside by user when importing zpub @@ -801,7 +816,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { if (change) { outputData['bip32Derivation'] = [ { - masterFingerprint, + masterFingerprint: masterFingerprintBuffer, path, pubkey, }, diff --git a/class/app-storage.js b/class/app-storage.js index df9f9aace..333980eed 100644 --- a/class/app-storage.js +++ b/class/app-storage.js @@ -305,7 +305,6 @@ export class AppStorage { if (key.prepareForSerialization) key.prepareForSerialization(); walletsToSave.push(JSON.stringify({ ...key, type: key.type })); } - let data = { wallets: walletsToSave, tx_metadata: this.tx_metadata, diff --git a/class/deeplinkSchemaMatch.js b/class/deeplinkSchemaMatch.js new file mode 100644 index 000000000..194b0c440 --- /dev/null +++ b/class/deeplinkSchemaMatch.js @@ -0,0 +1,221 @@ +import { AppStorage, LightningCustodianWallet } from './'; +import AsyncStorage from '@react-native-community/async-storage'; +import BitcoinBIP70TransactionDecode from '../bip70/bip70'; +const bitcoin = require('bitcoinjs-lib'); +const BlueApp = require('../BlueApp'); +class DeeplinkSchemaMatch { + static hasSchema(schemaString) { + if (typeof schemaString !== 'string' || schemaString.length <= 0) return false; + const lowercaseString = schemaString.trim().toLowerCase(); + return ( + lowercaseString.startsWith('bitcoin:') || + lowercaseString.startsWith('lightning:') || + lowercaseString.startsWith('blue:') || + lowercaseString.startsWith('bluewallet:') || + lowercaseString.startsWith('lapp:') + ); + } + + /** + * Examines the content of the event parameter. + * If the content is recognizable, create a dictionary with the respective + * navigation dictionary required by react-navigation + * @param {Object} event + * @param {void} completionHandler + */ + static navigationRouteFor(event, completionHandler) { + if (event.url === null) { + return; + } + if (typeof event.url !== 'string') { + return; + } + let isBothBitcoinAndLightning; + try { + isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(event.url); + } catch (e) { + console.log(e); + } + if (isBothBitcoinAndLightning) { + completionHandler({ + routeName: 'HandleOffchainAndOnChain', + params: { + onWalletSelect: this.isBothBitcoinAndLightningWalletSelect, + }, + }); + } else if (DeeplinkSchemaMatch.isBitcoinAddress(event.url) || BitcoinBIP70TransactionDecode.matchesPaymentURL(event.url)) { + completionHandler({ + routeName: 'SendDetails', + params: { + uri: event.url, + }, + }); + } else if (DeeplinkSchemaMatch.isLightningInvoice(event.url)) { + completionHandler({ + routeName: 'ScanLndInvoice', + params: { + uri: event.url, + }, + }); + } else if (DeeplinkSchemaMatch.isLnUrl(event.url)) { + completionHandler({ + routeName: 'LNDCreateInvoice', + params: { + uri: event.url, + }, + }); + } else if (DeeplinkSchemaMatch.isSafelloRedirect(event)) { + let urlObject = url.parse(event.url, true) // eslint-disable-line + + const safelloStateToken = urlObject.query['safello-state-token']; + + completionHandler({ + routeName: 'BuyBitcoin', + params: { + uri: event.url, + safelloStateToken, + }, + }); + } else { + let urlObject = url.parse(event.url, true); // eslint-disable-line + console.log('parsed', urlObject); + (async () => { + if (urlObject.protocol === 'bluewallet:' || urlObject.protocol === 'lapp:' || urlObject.protocol === 'blue:') { + switch (urlObject.host) { + case 'openlappbrowser': + console.log('opening LAPP', urlObject.query.url); + // searching for LN wallet: + let haveLnWallet = false; + for (let w of BlueApp.getWallets()) { + if (w.type === LightningCustodianWallet.type) { + haveLnWallet = true; + } + } + + if (!haveLnWallet) { + // need to create one + let w = new LightningCustodianWallet(); + w.setLabel(this.state.label || w.typeReadable); + + try { + let lndhub = await AsyncStorage.getItem(AppStorage.LNDHUB); + if (lndhub) { + w.setBaseURI(lndhub); + w.init(); + } + await w.createAccount(); + await w.authorize(); + } catch (Err) { + // giving up, not doing anything + return; + } + BlueApp.wallets.push(w); + await BlueApp.saveToDisk(); + } + + // now, opening lapp browser and navigating it to URL. + // looking for a LN wallet: + let lnWallet; + for (let w of BlueApp.getWallets()) { + if (w.type === LightningCustodianWallet.type) { + lnWallet = w; + break; + } + } + + if (!lnWallet) { + // something went wrong + return; + } + + this.navigator && + this.navigator.dispatch( + completionHandler({ + routeName: 'LappBrowser', + params: { + fromSecret: lnWallet.getSecret(), + fromWallet: lnWallet, + url: urlObject.query.url, + }, + }), + ); + break; + } + } + })(); + } + } + + static isBitcoinAddress(address) { + address = address + .replace('bitcoin:', '') + .replace('bitcoin=', '') + .split('?')[0]; + let isValidBitcoinAddress = false; + try { + bitcoin.address.toOutputScript(address); + isValidBitcoinAddress = true; + } catch (err) { + isValidBitcoinAddress = false; + } + return isValidBitcoinAddress; + } + + static isLightningInvoice(invoice) { + let isValidLightningInvoice = false; + if (invoice.toLowerCase().startsWith('lightning:lnb') || invoice.toLowerCase().startsWith('lnb')) { + isValidLightningInvoice = true; + } + return isValidLightningInvoice; + } + + static isLnUrl(text) { + if (text.toLowerCase().startsWith('lightning:lnurl') || text.toLowerCase().startsWith('lnurl')) { + return true; + } + return false; + } + + static isSafelloRedirect(event) { + let urlObject = url.parse(event.url, true) // eslint-disable-line + + return !!urlObject.query['safello-state-token']; + } + + static isBothBitcoinAndLightning(url) { + if (url.includes('lightning') && url.includes('bitcoin')) { + const txInfo = url.split(/(bitcoin:|lightning:|lightning=|bitcoin=)+/); + let bitcoin; + let lndInvoice; + for (const [index, value] of txInfo.entries()) { + try { + // Inside try-catch. We dont wan't to crash in case of an out-of-bounds error. + if (value.startsWith('bitcoin')) { + bitcoin = `bitcoin:${txInfo[index + 1]}`; + if (!DeeplinkSchemaMatch.isBitcoinAddress(bitcoin)) { + bitcoin = false; + break; + } + } else if (value.startsWith('lightning')) { + lndInvoice = `lightning:${txInfo[index + 1]}`; + if (!this.isLightningInvoice(lndInvoice)) { + lndInvoice = false; + break; + } + } + } catch (e) { + console.log(e); + } + if (bitcoin && lndInvoice) break; + } + if (bitcoin && lndInvoice) { + return { bitcoin, lndInvoice }; + } else { + return undefined; + } + } + return undefined; + } +} + +export default DeeplinkSchemaMatch; diff --git a/class/walletImport.js b/class/walletImport.js index 0279404f9..18f2f2078 100644 --- a/class/walletImport.js +++ b/class/walletImport.js @@ -18,24 +18,34 @@ const BlueApp = require('../BlueApp'); const loc = require('../loc'); export default class WalletImport { - static async _saveWallet(w) { + static async _saveWallet(w, additionalProperties) { try { const wallet = BlueApp.getWallets().some(wallet => wallet.getSecret() === w.secret && wallet.type !== PlaceholderWallet.type); if (wallet) { alert('This wallet has been previously imported.'); WalletImport.removePlaceholderWallet(); } else { - alert(loc.wallets.import.success); ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); w.setLabel(loc.wallets.import.imported + ' ' + w.typeReadable); w.setUserHasSavedExport(true); + if (additionalProperties) { + for (const [key, value] of Object.entries(additionalProperties)) { + w[key] = value; + } + } WalletImport.removePlaceholderWallet(); BlueApp.wallets.push(w); await BlueApp.saveToDisk(); A(A.ENUM.CREATED_WALLET); + alert(loc.wallets.import.success); } EV(EV.enum.WALLETS_COUNT_CHANGED); - } catch (_e) {} + } catch (e) { + alert(e); + console.log(e); + WalletImport.removePlaceholderWallet(); + EV(EV.enum.WALLETS_COUNT_CHANGED); + } } static removePlaceholderWallet() { @@ -58,7 +68,7 @@ export default class WalletImport { return BlueApp.getWallets().some(wallet => wallet.type === PlaceholderWallet.type); } - static async processImportText(importText) { + static async processImportText(importText, additionalProperties) { if (WalletImport.isCurrentlyImportingWallet()) { return; } @@ -209,7 +219,7 @@ export default class WalletImport { if (watchOnly.valid()) { await watchOnly.fetchTransactions(); await watchOnly.fetchBalance(); - return WalletImport._saveWallet(watchOnly); + return WalletImport._saveWallet(watchOnly, additionalProperties); } // nope? diff --git a/class/watch-only-wallet.js b/class/watch-only-wallet.js index dedd613c1..96c56c4e5 100644 --- a/class/watch-only-wallet.js +++ b/class/watch-only-wallet.js @@ -11,6 +11,7 @@ export class WatchOnlyWallet extends LegacyWallet { constructor() { super(); this.use_with_hardware_wallet = false; + this.masterFingerprint = false; } allowSend() { @@ -146,7 +147,7 @@ export class WatchOnlyWallet extends LegacyWallet { */ createTransaction(utxos, targets, feeRate, changeAddress, sequence) { if (this._hdWalletInstance instanceof HDSegwitBech32Wallet) { - return this._hdWalletInstance.createTransaction(utxos, targets, feeRate, changeAddress, sequence, true); + return this._hdWalletInstance.createTransaction(utxos, targets, feeRate, changeAddress, sequence, true, this.masterFingerprint); } else { throw new Error('Not a zpub watch-only wallet, cant create PSBT (or just not initialized)'); } diff --git a/ios/BlueWallet.xcodeproj/project.pbxproj b/ios/BlueWallet.xcodeproj/project.pbxproj index 902a31f6b..93f3854b3 100644 --- a/ios/BlueWallet.xcodeproj/project.pbxproj +++ b/ios/BlueWallet.xcodeproj/project.pbxproj @@ -164,6 +164,7 @@ 3271B0BA236E329400DA766F /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; 32B5A3282334450100F8D608 /* BlueWallet-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "BlueWallet-Bridging-Header.h"; sourceTree = ""; }; 32B5A3292334450100F8D608 /* Bridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bridge.swift; sourceTree = ""; }; + 32C7944323B8879D00BE2AFA /* BlueWalletRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = BlueWalletRelease.entitlements; path = BlueWallet/BlueWalletRelease.entitlements; sourceTree = ""; }; 32F0A24F2310B0700095C559 /* BlueWalletWatch Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "BlueWalletWatch Extension.entitlements"; sourceTree = ""; }; 32F0A2502310B0910095C559 /* BlueWallet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = BlueWallet.entitlements; path = BlueWallet/BlueWallet.entitlements; sourceTree = ""; }; 32F0A2992311DBB20095C559 /* ComplicationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComplicationController.swift; sourceTree = ""; }; @@ -332,6 +333,7 @@ 13B07FAE1A68108700A75B9A /* BlueWallet */ = { isa = PBXGroup; children = ( + 32C7944323B8879D00BE2AFA /* BlueWalletRelease.entitlements */, 32F0A2502310B0910095C559 /* BlueWallet.entitlements */, 008F07F21AC5B25A0029DE68 /* main.jsbundle */, 13B07FAF1A68108700A75B9A /* AppDelegate.h */, @@ -1266,7 +1268,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = BlueWallet/BlueWallet.entitlements; + CODE_SIGN_ENTITLEMENTS = BlueWallet/BlueWalletRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/ios/BlueWallet/BlueWalletRelease.entitlements b/ios/BlueWallet/BlueWalletRelease.entitlements new file mode 100644 index 000000000..16a6e901d --- /dev/null +++ b/ios/BlueWallet/BlueWalletRelease.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.developer.icloud-container-identifiers + + com.apple.developer.icloud-services + + CloudDocuments + + com.apple.developer.ubiquity-container-identifiers + + com.apple.security.application-groups + + group.io.bluewallet.bluewallet + + + diff --git a/ios/BlueWallet/Info.plist b/ios/BlueWallet/Info.plist index e1375e790..ec5720979 100644 --- a/ios/BlueWallet/Info.plist +++ b/ios/BlueWallet/Info.plist @@ -2,12 +2,29 @@ - UIUserInterfaceStyle - Light + LSSupportsOpeningDocumentsInPlace + CFBundleDevelopmentRegion en CFBundleDisplayName BlueWallet + CFBundleDocumentTypes + + + CFBundleTypeIconFiles + + CFBundleTypeName + PSBT + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + io.bluewallet.psbt + + + CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -58,18 +75,18 @@ NSAppleMusicUsageDescription This alert should not show up as we do not require this data - NSFaceIDUsageDescription - In order to confirm your identity, we need your permission to use FaceID. NSBluetoothPeripheralUsageDescription This alert should not show up as we do not require this data NSCalendarsUsageDescription This alert should not show up as we do not require this data NSCameraUsageDescription In order to quickly scan the recipient's address, we need your permission to use the camera to scan their QR Code. - NSLocationWhenInUseUsageDescription - This alert should not show up as we do not require this data + NSFaceIDUsageDescription + In order to confirm your identity, we need your permission to use FaceID. NSLocationAlwaysUsageDescription This alert should not show up as we do not require this data + NSLocationWhenInUseUsageDescription + This alert should not show up as we do not require this data NSMicrophoneUsageDescription This alert should not show up as we do not require this data NSMotionUsageDescription @@ -116,7 +133,53 @@ UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortraitUpsideDown + UIUserInterfaceStyle + Light UIViewControllerBasedStatusBarAppearance + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.data + + UTTypeDescription + PSBT + UTTypeIconFiles + + UTTypeIdentifier + io.bluewallet.psbt + UTTypeTagSpecification + + public.filename-extension + + psbt + + + + + UTImportedTypeDeclarations + + + UTTypeConformsTo + + public.data + + UTTypeDescription + PSBT + UTTypeIconFiles + + UTTypeIdentifier + io.bluewallet.psbt + UTTypeTagSpecification + + public.filename-extension + + psbt + + + + diff --git a/package.json b/package.json index c04ba6135..c465e9dfd 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,8 @@ "react-native-camera": "3.4.0", "react-native-default-preference": "1.4.1", "react-native-device-info": "4.0.1", + "react-native-directory-picker": "git+https://github.com/BlueWallet/react-native-directory-picker.git#63307e646f72444ab83b619e579c55ee38cd162a", + "react-native-document-picker": "git+https://github.com/BlueWallet/react-native-document-picker.git#9ce83792db340d01b1361d24b19613658abef4aa", "react-native-elements": "0.19.0", "react-native-flexi-radio-button": "0.2.2", "react-native-fs": "2.13.3", @@ -116,6 +118,7 @@ "react-native-snap-carousel": "3.8.4", "react-native-sortable-list": "0.0.23", "react-native-svg": "9.5.1", + "react-native-swiper": "git+https://github.com/BlueWallet/react-native-swiper.git#1.5.14", "react-native-tcp": "git+https://github.com/aprock/react-native-tcp.git", "react-native-tooltip": "git+https://github.com/marcosrdz/react-native-tooltip.git", "react-native-vector-icons": "6.6.0", diff --git a/screen/lnd/scanLndInvoice.js b/screen/lnd/scanLndInvoice.js index d54a5eed0..c10a3952c 100644 --- a/screen/lnd/scanLndInvoice.js +++ b/screen/lnd/scanLndInvoice.js @@ -19,6 +19,7 @@ import { BlueNavigationStyle, BlueAddressInput, BlueBitcoinAmount, + BlueLoading, } from '../../BlueComponents'; import { LightningCustodianWallet } from '../../class/lightning-custodian-wallet'; import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; @@ -45,7 +46,8 @@ export default class ScanLndInvoice extends React.Component { constructor(props) { super(props); - + this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this._keyboardDidShow); + this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this._keyboardDidHide); if (!BlueApp.getWallets().some(item => item.type === LightningCustodianWallet.type)) { ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); alert('Before paying a Lightning invoice, you must first add a Lightning wallet.'); @@ -78,9 +80,7 @@ export default class ScanLndInvoice extends React.Component { } } - async componentDidMount() { - this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this._keyboardDidShow); - this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this._keyboardDidHide); + componentDidMount() { if (this.props.navigation.state.params.uri) { this.processTextForInvoice(this.props.navigation.getParam('uri')); } @@ -265,6 +265,9 @@ export default class ScanLndInvoice extends React.Component { }; render() { + if (!this.state.fromWallet) { + return ; + } return ( @@ -300,6 +303,7 @@ export default class ScanLndInvoice extends React.Component { isLoading={this.state.isLoading} placeholder={loc.lnd.placeholder} inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID} + launchedBy={this.props.navigation.state.routeName} /> { + try { + const res = await DocumentPicker.pick(); + const file = await RNFS.readFile(res.uri, 'ascii'); + const bufferDecoded = Buffer.from(file, 'ascii').toString('base64'); + if (bufferDecoded) { + if (this.state.fromWallet.type === WatchOnlyWallet.type) { + // watch-only wallets with enabled HW wallet support have different flow. we have to show PSBT to user as QR code + // so he can scan it and sign it. then we have to scan it back from user (via camera and QR code), and ask + // user whether he wants to broadcast it + this.props.navigation.navigate('PsbtWithHardwareWallet', { + memo: this.state.memo, + fromWallet: this.state.fromWallet, + psbt: bufferDecoded, + isFirstPSBTAlreadyBase64: true, + }); + this.setState({ isLoading: false }); + return; + } + } else { + throw new Error(); + } + } catch (err) { + if (!DocumentPicker.isCancel(err)) { + alert('The selected file does not contain a signed transaction that can be imported.'); + } + } + this.setState({ isAdvancedTransactionOptionsVisible: false }); + }; + renderAdvancedTransactionOptionsModal = () => { const isSendMaxUsed = this.state.addresses.some(element => element.amount === BitcoinUnit.MAX); return ( @@ -736,6 +770,9 @@ export default class SendDetails extends Component { onSwitch={this.onReplaceableFeeSwitchValueChanged} /> )} + {this.state.fromWallet.use_with_hardware_wallet && ( + + )} {this.state.fromWallet.allowBatchSend() && ( <> {this.state.addresses.length > 1 && ( @@ -1067,6 +1105,7 @@ SendDetails.propTypes = { getParam: PropTypes.func, setParams: PropTypes.func, state: PropTypes.shape({ + routeName: PropTypes.string, params: PropTypes.shape({ amount: PropTypes.number, address: PropTypes.string, diff --git a/screen/send/psbtWithHardwareWallet.js b/screen/send/psbtWithHardwareWallet.js index 45e22dd98..f7d716aef 100644 --- a/screen/send/psbtWithHardwareWallet.js +++ b/screen/send/psbtWithHardwareWallet.js @@ -1,6 +1,17 @@ /* global alert */ import React, { Component } from 'react'; -import { ActivityIndicator, TouchableOpacity, View, Dimensions, Image, TextInput, Clipboard, Linking } from 'react-native'; +import { + ActivityIndicator, + TouchableOpacity, + ScrollView, + View, + Dimensions, + Image, + TextInput, + Clipboard, + Linking, + Platform, +} from 'react-native'; import QRCode from 'react-native-qrcode-svg'; import { Icon, Text } from 'react-native-elements'; import { @@ -13,8 +24,12 @@ import { BlueCopyToClipboardButton, } from '../../BlueComponents'; import PropTypes from 'prop-types'; +import Share from 'react-native-share'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import { RNCamera } from 'react-native-camera'; +import RNFS from 'react-native-fs'; +import DocumentPicker from 'react-native-document-picker'; +import DirectoryPickerManager from 'react-native-directory-picker'; let loc = require('../../loc'); let EV = require('../../events'); let BlueElectrum = require('../../BlueElectrum'); @@ -36,7 +51,10 @@ export default class PsbtWithHardwareWallet extends Component { this.setState({ renderScanner: false }, () => { console.log(ret.data); try { - let Tx = this.state.fromWallet.combinePsbt(this.state.psbt.toBase64(), ret.data); + let Tx = this.state.fromWallet.combinePsbt( + this.state.isFirstPSBTAlreadyBase64 ? this.state.psbt : this.state.psbt.toBase64(), + this.state.isSecondPSBTAlreadyBase64 ? ret.data : ret.data.toBase64(), + ); this.setState({ txhex: Tx.toHex() }); } catch (Err) { alert(Err); @@ -46,18 +64,19 @@ export default class PsbtWithHardwareWallet extends Component { constructor(props) { super(props); - this.state = { isLoading: false, renderScanner: false, - qrCodeHeight: height > width ? width - 40 : width / 2, + qrCodeHeight: height > width ? width - 40 : width / 3, memo: props.navigation.getParam('memo'), psbt: props.navigation.getParam('psbt'), fromWallet: props.navigation.getParam('fromWallet'), + isFirstPSBTAlreadyBase64: props.navigation.getParam('isFirstPSBTAlreadyBase64'), + isSecondPSBTAlreadyBase64: false, }; } - async componentDidMount() { + componentDidMount() { console.log('send/psbtWithHardwareWallet - componentDidMount'); } @@ -185,6 +204,60 @@ export default class PsbtWithHardwareWallet extends Component { ); } + exportPSBT = async () => { + const fileName = `${Date.now()}.psbt`; + if (Platform.OS === 'ios') { + const filePath = RNFS.TemporaryDirectoryPath + `/${fileName}`; + await RNFS.writeFile(filePath, this.state.isFirstPSBTAlreadyBase64 ? this.state.psbt : this.state.psbt.toBase64(), 'ascii'); + Share.open({ + url: 'file://' + filePath, + }) + .catch(error => console.log(error)) + .finally(() => { + RNFS.unlink(filePath); + }); + } else if (Platform.OS === 'android') { + DirectoryPickerManager.showDirectoryPicker(null, async response => { + if (response.didCancel) { + console.log('User cancelled directory picker'); + } else if (response.error) { + console.log('DirectoryPickerManager Error: ', response.error); + } else { + try { + await RNFS.writeFile( + response.path + `/${fileName}`, + this.state.isFirstPSBTAlreadyBase64 ? this.state.psbt : this.state.psbt.toBase64(), + 'ascii', + ); + alert('Successfully exported.'); + RNFS.unlink(response.path + `/${fileName}`); + } catch (e) { + console.log(e); + alert(e); + } + } + }); + } + }; + + openSignedTransaction = async () => { + try { + const res = await DocumentPicker.pick(); + const file = await RNFS.readFile(res.uri, 'ascii'); + const bufferDecoded = Buffer.from(file, 'ascii').toString('base64'); + if (bufferDecoded) { + this.setState({ isSecondPSBTAlreadyBase64: true }, () => this.onBarCodeRead({ data: bufferDecoded })); + } else { + this.setState({ isSecondPSBTAlreadyBase64: false }); + throw new Error(); + } + } catch (err) { + if (!DocumentPicker.isCancel(err)) { + alert('The selected file does not contain a signed transaction that can be imported.'); + } + } + }; + render() { if (this.state.isLoading) { return ( @@ -200,27 +273,58 @@ export default class PsbtWithHardwareWallet extends Component { return ( - + This is partially signed bitcoin transaction (PSBT). Please finish signing it with your hardware wallet. - this.setState({ renderScanner: true })} title={'Scan signed transaction'} /> + this.setState({ renderScanner: true })} + title={'Scan Signed Transaction'} + /> + + + + - + - + ); } diff --git a/screen/send/scanQrAddress.js b/screen/send/scanQrAddress.js index b00ab06d6..fa750b5a4 100644 --- a/screen/send/scanQrAddress.js +++ b/screen/send/scanQrAddress.js @@ -1,30 +1,69 @@ /* global alert */ -import React from 'react'; -import { Image, TouchableOpacity, Platform } from 'react-native'; -import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import { Image, View, TouchableOpacity, Platform } from 'react-native'; import { RNCamera } from 'react-native-camera'; -import { SafeBlueArea } from '../../BlueComponents'; import { Icon } from 'react-native-elements'; import ImagePicker from 'react-native-image-picker'; +import PropTypes from 'prop-types'; +import { useNavigationParam, useNavigation } from 'react-navigation-hooks'; +import DocumentPicker from 'react-native-document-picker'; +import RNFS from 'react-native-fs'; const LocalQRCode = require('@remobile/react-native-qrcode-local-image'); -export default class ScanQRCode extends React.Component { - static navigationOptions = { - header: null, +const ScanQRCode = ({ + onBarScanned = useNavigationParam('onBarScanned'), + cameraPreviewIsPaused = false, + showCloseButton = true, + showFileImportButton = useNavigationParam('showFileImportButton') || false, + launchedBy = useNavigationParam('launchedBy'), +}) => { + const [isLoading, setIsLoading] = useState(false); + const { navigate } = useNavigation(); + + const onBarCodeRead = ret => { + if (!isLoading && !cameraPreviewIsPaused) { + setIsLoading(true); + try { + if (showCloseButton && launchedBy) { + navigate(launchedBy); + } + if (ret.additionalProperties) { + onBarScanned(ret.data, ret.additionalProperties); + } else { + onBarScanned(ret.data); + } + } catch (e) { + console.log(e); + } + } + setIsLoading(false); }; - cameraRef = null; + const showFilePicker = async () => { + setIsLoading(true); + try { + const res = await DocumentPicker.pick(); + const file = await RNFS.readFile(res.uri); + const fileParsed = JSON.parse(file); + if (fileParsed.keystore.xpub) { + onBarCodeRead({ data: fileParsed.keystore.xpub, additionalProperties: { masterFingerprint: fileParsed.keystore.ckcc_xfp } }); + } else { + throw new Error(); + } + } catch (err) { + if (!DocumentPicker.isCancel(err)) { + alert('The selected file does not contain a wallet that can be imported.'); + } + setIsLoading(false); + } + setIsLoading(false); + }; - onBarCodeRead = ret => { - if (RNCamera.Constants.CameraStatus === RNCamera.Constants.CameraStatus.READY) this.cameraRef.pausePreview(); - const onBarScannedProp = this.props.navigation.getParam('onBarScanned'); - this.props.navigation.goBack(); - onBarScannedProp(ret.data); - }; // end + useEffect(() => {}, [cameraPreviewIsPaused]); - render() { - return ( - + return ( + + {!cameraPreviewIsPaused && !isLoading && ( (this.cameraRef = ref)} - style={{ flex: 1, justifyContent: 'space-between' }} - onBarCodeRead={this.onBarCodeRead} + style={{ flex: 1, justifyContent: 'space-between', backgroundColor: '#000000' }} + onBarCodeRead={onBarCodeRead} barCodeTypes={[RNCamera.Constants.BarCodeType.qr]} /> + )} + {showCloseButton && ( this.props.navigation.goBack(null)} + onPress={() => navigate(launchedBy)} > - { - if (RNCamera.Constants.CameraStatus === RNCamera.Constants.CameraStatus.READY) this.cameraRef.pausePreview(); + )} + { + if (!isLoading) { + setIsLoading(true); ImagePicker.launchImageLibrary( { title: null, @@ -77,30 +119,49 @@ export default class ScanQRCode extends React.Component { const uri = Platform.OS === 'ios' ? response.uri.toString().replace('file://', '') : response.path.toString(); LocalQRCode.decode(uri, (error, result) => { if (!error) { - this.onBarCodeRead({ data: result }); + onBarCodeRead({ data: result }); } else { - if (RNCamera.Constants.CameraStatus === RNCamera.Constants.CameraStatus.READY) this.cameraRef.resumePreview(); alert('The selected image does not contain a QR Code.'); } }); - } else { - if (RNCamera.Constants.CameraStatus === RNCamera.Constants.CameraStatus.READY) this.cameraRef.resumePreview(); } + setIsLoading(false); }, ); + } + }} + > + + + {showFileImportButton && ( + - + - - ); - } -} - -ScanQRCode.propTypes = { - navigation: PropTypes.shape({ - goBack: PropTypes.func, - dismiss: PropTypes.func, - getParam: PropTypes.func, - }), + )} + + ); }; + +ScanQRCode.navigationOptions = { + header: null, +}; +ScanQRCode.propTypes = { + launchedBy: PropTypes.string, + onBarScanned: PropTypes.func, + cameraPreviewIsPaused: PropTypes.bool, + showFileImportButton: PropTypes.bool, + showCloseButton: PropTypes.bool, +}; +export default ScanQRCode; diff --git a/screen/wallets/details.js b/screen/wallets/details.js index 590c2979d..db04793c3 100644 --- a/screen/wallets/details.js +++ b/screen/wallets/details.js @@ -20,6 +20,7 @@ import { HDSegwitP2SHWallet } from '../../class/hd-segwit-p2sh-wallet'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import Biometric from '../../class/biometrics'; import { HDSegwitBech32Wallet, WatchOnlyWallet } from '../../class'; +import { ScrollView } from 'react-native-gesture-handler'; let EV = require('../../events'); let prompt = require('../../prompt'); /** @type {AppStorage} */ @@ -55,6 +56,7 @@ export default class WalletDetails extends Component { walletName: wallet.getLabel(), wallet, useWithHardwareWallet: !!wallet.use_with_hardware_wallet, + masterFingerprint: wallet.masterFingerprint ? String(wallet.masterFingerprint) : '', }; this.props.navigation.setParams({ isLoading, saveAction: () => this.setLabel() }); } @@ -71,6 +73,7 @@ export default class WalletDetails extends Component { this.props.navigation.setParams({ isLoading: true }); this.setState({ isLoading: true }, async () => { this.state.wallet.setLabel(this.state.walletName); + this.state.wallet.masterFingerprint = Number(this.state.masterFingerprint); BlueApp.saveToDisk(); alert('Wallet updated.'); this.props.navigation.goBack(null); @@ -122,7 +125,7 @@ export default class WalletDetails extends Component { return ( - + {(() => { if (this.state.wallet.getAddress()) { @@ -158,18 +161,20 @@ export default class WalletDetails extends Component { placeholder={loc.send.details.note_placeholder} value={this.state.walletName} onChangeText={text => { - if (text.trim().length === 0) { - text = this.state.wallet.getLabel(); - } this.setState({ walletName: text }); }} + onBlur={() => { + if (this.state.walletName.trim().length === 0) { + this.setState({ walletName: this.state.wallet.getLabel() }); + } + }} numberOfLines={1} style={{ flex: 1, marginHorizontal: 8, minHeight: 33 }} editable={!this.state.isLoading} underlineColorAndroid="transparent" /> - + {loc.wallets.details.type.toLowerCase()} @@ -182,15 +187,48 @@ export default class WalletDetails extends Component { )} - {this.state.wallet.type === WatchOnlyWallet.type && this.state.wallet.getSecret().startsWith('zpub') && ( - + <> + {'advanced'} + Master Fingerprint + + + { + if (isNaN(text)) { + return; + } + this.setState({ masterFingerprint: text }); + }} + numberOfLines={1} + style={{ flex: 1, marginHorizontal: 8, minHeight: 33 }} + editable={!this.state.isLoading} + keyboardType="decimal-pad" + underlineColorAndroid="transparent" + /> + + {'Use with hardware wallet'} this.onUseWithHardwareWalletSwitch(value)} /> - + )} - + ); diff --git a/screen/wallets/import.js b/screen/wallets/import.js index 86ecbe568..f25487212 100644 --- a/screen/wallets/import.js +++ b/screen/wallets/import.js @@ -35,9 +35,9 @@ const WalletsImport = () => { importMnemonic(importText); }; - const importMnemonic = importText => { + const importMnemonic = (importText, additionalProperties) => { try { - WalletImport.processImportText(importText); + WalletImport.processImportText(importText, additionalProperties); dismiss(); } catch (error) { alert(loc.wallets.import.error); @@ -45,9 +45,9 @@ const WalletsImport = () => { } }; - const onBarScanned = value => { + const onBarScanned = (value, additionalProperties) => { setImportText(value); - importMnemonic(value); + importMnemonic(value, additionalProperties); }; return ( @@ -110,7 +110,7 @@ const WalletsImport = () => { { - navigate('ScanQrAddress', { onBarScanned }); + navigate('ScanQrAddress', { launchedBy: 'ImportWallet', onBarScanned, showFileImportButton: true }); }} /> diff --git a/screen/wallets/list.js b/screen/wallets/list.js index d5b965467..a52ff288e 100644 --- a/screen/wallets/list.js +++ b/screen/wallets/list.js @@ -1,6 +1,17 @@ /* global alert */ import React, { Component } from 'react'; -import { View, TouchableOpacity, Text, FlatList, InteractionManager, RefreshControl, ScrollView, Alert } from 'react-native'; +import { + View, + StatusBar, + TouchableOpacity, + Text, + StyleSheet, + FlatList, + InteractionManager, + RefreshControl, + ScrollView, + Alert, +} from 'react-native'; import { BlueLoading, SafeBlueArea, WalletsCarousel, BlueList, BlueHeaderDefaultMain, BlueTransactionListItem } from '../../BlueComponents'; import { Icon } from 'react-native-elements'; import { NavigationEvents } from 'react-navigation'; @@ -8,6 +19,9 @@ import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import PropTypes from 'prop-types'; import { PlaceholderWallet } from '../../class'; import WalletImport from '../../class/walletImport'; +import Swiper from 'react-native-swiper'; +import ScanQRCode from '../send/scanQrAddress'; +import DeeplinkSchemaMatch from '../../class/deeplinkSchemaMatch'; let EV = require('../../events'); let A = require('../../analytics'); /** @type {AppStorage} */ @@ -16,23 +30,8 @@ let loc = require('../../loc'); let BlueElectrum = require('../../BlueElectrum'); export default class WalletsList extends Component { - static navigationOptions = ({ navigation }) => ({ - headerStyle: { - backgroundColor: '#FFFFFF', - borderBottomWidth: 0, - elevation: 0, - }, - headerRight: ( - navigation.navigate('Settings')} - > - - - ), - }); - walletsCarousel = React.createRef(); + swiperRef = React.createRef(); constructor(props) { super(props); @@ -299,21 +298,47 @@ export default class WalletsList extends Component { } }; + onSwiperIndexChanged = index => { + StatusBar.setBarStyle(index === 1 ? 'dark-content' : 'light-content'); + this.setState({ cameraPreviewIsPaused: index === 1 }); + }; + + onBarScanned = value => { + DeeplinkSchemaMatch.navigationRouteFor({ url: value }, completionValue => { + ReactNativeHapticFeedback.trigger('impactLight', { ignoreAndroidSystemSettings: false }); + this.props.navigation.navigate(completionValue); + }); + }; + _renderItem = data => { return ; }; + + renderNavigationHeader = () => { + return ( + + this.props.navigation.navigate('Settings')}> + + + + ); + }; + render() { if (this.state.isLoading) { return ; } return ( - + { + onDidFocus={() => { this.redrawScreen(); + this.setState({ cameraPreviewIsPaused: this.swiperRef.current.index === 1 }); }} + onWillBlur={() => this.setState({ cameraPreviewIsPaused: true })} /> this.refreshTransactions()} @@ -322,65 +347,112 @@ export default class WalletsList extends Component { /> } > - wallet.type === PlaceholderWallet.type) - ? () => this.props.navigation.navigate('AddWallet') - : null - } - /> - { - this.handleClick(index); - }} - handleLongPress={this.handleLongPress} - onSnapToItem={index => { - this.onSnapToItem(index); - }} - ref={c => (this.walletsCarousel = c)} - /> - - - + + + + + + {this.renderNavigationHeader()} + this.refreshTransactions()} refreshing={!this.state.isFlatListRefreshControlHidden} /> + } + > + wallet.type === PlaceholderWallet.type) + ? () => this.props.navigation.navigate('AddWallet') + : null + } + /> + { + this.handleClick(index); }} - > - {loc.wallets.list.empty_txs1} - - { + this.onSnapToItem(index); }} - > - {loc.wallets.list.empty_txs2} - - - } - data={this.state.dataSource} - extraData={this.state.dataSource} - keyExtractor={this._keyExtractor} - renderItem={this._renderItem} - /> - + ref={c => (this.walletsCarousel = c)} + /> + + + + {loc.wallets.list.empty_txs1} + + + {loc.wallets.list.empty_txs2} + + + } + data={this.state.dataSource} + extraData={this.state.dataSource} + keyExtractor={this._keyExtractor} + renderItem={this._renderItem} + /> + + + + + - + ); } } +const styles = StyleSheet.create({ + wrapper: { + backgroundColor: '#FFFFFF', + }, + walletsListWrapper: { + flex: 1, + backgroundColor: '#FFFFFF', + }, + scanQRWrapper: { + flex: 1, + backgroundColor: '#000000', + }, +}); + WalletsList.propTypes = { navigation: PropTypes.shape({ + state: PropTypes.shape({ + routeName: PropTypes.string, + }), navigate: PropTypes.func, }), }; diff --git a/screen/wallets/transactions.js b/screen/wallets/transactions.js index 1f8493263..289623c6c 100644 --- a/screen/wallets/transactions.js +++ b/screen/wallets/transactions.js @@ -16,6 +16,7 @@ import { StatusBar, Linking, KeyboardAvoidingView, + Alert, } from 'react-native'; import PropTypes from 'prop-types'; import { NavigationEvents } from 'react-navigation'; @@ -29,7 +30,7 @@ import { } from '../../BlueComponents'; import WalletGradient from '../../class/walletGradient'; import { Icon } from 'react-native-elements'; -import { LightningCustodianWallet } from '../../class'; +import { LightningCustodianWallet, HDSegwitBech32Wallet } from '../../class'; import Handoff from 'react-native-handoff'; import Modal from 'react-native-modal'; import NavigationService from '../../NavigationService'; @@ -400,7 +401,7 @@ export default class WalletTransactions extends Component { } }; - async onWillBlur() { + onWillBlur() { StatusBar.setBarStyle('dark-content'); } @@ -409,6 +410,14 @@ export default class WalletTransactions extends Component { clearInterval(this.interval); } + navigateToSendScreen = () => { + this.props.navigation.navigate('SendDetails', { + fromAddress: this.state.wallet.getAddress(), + fromSecret: this.state.wallet.getSecret(), + fromWallet: this.state.wallet, + }); + }; + renderItem = item => { return ( { - if (this.state.wallet.allowSend()) { + if ( + this.state.wallet.allowSend() || + (this.state.wallet._hdWalletInstance instanceof HDSegwitBech32Wallet && this.state.wallet._hdWalletInstance.allowSend()) + ) { return ( { if (this.state.wallet.chain === Chain.OFFCHAIN) { navigate('ScanLndInvoice', { fromSecret: this.state.wallet.getSecret() }); } else { - navigate('SendDetails', { - fromAddress: this.state.wallet.getAddress(), - fromSecret: this.state.wallet.getSecret(), - fromWallet: this.state.wallet, - }); + if ( + this.state.wallet._hdWalletInstance instanceof HDSegwitBech32Wallet && + this.state.wallet._hdWalletInstance.allowSend() + ) { + if (this.state.wallet.use_with_hardware_wallet) { + this.navigateToSendScreen(); + } else { + Alert.alert( + 'Wallet', + 'This wallet is not being used in conjunction with a hardwarde wallet. Would you like to enable hardware wallet use?', + [ + { + text: loc._.ok, + onPress: async () => { + this.state.wallet.use_with_hardware_wallet = true; + await BlueApp.saveToDisk(); + this.navigateToSendScreen(); + }, + style: 'default', + }, + + { text: loc.send.details.cancel, onPress: () => {}, style: 'cancel' }, + ], + { cancelable: false }, + ); + } + } else { + this.navigateToSendScreen(); + } } }} /> diff --git a/tests/integration/deepLinkSchemaMatch.test.js b/tests/integration/deepLinkSchemaMatch.test.js new file mode 100644 index 000000000..f852b56c7 --- /dev/null +++ b/tests/integration/deepLinkSchemaMatch.test.js @@ -0,0 +1,50 @@ +/* global describe, it, expect */ +import DeeplinkSchemaMatch from '../../class/deeplinkSchemaMatch'; +const assert = require('assert'); + +describe('unit - DeepLinkSchemaMatch', function() { + it('hasSchema', () => { + const hasSchema = DeeplinkSchemaMatch.hasSchema('bitcoin:12eQ9m4sgAwTSQoNXkRABKhCXCsjm2jdVG'); + assert.ok(hasSchema); + }); + + it('isBitcoin Address', () => { + assert.ok(DeeplinkSchemaMatch.isBitcoinAddress('12eQ9m4sgAwTSQoNXkRABKhCXCsjm2jdVG')); + }); + + it('isLighting Invoice', () => { + assert.ok( + DeeplinkSchemaMatch.isLightningInvoice( + 'lightning:lnbc10u1pwjqwkkpp5vlc3tttdzhpk9fwzkkue0sf2pumtza7qyw9vucxyyeh0yaqq66yqdq5f38z6mmwd3ujqar9wd6qcqzpgxq97zvuqrzjqvgptfurj3528snx6e3dtwepafxw5fpzdymw9pj20jj09sunnqmwqz9hx5qqtmgqqqqqqqlgqqqqqqgqjq5duu3fs9xq9vn89qk3ezwpygecu4p3n69wm3tnl28rpgn2gmk5hjaznemw0gy32wrslpn3g24khcgnpua9q04fttm2y8pnhmhhc2gncplz0zde', + ), + ); + }); + + it('isBoth Bitcoin & Invoice', () => { + assert.ok( + DeeplinkSchemaMatch.isBothBitcoinAndLightning( + 'bitcoin:1DamianM2k8WfNEeJmyqSe2YW1upB7UATx?amount=0.000001&lightning=lnbc1u1pwry044pp53xlmkghmzjzm3cljl6729cwwqz5hhnhevwfajpkln850n7clft4sdqlgfy4qv33ypmj7sj0f32rzvfqw3jhxaqcqzysxq97zvuq5zy8ge6q70prnvgwtade0g2k5h2r76ws7j2926xdjj2pjaq6q3r4awsxtm6k5prqcul73p3atveljkn6wxdkrcy69t6k5edhtc6q7lgpe4m5k4', + ), + ); + }); + + it('isLnurl', () => { + assert.ok( + DeeplinkSchemaMatch.isLnUrl( + 'LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS', + ), + ); + }); + + it('navigationForRoute', () => { + const event = { uri: '12eQ9m4sgAwTSQoNXkRABKhCXCsjm2jdVG' }; + DeeplinkSchemaMatch.navigationRouteFor(event, navValue => { + assert.strictEqual(navValue, { + routeName: 'SendDetails', + params: { + uri: event.url, + }, + }); + }); + }); +});