BlueWallet/App.js
Tristan Edwards 298aba26f4 Support Safello redirect URLs
When the user buys bitcoin through Safello, they will be redirected to
the Yoti app to verify their identity.

Previously, when the process was done, they
would be redirected to a browser window to continue their purchase, but
since bluewallet has a `bluewallet://` redirect URI associated to its
app ID, we can redirect them back to the app with a
`safello-state-token` query parameter appended to the URI instead.

Bluewallet then simply needs to check if that query parameter is
present, and if so, open a new WebView with the same Safello URL +
`stateToken=xxx` in order to resume the user's session. This way, the
whole buying process can take place inside Bluewallet's app, which
is a clear improvement in terms of user experience.
2019-05-09 12:17:01 +02:00

292 lines
9.4 KiB
JavaScript

import React from 'react';
import { Linking, 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';
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 {
navigator = null;
state = {
appState: AppState.currentState,
isClipboardContentModalVisible: false,
clipboardContentModalAddressType: bitcoinModalString,
clipboardContent: '',
};
componentDidMount() {
Linking.getInitialURL()
.then(url => {
if (this.hasSchema(url)) {
this.handleOpenURL({ url });
}
})
.catch(console.error);
Linking.addEventListener('url', this.handleOpenURL);
AppState.addEventListener('change', this._handleAppStateChange);
}
componentWillUnmount() {
Linking.removeEventListener('url', this.handleOpenURL);
AppState.removeEventListener('change', this._handleAppStateChange);
}
_handleAppStateChange = async nextAppState => {
if (BlueApp.getWallets().length > 0) {
if (this.state.appState.match(/inactive|background/) && nextAppState === 'active') {
const clipboard = await Clipboard.getString();
if (this.state.clipboardContent !== clipboard && (this.isBitcoinAddress(clipboard) || this.isLightningInvoice(clipboard))) {
this.setState({ isClipboardContentModalVisible: true });
}
this.setState({ clipboardContent: clipboard });
}
this.setState({ appState: nextAppState });
}
};
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) {
let isValidBitcoinAddress = false;
try {
bitcoin.address.toOutputScript(address);
isValidBitcoinAddress = true;
this.setState({ clipboardContentModalAddressType: bitcoinModalString });
} catch (err) {
isValidBitcoinAddress = false;
}
if (!isValidBitcoinAddress) {
if (address.indexOf('bitcoin:') === 0 || address.indexOf('BITCOIN:') === 0) {
isValidBitcoinAddress = true;
this.setState({ clipboardContentModalAddressType: bitcoinModalString });
}
}
return isValidBitcoinAddress;
}
isLightningInvoice(invoice) {
let isValidLightningInvoice = false;
if (invoice.indexOf('lightning:lnb') === 0 || invoice.indexOf('LIGHTNING:lnb') === 0 || invoice.toLowerCase().startsWith('lnb')) {
this.setState({ clipboardContentModalAddressType: lightningModalString });
isValidLightningInvoice = true;
}
return isValidLightningInvoice;
}
isSafelloRedirect(event) {
let urlObject = url.parse(event.url, true) // eslint-disable-line
return !!urlObject.query["safello-state-token"]
}
handleOpenURL = event => {
if (event.url === null) {
return;
}
if (typeof event.url !== 'string') {
return;
}
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.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;
}
}
})();
}
};
renderClipboardContentModal = () => {
return (
<Modal
onModalShow={() => ReactNativeHapticFeedback.trigger('impactLight', { ignoreAndroidSystemSettings: false })}
isVisible={this.state.isClipboardContentModalVisible}
style={styles.bottomModal}
onBackdropPress={() => {
this.setState({ isClipboardContentModalVisible: false });
}}
>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'position' : null}>
<View style={styles.modalContent}>
<BlueTextCentered>
You have a {this.state.clipboardContentModalAddressType} on your clipboard. Would you like to use it for a transaction?
</BlueTextCentered>
<View style={styles.modelContentButtonLayout}>
<BlueButton
noMinWidth
title={loc.send.details.cancel}
onPress={() => this.setState({ isClipboardContentModalVisible: false })}
/>
<View style={{ marginHorizontal: 8 }} />
<BlueButton
noMinWidth
title="OK"
onPress={() => {
this.setState({ isClipboardContentModalVisible: false }, async () => {
const clipboard = await Clipboard.getString();
setTimeout(() => this.handleOpenURL({ url: clipboard }), 100);
});
}}
/>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
};
render() {
return (
<View style={{ flex: 1 }}>
<MainBottomTabs
ref={nav => {
this.navigator = nav;
NavigationService.setTopLevelNavigator(nav);
}}
/>
{this.renderClipboardContentModal()}
</View>
);
}
}
const styles = StyleSheet.create({
modalContent: {
backgroundColor: '#FFFFFF',
padding: 22,
justifyContent: 'center',
alignItems: 'center',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderColor: 'rgba(0, 0, 0, 0.1)',
minHeight: 200,
height: 200,
},
bottomModal: {
justifyContent: 'flex-end',
margin: 0,
},
modelContentButtonLayout: {
flexDirection: 'row',
margin: 16,
justifyContent: 'space-between',
alignItems: 'flex-end',
},
});