mirror of
https://github.com/BlueWallet/BlueWallet.git
synced 2025-03-11 01:28:58 +01:00
ADD: Export/Import PSBTs
eipiji
This commit is contained in:
parent
f5f5e787b8
commit
9e302df75d
22 changed files with 971 additions and 414 deletions
252
App.js
252
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 = () => {
|
||||
|
|
|
@ -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 {
|
|||
<NewWalletPanel
|
||||
onPress={() => {
|
||||
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 {
|
|||
<TouchableOpacity
|
||||
disabled={this.props.isLoading}
|
||||
onPress={() => {
|
||||
NavigationService.navigate('ScanQrAddress', { onBarScanned: this.props.onBarScanned });
|
||||
NavigationService.navigate('ScanQrAddress', { onBarScanned: this.props.onBarScanned, launchedBy: this.props.launchedBy });
|
||||
Keyboard.dismiss();
|
||||
}}
|
||||
style={{
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:label="@string/app_name"
|
||||
|
@ -34,6 +35,26 @@
|
|||
<data android:scheme="lapp" />
|
||||
<data android:scheme="blue" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:mimeType="application/octet-stream"
|
||||
android:host="*"
|
||||
android:pathPattern=".*\\.psbt"
|
||||
/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:mimeType="text/plain"
|
||||
android:host="*"
|
||||
android:pathPattern=".*\\.psbt"
|
||||
/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
|
||||
</application>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
221
class/deeplinkSchemaMatch.js
Normal file
221
class/deeplinkSchemaMatch.js
Normal file
|
@ -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;
|
|
@ -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?
|
||||
|
|
|
@ -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)');
|
||||
}
|
||||
|
|
|
@ -164,6 +164,7 @@
|
|||
3271B0BA236E329400DA766F /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = "<group>"; };
|
||||
32B5A3282334450100F8D608 /* BlueWallet-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "BlueWallet-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
32B5A3292334450100F8D608 /* Bridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bridge.swift; sourceTree = "<group>"; };
|
||||
32C7944323B8879D00BE2AFA /* BlueWalletRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = BlueWalletRelease.entitlements; path = BlueWallet/BlueWalletRelease.entitlements; sourceTree = "<group>"; };
|
||||
32F0A24F2310B0700095C559 /* BlueWalletWatch Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "BlueWalletWatch Extension.entitlements"; sourceTree = "<group>"; };
|
||||
32F0A2502310B0910095C559 /* BlueWallet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = BlueWallet.entitlements; path = BlueWallet/BlueWallet.entitlements; sourceTree = "<group>"; };
|
||||
32F0A2992311DBB20095C559 /* ComplicationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComplicationController.swift; sourceTree = "<group>"; };
|
||||
|
@ -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;
|
||||
|
|
18
ios/BlueWallet/BlueWalletRelease.entitlements
Normal file
18
ios/BlueWallet/BlueWalletRelease.entitlements
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudDocuments</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.io.bluewallet.bluewallet</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
|
@ -2,12 +2,29 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Light</string>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>BlueWallet</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>PSBT</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>io.bluewallet.psbt</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
@ -58,18 +75,18 @@
|
|||
</dict>
|
||||
<key>NSAppleMusicUsageDescription</key>
|
||||
<string>This alert should not show up as we do not require this data</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>In order to confirm your identity, we need your permission to use FaceID.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>This alert should not show up as we do not require this data</string>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>This alert should not show up as we do not require this data</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>In order to quickly scan the recipient's address, we need your permission to use the camera to scan their QR Code.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>This alert should not show up as we do not require this data</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>In order to confirm your identity, we need your permission to use FaceID.</string>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<string>This alert should not show up as we do not require this data</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>This alert should not show up as we do not require this data</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This alert should not show up as we do not require this data</string>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
|
@ -116,7 +133,53 @@
|
|||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Light</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>PSBT</string>
|
||||
<key>UTTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>io.bluewallet.psbt</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>psbt</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>PSBT</string>
|
||||
<key>UTTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>io.bluewallet.psbt</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>psbt</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 <BlueLoading />;
|
||||
}
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
|
||||
<SafeBlueArea forceInset={{ horizontal: 'always' }} style={{ flex: 1 }}>
|
||||
|
@ -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}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
|
@ -353,6 +357,7 @@ ScanLndInvoice.propTypes = {
|
|||
getParam: PropTypes.func,
|
||||
dismiss: PropTypes.func,
|
||||
state: PropTypes.shape({
|
||||
routeName: PropTypes.string,
|
||||
params: PropTypes.shape({
|
||||
uri: PropTypes.string,
|
||||
fromSecret: PropTypes.string,
|
||||
|
|
|
@ -38,6 +38,8 @@ import { BitcoinUnit, Chain } from '../../models/bitcoinUnits';
|
|||
import { HDLegacyP2PKHWallet, HDSegwitBech32Wallet, HDSegwitP2SHWallet, LightningCustodianWallet, WatchOnlyWallet } from '../../class';
|
||||
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
|
||||
import { BitcoinTransaction } from '../../models/bitcoinTransactionInfo';
|
||||
import DocumentPicker from 'react-native-document-picker';
|
||||
import RNFS from 'react-native-fs';
|
||||
const bitcoin = require('bitcoinjs-lib');
|
||||
const bip21 = require('bip21');
|
||||
let BigNumber = require('bignumber.js');
|
||||
|
@ -58,9 +60,14 @@ export default class SendDetails extends Component {
|
|||
title: loc.send.header,
|
||||
});
|
||||
|
||||
state = { isLoading: true };
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this._keyboardDidShow);
|
||||
this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this._keyboardDidHide);
|
||||
|
||||
let fromAddress;
|
||||
if (props.navigation.state.params) fromAddress = props.navigation.state.params.fromAddress;
|
||||
let fromSecret;
|
||||
|
@ -177,9 +184,6 @@ export default class SendDetails extends Component {
|
|||
this.renderNavigationHeader();
|
||||
console.log('send/details - componentDidMount');
|
||||
StatusBar.setBarStyle('dark-content');
|
||||
this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this._keyboardDidShow);
|
||||
this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this._keyboardDidHide);
|
||||
|
||||
let addresses = [];
|
||||
let initialMemo = '';
|
||||
if (this.props.navigation.state.params.uri) {
|
||||
|
@ -705,6 +709,36 @@ export default class SendDetails extends Component {
|
|||
);
|
||||
};
|
||||
|
||||
importTransaction = 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) {
|
||||
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 && (
|
||||
<BlueListItem title="Import Transaction" hideChevron component={TouchableOpacity} onPress={this.importTransaction} />
|
||||
)}
|
||||
{this.state.fromWallet.allowBatchSend() && (
|
||||
<>
|
||||
<BlueListItem
|
||||
|
@ -892,6 +929,7 @@ export default class SendDetails extends Component {
|
|||
address={item.address}
|
||||
isLoading={this.state.isLoading}
|
||||
inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID}
|
||||
launchedBy={this.props.navigation.state.routeName}
|
||||
/>
|
||||
{this.state.addresses.length > 1 && (
|
||||
<BlueText style={{ alignSelf: 'flex-end', marginRight: 18, marginVertical: 8 }}>
|
||||
|
@ -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,
|
||||
|
|
|
@ -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 (
|
||||
<SafeBlueArea style={{ flex: 1 }}>
|
||||
<View style={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<ScrollView centerContent contentContainerStyle={{ flexGrow: 1, justifyContent: 'space-between' }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'center', paddingTop: 16, paddingBottom: 16 }}>
|
||||
<BlueCard>
|
||||
<BlueText>This is partially signed bitcoin transaction (PSBT). Please finish signing it with your hardware wallet.</BlueText>
|
||||
<BlueSpacing20 />
|
||||
<QRCode
|
||||
value={this.state.psbt.toBase64()}
|
||||
value={this.state.isFirstPSBTAlreadyBase64 ? this.state.psbt : this.state.psbt.toBase64()}
|
||||
size={this.state.qrCodeHeight}
|
||||
color={BlueApp.settings.foregroundColor}
|
||||
logoBackgroundColor={BlueApp.settings.brandingColor}
|
||||
ecl={'L'}
|
||||
/>
|
||||
<BlueSpacing20 />
|
||||
<BlueButton onPress={() => this.setState({ renderScanner: true })} title={'Scan signed transaction'} />
|
||||
<BlueButton
|
||||
icon={{
|
||||
name: 'qrcode',
|
||||
type: 'font-awesome',
|
||||
color: BlueApp.settings.buttonTextColor,
|
||||
}}
|
||||
onPress={() => this.setState({ renderScanner: true })}
|
||||
title={'Scan Signed Transaction'}
|
||||
/>
|
||||
<BlueSpacing20 />
|
||||
<BlueButton
|
||||
icon={{
|
||||
name: 'file-import',
|
||||
type: 'material-community',
|
||||
color: BlueApp.settings.buttonTextColor,
|
||||
}}
|
||||
onPress={this.openSignedTransaction}
|
||||
title={'Open Signed Transaction'}
|
||||
/>
|
||||
<BlueSpacing20 />
|
||||
<BlueButton
|
||||
icon={{
|
||||
name: 'share-alternative',
|
||||
type: 'entypo',
|
||||
color: BlueApp.settings.buttonTextColor,
|
||||
}}
|
||||
onPress={this.exportPSBT}
|
||||
title={'Export'}
|
||||
/>
|
||||
<BlueSpacing20 />
|
||||
<View style={{ justifyContent: 'center', alignItems: 'center' }}>
|
||||
<BlueCopyToClipboardButton stringToCopy={this.state.psbt.toBase64()} displayText={'Copy to Clipboard'} />
|
||||
<BlueCopyToClipboardButton
|
||||
stringToCopy={this.state.isFirstPSBTAlreadyBase64 ? this.state.psbt : this.state.psbt.toBase64()}
|
||||
displayText={'Copy to Clipboard'}
|
||||
/>
|
||||
</View>
|
||||
</BlueCard>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeBlueArea>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<SafeBlueArea style={{ flex: 1 }}>
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||||
{!cameraPreviewIsPaused && !isLoading && (
|
||||
<RNCamera
|
||||
captureAudio={false}
|
||||
androidCameraPermissionOptions={{
|
||||
|
@ -33,11 +72,12 @@ export default class ScanQRCode extends React.Component {
|
|||
buttonPositive: 'OK',
|
||||
buttonNegative: 'Cancel',
|
||||
}}
|
||||
ref={ref => (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 && (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
width: 40,
|
||||
|
@ -49,23 +89,25 @@ export default class ScanQRCode extends React.Component {
|
|||
right: 16,
|
||||
top: 64,
|
||||
}}
|
||||
onPress={() => this.props.navigation.goBack(null)}
|
||||
onPress={() => navigate(launchedBy)}
|
||||
>
|
||||
<Image style={{ alignSelf: 'center' }} source={require('../../img/close-white.png')} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: '#FFFFFF',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
position: 'absolute',
|
||||
left: 24,
|
||||
bottom: 48,
|
||||
}}
|
||||
onPress={() => {
|
||||
if (RNCamera.Constants.CameraStatus === RNCamera.Constants.CameraStatus.READY) this.cameraRef.pausePreview();
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: '#FFFFFF',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
position: 'absolute',
|
||||
left: 24,
|
||||
bottom: 48,
|
||||
}}
|
||||
onPress={() => {
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="image" type="font-awesome" color="#0c2550" />
|
||||
</TouchableOpacity>
|
||||
{showFileImportButton && (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: '#FFFFFF',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
position: 'absolute',
|
||||
left: 96,
|
||||
bottom: 48,
|
||||
}}
|
||||
onPress={showFilePicker}
|
||||
>
|
||||
<Icon name="image" type="font-awesome" color="#0c2550" />
|
||||
<Icon name="file-import" type="material-community" color="#0c2550" />
|
||||
</TouchableOpacity>
|
||||
</SafeBlueArea>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ScanQRCode.propTypes = {
|
||||
navigation: PropTypes.shape({
|
||||
goBack: PropTypes.func,
|
||||
dismiss: PropTypes.func,
|
||||
getParam: PropTypes.func,
|
||||
}),
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
ScanQRCode.navigationOptions = {
|
||||
header: null,
|
||||
};
|
||||
ScanQRCode.propTypes = {
|
||||
launchedBy: PropTypes.string,
|
||||
onBarScanned: PropTypes.func,
|
||||
cameraPreviewIsPaused: PropTypes.bool,
|
||||
showFileImportButton: PropTypes.bool,
|
||||
showCloseButton: PropTypes.bool,
|
||||
};
|
||||
export default ScanQRCode;
|
||||
|
|
|
@ -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 (
|
||||
<SafeBlueArea style={{ flex: 1 }}>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
|
||||
<BlueCard style={{ alignItems: 'center', flex: 1 }}>
|
||||
{(() => {
|
||||
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"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<BlueSpacing20 />
|
||||
<Text style={{ color: '#0c2550', fontWeight: '500', fontSize: 14, marginVertical: 12 }}>
|
||||
{loc.wallets.details.type.toLowerCase()}
|
||||
</Text>
|
||||
|
@ -182,15 +187,48 @@ export default class WalletDetails extends Component {
|
|||
)}
|
||||
<View>
|
||||
<BlueSpacing20 />
|
||||
|
||||
{this.state.wallet.type === WatchOnlyWallet.type && this.state.wallet.getSecret().startsWith('zpub') && (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<Text style={{ color: '#0c2550', fontWeight: '500', fontSize: 14, marginVertical: 16 }}>{'advanced'}</Text>
|
||||
<BlueText>Master Fingerprint</BlueText>
|
||||
<BlueSpacing20 />
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
borderColor: '#d2d2d2',
|
||||
borderBottomColor: '#d2d2d2',
|
||||
borderWidth: 1.0,
|
||||
borderBottomWidth: 0.5,
|
||||
backgroundColor: '#f5f5f5',
|
||||
minHeight: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Master Fingerprint"
|
||||
value={this.state.masterFingerprint}
|
||||
onChangeText={text => {
|
||||
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"
|
||||
/>
|
||||
</View>
|
||||
<BlueSpacing20 />
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<BlueText>{'Use with hardware wallet'}</BlueText>
|
||||
<Switch value={this.state.useWithHardwareWallet} onValueChange={value => this.onUseWithHardwareWalletSwitch(value)} />
|
||||
</View>
|
||||
<BlueSpacing20 />
|
||||
</React.Fragment>
|
||||
</>
|
||||
)}
|
||||
|
||||
<BlueButton
|
||||
|
@ -285,7 +323,7 @@ export default class WalletDetails extends Component {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</BlueCard>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</TouchableWithoutFeedback>
|
||||
</SafeBlueArea>
|
||||
);
|
||||
|
|
|
@ -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 = () => {
|
|||
<BlueButtonLink
|
||||
title={loc.wallets.import.scan_qr}
|
||||
onPress={() => {
|
||||
navigate('ScanQrAddress', { onBarScanned });
|
||||
navigate('ScanQrAddress', { launchedBy: 'ImportWallet', onBarScanned, showFileImportButton: true });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
|
|
@ -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: (
|
||||
<TouchableOpacity
|
||||
style={{ marginHorizontal: 16, width: 40, height: 40, justifyContent: 'center', alignItems: 'flex-end' }}
|
||||
onPress={() => navigation.navigate('Settings')}
|
||||
>
|
||||
<Icon size={22} name="kebab-horizontal" type="octicon" color={BlueApp.settings.foregroundColor} />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
|
||||
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 <BlueTransactionListItem item={data.item} itemPriceUnit={data.item.walletPreferredBalanceUnit} />;
|
||||
};
|
||||
|
||||
renderNavigationHeader = () => {
|
||||
return (
|
||||
<View style={{ height: 44, alignItems: 'flex-end', justifyContent: 'center' }}>
|
||||
<TouchableOpacity style={{ marginHorizontal: 16 }} onPress={() => this.props.navigation.navigate('Settings')}>
|
||||
<Icon size={22} name="kebab-horizontal" type="octicon" color={BlueApp.settings.foregroundColor} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.isLoading) {
|
||||
return <BlueLoading />;
|
||||
}
|
||||
return (
|
||||
<SafeBlueArea style={{ flex: 1, backgroundColor: '#FFFFFF' }}>
|
||||
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||||
<NavigationEvents
|
||||
onWillFocus={() => {
|
||||
onDidFocus={() => {
|
||||
this.redrawScreen();
|
||||
this.setState({ cameraPreviewIsPaused: this.swiperRef.current.index === 1 });
|
||||
}}
|
||||
onWillBlur={() => this.setState({ cameraPreviewIsPaused: true })}
|
||||
/>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ flex: 1 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
onRefresh={() => this.refreshTransactions()}
|
||||
|
@ -322,65 +347,112 @@ export default class WalletsList extends Component {
|
|||
/>
|
||||
}
|
||||
>
|
||||
<BlueHeaderDefaultMain
|
||||
leftText={loc.wallets.list.title}
|
||||
onNewWalletPress={
|
||||
!BlueApp.getWallets().some(wallet => wallet.type === PlaceholderWallet.type)
|
||||
? () => this.props.navigation.navigate('AddWallet')
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<WalletsCarousel
|
||||
removeClippedSubviews={false}
|
||||
data={this.state.wallets}
|
||||
handleClick={index => {
|
||||
this.handleClick(index);
|
||||
}}
|
||||
handleLongPress={this.handleLongPress}
|
||||
onSnapToItem={index => {
|
||||
this.onSnapToItem(index);
|
||||
}}
|
||||
ref={c => (this.walletsCarousel = c)}
|
||||
/>
|
||||
<BlueList>
|
||||
<FlatList
|
||||
ListHeaderComponent={this.renderListHeaderComponent}
|
||||
ListEmptyComponent={
|
||||
<View style={{ top: 50, height: 100 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: '#9aa0aa',
|
||||
textAlign: 'center',
|
||||
<Swiper
|
||||
style={styles.wrapper}
|
||||
onIndexChanged={this.onSwiperIndexChanged}
|
||||
index={1}
|
||||
ref={this.swiperRef}
|
||||
showsPagination={false}
|
||||
showsButtons={false}
|
||||
loop={false}
|
||||
>
|
||||
<View style={styles.scanQRWrapper}>
|
||||
<ScanQRCode
|
||||
cameraPreviewIsPaused={this.state.cameraPreviewIsPaused}
|
||||
onBarScanned={this.onBarScanned}
|
||||
showCloseButton={false}
|
||||
initialCameraStatusReady={false}
|
||||
launchedBy={this.props.navigation.state.routeName}
|
||||
/>
|
||||
</View>
|
||||
<SafeBlueArea>
|
||||
<View style={styles.walletsListWrapper}>
|
||||
{this.renderNavigationHeader()}
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<RefreshControl onRefresh={() => this.refreshTransactions()} refreshing={!this.state.isFlatListRefreshControlHidden} />
|
||||
}
|
||||
>
|
||||
<BlueHeaderDefaultMain
|
||||
leftText={loc.wallets.list.title}
|
||||
onNewWalletPress={
|
||||
!BlueApp.getWallets().some(wallet => wallet.type === PlaceholderWallet.type)
|
||||
? () => this.props.navigation.navigate('AddWallet')
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<WalletsCarousel
|
||||
removeClippedSubviews={false}
|
||||
data={this.state.wallets}
|
||||
handleClick={index => {
|
||||
this.handleClick(index);
|
||||
}}
|
||||
>
|
||||
{loc.wallets.list.empty_txs1}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: '#9aa0aa',
|
||||
textAlign: 'center',
|
||||
handleLongPress={this.handleLongPress}
|
||||
onSnapToItem={index => {
|
||||
this.onSnapToItem(index);
|
||||
}}
|
||||
>
|
||||
{loc.wallets.list.empty_txs2}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
data={this.state.dataSource}
|
||||
extraData={this.state.dataSource}
|
||||
keyExtractor={this._keyExtractor}
|
||||
renderItem={this._renderItem}
|
||||
/>
|
||||
</BlueList>
|
||||
ref={c => (this.walletsCarousel = c)}
|
||||
/>
|
||||
<BlueList>
|
||||
<FlatList
|
||||
ListHeaderComponent={this.renderListHeaderComponent}
|
||||
ListEmptyComponent={
|
||||
<View style={{ top: 50, height: 100 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: '#9aa0aa',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{loc.wallets.list.empty_txs1}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: '#9aa0aa',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{loc.wallets.list.empty_txs2}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
data={this.state.dataSource}
|
||||
extraData={this.state.dataSource}
|
||||
keyExtractor={this._keyExtractor}
|
||||
renderItem={this._renderItem}
|
||||
/>
|
||||
</BlueList>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeBlueArea>
|
||||
</Swiper>
|
||||
</ScrollView>
|
||||
</SafeBlueArea>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<BlueTransactionListItem
|
||||
|
@ -569,18 +578,45 @@ export default class WalletTransactions extends Component {
|
|||
})()}
|
||||
|
||||
{(() => {
|
||||
if (this.state.wallet.allowSend()) {
|
||||
if (
|
||||
this.state.wallet.allowSend() ||
|
||||
(this.state.wallet._hdWalletInstance instanceof HDSegwitBech32Wallet && this.state.wallet._hdWalletInstance.allowSend())
|
||||
) {
|
||||
return (
|
||||
<BlueSendButtonIcon
|
||||
onPress={() => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
50
tests/integration/deepLinkSchemaMatch.test.js
Normal file
50
tests/integration/deepLinkSchemaMatch.test.js
Normal file
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue