1
0
mirror of https://github.com/trezor/trezor-wallet synced 2024-11-13 20:08:56 +00:00

Merge pull request #340 from trezor/feature/qr-scanner

Feature/QR scanner
This commit is contained in:
Vladimir Volek 2019-01-22 15:26:01 +01:00 committed by GitHub
commit 9f911ff8b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 347 additions and 0 deletions

View File

@ -54,6 +54,7 @@
"react-dom": "^16.6.3",
"react-hot-loader": "^4.6.2",
"react-json-view": "^1.19.1",
"react-qr-reader": "^2.1.2",
"react-qr-svg": "^2.1.0",
"react-redux": "^6.0.0",
"react-router": "^4.3.1",

View File

@ -1,3 +1,4 @@
/* eslint-disable import/no-named-as-default-member */
/* @flow */
import TrezorConnect, { UI } from 'trezor-connect';
@ -9,6 +10,10 @@ import type {
ThunkAction, AsyncAction, Action, GetState, Dispatch, TrezorDevice,
} from 'flowtype';
import type { State } from 'reducers/ModalReducer';
import type { parsedURI } from 'utils/cryptoUriParser';
import sendEthereumFormActions from './ethereum/SendFormActions';
import sendRippleFormActions from './ripple/SendFormActions';
export type ModalAction = {
type: typeof MODAL.CLOSE
@ -16,8 +21,11 @@ export type ModalAction = {
type: typeof MODAL.OPEN_EXTERNAL_WALLET,
id: string,
url: string,
} | {
type: typeof MODAL.OPEN_SCAN_QR,
};
export const onPinSubmit = (value: string): Action => {
TrezorConnect.uiResponse({ type: UI.RECEIVE_PIN, payload: value });
return {
@ -139,6 +147,29 @@ export const gotoExternalWallet = (id: string, url: string): ThunkAction => (dis
});
};
export const openQrModal = (): ThunkAction => (dispatch: Dispatch): void => {
dispatch({
type: MODAL.OPEN_SCAN_QR,
});
};
export const onQrScan = (parsedUri: parsedURI, networkType: string): ThunkAction => (dispatch: Dispatch): void => {
const { address = '', amount } = parsedUri;
switch (networkType) {
case 'ethereum':
dispatch(sendEthereumFormActions.onAddressChange(address));
if (amount) dispatch(sendEthereumFormActions.onAmountChange(amount));
break;
case 'ripple':
dispatch(sendRippleFormActions.onAddressChange(address));
if (amount) dispatch(sendRippleFormActions.onAmountChange(amount));
break;
default:
break;
}
};
export default {
onPinSubmit,
onPassphraseSubmit,
@ -149,4 +180,6 @@ export default {
onDuplicateDevice,
onWalletTypeRequest,
gotoExternalWallet,
openQrModal,
onQrScan,
};

View File

@ -5,3 +5,5 @@ export const OPEN_EXTERNAL_WALLET: 'modal__external_wallet' = 'modal__external_w
export const CONTEXT_NONE: 'modal_ctx_none' = 'modal_ctx_none';
export const CONTEXT_DEVICE: 'modal_ctx_device' = 'modal_ctx_device';
export const CONTEXT_EXTERNAL_WALLET: 'modal_ctx_external-wallet' = 'modal_ctx_external-wallet';
export const OPEN_SCAN_QR: 'modal__open_scan_qr' = 'modal__open_scan_qr';
export const CONTEXT_SCAN_QR: 'modal__ctx_scan_qr' = 'modal__ctx_scan_qr';

View File

@ -0,0 +1,159 @@
/* @flow */
import * as React from 'react';
import PropTypes from 'prop-types';
import QrReader from 'react-qr-reader';
import styled from 'styled-components';
import colors from 'config/colors';
import icons from 'config/icons';
import { H2 } from 'components/Heading';
import P from 'components/Paragraph';
import Icon from 'components/Icon';
import Link from 'components/Link';
import { parseUri } from 'utils/cryptoUriParser';
import type { parsedURI } from 'utils/cryptoUriParser';
import type { Props as BaseProps } from '../Container';
const Wrapper = styled.div`
width: 90vw;
max-width: 450px;
padding: 30px 0px;
`;
const Padding = styled.div`
padding: 0px 48px;
`;
const CloseLink = styled(Link)`
position: absolute;
right: 15px;
top: 15px;
`;
const CameraPlaceholder = styled(P)`
text-align: center;
padding: 10px 0;
`;
const Error = styled(P)`
text-align: center;
padding: 10px 0;
color: ${colors.ERROR_PRIMARY};
`;
const StyledQrReader = styled(QrReader)`
padding: 10px 0;
`;
// TODO fix types
type Props = {
onScan: (data: parsedURI) => any,
onError?: (error: any) => any,
onCancel?: $ElementType<$ElementType<BaseProps, 'modalActions'>, 'onCancel'>;
}
type State = {
readerLoaded: boolean,
error: any,
};
class QrModal extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
readerLoaded: false,
error: null,
};
}
onLoad = () => {
this.setState({
readerLoaded: true,
});
}
handleScan = (data: string) => {
if (data) {
try {
const parsedUri = parseUri(data);
if (parsedUri) {
this.props.onScan(parsedUri);
// reset error
this.setState({
error: null,
});
// close window
this.handleCancel();
}
} catch (error) {
this.handleError(error);
}
}
}
handleError = (err: any) => {
console.log(err);
this.setState({
error: err,
});
if (this.props.onError) {
this.props.onError(err);
}
}
handleCancel = () => {
if (this.props.onCancel) {
this.props.onCancel();
}
}
render() {
return (
<Wrapper>
<CloseLink onClick={this.handleCancel}>
<Icon
size={24}
color={colors.TEXT_SECONDARY}
icon={icons.CLOSE}
/>
</CloseLink>
<Padding>
<H2>Scan an address from a QR code</H2>
{!this.state.readerLoaded && (
<CameraPlaceholder>
Waiting for camera...
</CameraPlaceholder>)
}
</Padding>
<StyledQrReader
delay={500}
onError={this.handleError}
onScan={this.handleScan}
onLoad={this.onLoad}
style={{ width: '100%' }}
showViewFinder={false}
/>
<Padding>
{this.state.error && (
<Error>
{this.state.error.toString()}
</Error>
)}
</Padding>
</Wrapper>
);
}
}
QrModal.propTypes = {
onScan: PropTypes.func.isRequired,
onError: PropTypes.func,
onCancel: PropTypes.func,
};
export default QrModal;

View File

@ -29,6 +29,8 @@ import Nem from 'components/modals/external/Nem';
import Cardano from 'components/modals/external/Cardano';
import Stellar from 'components/modals/external/Stellar';
import QrModal from 'components/modals/QrModal';
import type { Props } from './Container';
const ModalContainer = styled.div`
@ -166,6 +168,20 @@ const getExternalContextModal = (props: Props) => {
}
};
const getQrModal = (props: Props) => {
const { modalActions, selectedAccount } = props;
if (!selectedAccount.network) return null;
const networkType = selectedAccount.network.type;
return (
<QrModal
onCancel={modalActions.onCancel}
onScan={parsedUri => modalActions.onQrScan(parsedUri, networkType)}
/>
);
};
// modal container component
const Modal = (props: Props) => {
const { modal } = props;
@ -179,6 +195,9 @@ const Modal = (props: Props) => {
case MODAL.CONTEXT_EXTERNAL_WALLET:
component = getExternalContextModal(props);
break;
case MODAL.CONTEXT_SCAN_QR:
component = getQrModal(props);
break;
default:
break;
}

View File

@ -67,6 +67,9 @@ export default {
SUCCESS: [
'M692.8 313.92l-1.92-1.92c-6.246-7.057-15.326-11.484-25.44-11.484s-19.194 4.427-25.409 11.448l-0.031 0.036-196.48 224-3.84 1.6-3.84-1.92-48.64-57.28c-7.010-7.905-17.193-12.862-28.533-12.862-21.031 0-38.080 17.049-38.080 38.080 0 7.495 2.165 14.485 5.905 20.377l-0.092-0.155 100.8 148.16c5.391 8.036 14.386 13.292 24.618 13.44h8.662c17.251-0.146 32.385-9.075 41.163-22.529l0.117-0.191 195.2-296.32c4.473-6.632 7.141-14.803 7.141-23.597 0-11.162-4.297-21.32-11.326-28.911l0.025 0.028z',
],
QRCODE: [
'M832 1024l-64 0l0 -128l64 0l0 128Zm-320 0l-64 0l0 -128l64 0l0 128Zm192 0l-128 0l0 -128l128 0l0 128Zm192 -192l64 0l0 64l64 0l0 128l-128 0l0 -192Zm-896 -192l384 0l0 384l-384 0l0 -384Zm320 320l0 -256l-256 0l0 256l256 0Zm-64 -64l-128 0l0 -128l128 0l0 128Zm512 0l-64 0l0 -64l64 0l0 64Zm-192 -128l0 128l-64 0l0 -64l-64 0l0 -64l128 0Zm128 64l-64 0l0 -64l64 0l0 64Zm192 0l-128 0l0 -64l128 0l0 64Zm-256 -64l-64 0l0 -64l64 0l0 64Zm320 -64l-64 0l0 -64l128 0l0 128l-64 0l0 -64Zm-384 0l-128 0l0 -128l128 0l0 128Zm64 -64l64 0l0 -64l128 0l0 128l-192 0l0 -64Zm-320 -128l64 0l0 -64l64 0l0 128l-128 0l0 -64Zm256 0l-64 0l0 -64l192 0l0 128l-128 0l0 -64Zm-576 -64l128 0l0 64l64 0l0 64l-192 0l0 -128Zm896 64l-128 0l0 -64l256 0l0 128l-128 0l0 -64Zm-576 0l-128 0l0 -64l128 0l0 64Zm192 -64l-64 0l0 -64l64 0l0 64Zm-512 -448l384 0l0 384l-384 0l0 -384Zm576 384l-64 0l0 -128l64 0l0 128Zm64 -384l384 0l0 384l-384 0l0 -384Zm-320 320l0 -256l-256 0l0 256l256 0Zm640 0l0 -256l-256 0l0 256l256 0Zm-704 -64l-128 0l0 -128l128 0l0 128Zm640 0l-128 0l0 -128l128 0l0 128Zm-384 -256l0 64l64 0l0 128l-64 0l0 64l-64 0l0 -256l64 0Z',
],
};
/*

View File

@ -18,6 +18,8 @@ export type State = {
} | {
context: typeof MODAL.CONTEXT_EXTERNAL_WALLET,
windowType?: string;
} | {
context: typeof MODAL.CONTEXT_SCAN_QR,
}
const initialState: State = {
@ -91,6 +93,11 @@ export default function modal(state: State = initialState, action: Action): Stat
windowType: action.id,
};
case MODAL.OPEN_SCAN_QR:
return {
context: MODAL.CONTEXT_SCAN_QR,
};
default:
return state;
}

View File

@ -0,0 +1,39 @@
/* eslint-disable no-param-reassign */
/* eslint-disable prefer-destructuring */
/* @flow */
// copy paste from mytrezor (old wallet) https://github.com/satoshilabs/mytrezor/blob/87f8a8d9ca82a27b3941c5ec0f399079903f2bfd/app/components/address-input/address-input.js
export type parsedURI = {
address: string,
amount: ?string,
};
// Parse a string read from a bitcoin QR code into an object
export const parseUri = (uri: string): ?parsedURI => {
const str = stripPrefix(uri);
const query: Array<string> = str.split('?');
const values: Object = (query.length > 1) ? parseQuery(query[1]) : {};
values.address = query[0];
return values;
};
const stripPrefix = (str: string): string => {
if (!str.match(':')) {
return str;
}
const parts = str.split(':');
parts.shift();
return parts.join('');
};
// Parse URL query string (like 'foo=bar&baz=1337) into an object
const parseQuery = (str: string): {} => str.split('&')
.map(val => val.split('='))
.reduce((vals, pair) => {
if (pair.length > 1) {
vals[pair[0]] = pair[1];
}
return vals;
}, {});

View File

@ -4,6 +4,7 @@ import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import SendFormActions from 'actions/ethereum/SendFormActions';
import { openQrModal } from 'actions/ModalActions';
import type { MapStateToProps, MapDispatchToProps } from 'react-redux';
import type { State, Dispatch } from 'flowtype';
import AccountSend from './index';
@ -20,6 +21,7 @@ export type StateProps = {
export type DispatchProps = {
sendFormActions: typeof SendFormActions,
openQrModal: typeof openQrModal,
}
export type Props = StateProps & DispatchProps;
@ -34,6 +36,8 @@ const mapStateToProps: MapStateToProps<State, OwnProps, StateProps> = (state: St
const mapDispatchToProps: MapDispatchToProps<Dispatch, OwnProps, DispatchProps> = (dispatch: Dispatch): DispatchProps => ({
sendFormActions: bindActionCreators(SendFormActions, dispatch),
openQrModal: bindActionCreators(openQrModal, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(AccountSend);

View File

@ -175,6 +175,14 @@ const AdvancedSettingsIcon = styled(Icon)`
margin-left: 10px;
`;
const QrButton = styled(Button)`
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-left: 0px;
height: 40px;
padding: 0 10px;
`;
// render helpers
const getAddressInputState = (address: string, addressErrors: string, addressWarnings: string): string => {
let state = '';
@ -298,6 +306,19 @@ const AccountSend = (props: Props) => {
bottomText={errors.address || warnings.address || infos.address}
value={address}
onChange={event => onAddressChange(event.target.value)}
sideAddons={[(
<QrButton
key="qrButton"
isWhite
onClick={props.openQrModal}
>
<Icon
size={25}
color={colors.TEXT_SECONDARY}
icon={ICONS.QRCODE}
/>
</QrButton>
)]}
/>
</InputRow>
<InputRow>

View File

@ -4,6 +4,7 @@ import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import SendFormActions from 'actions/ripple/SendFormActions';
import { openQrModal } from 'actions/ModalActions';
import type { MapStateToProps, MapDispatchToProps } from 'react-redux';
import type { State, Dispatch } from 'flowtype';
import AccountSend from './index';
@ -20,6 +21,7 @@ export type StateProps = {
export type DispatchProps = {
sendFormActions: typeof SendFormActions,
openQrModal: typeof openQrModal,
}
export type Props = StateProps & DispatchProps;
@ -34,6 +36,7 @@ const mapStateToProps: MapStateToProps<State, OwnProps, StateProps> = (state: St
const mapDispatchToProps: MapDispatchToProps<Dispatch, OwnProps, DispatchProps> = (dispatch: Dispatch): DispatchProps => ({
sendFormActions: bindActionCreators(SendFormActions, dispatch),
openQrModal: bindActionCreators(openQrModal, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(AccountSend);

View File

@ -172,6 +172,15 @@ const AdvancedSettingsIcon = styled(Icon)`
margin-left: 10px;
`;
const QrButton = styled(Button)`
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-left: 0px;
height: 40px;
padding: 0 10px;
`;
// render helpers
const getAddressInputState = (address: string, addressErrors: string, addressWarnings: string): string => {
let state = '';
@ -271,6 +280,19 @@ const AccountSend = (props: Props) => {
bottomText={errors.address || warnings.address || infos.address}
value={address}
onChange={event => onAddressChange(event.target.value)}
sideAddons={[(
<QrButton
key="qrButton"
isWhite
onClick={props.openQrModal}
>
<Icon
size={25}
color={colors.TEXT_SECONDARY}
icon={ICONS.QRCODE}
/>
</QrButton>
)]}
/>
</InputRow>
<InputRow>

View File

@ -5996,6 +5996,11 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
jsqr@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/jsqr/-/jsqr-1.1.1.tgz#a0d7f95e6c3b0bec913dfef2ca64a877f28ed05f"
integrity sha512-FVoMU2ncTyjaOqN/vwvDnZ7jaAVvFzM3LK3vG3jvQZFWJQlAwJ1XTCOgAEKo+4Rkd6ydMXTTvqGV/4w5VunmTw==
jssha@2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/jssha/-/jssha-2.3.1.tgz#147b2125369035ca4b2f7d210dc539f009b3de9a"
@ -7968,6 +7973,15 @@ react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
react-qr-reader@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/react-qr-reader/-/react-qr-reader-2.1.2.tgz#ff3d7d377d3ffbd9d78a0947ca04221c2f180e15"
integrity sha512-SyRrRRRS7XcIyX8x6tb+mgcMqYZw6Admf4vlnUO/Z21nJklf6WILmP4jstd1W5tNlonvuC/S8R8/doIuZBgVjA==
dependencies:
jsqr "^1.1.1"
prop-types "^15.5.8"
webrtc-adapter "^6.4.0"
react-qr-svg@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/react-qr-svg/-/react-qr-svg-2.1.0.tgz#fceaf0e8bd367f714d89ae46e46b87c5201a99e9"
@ -8652,6 +8666,13 @@ rsvp@^3.3.3:
version "3.6.2"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a"
rtcpeerconnection-shim@^1.2.14:
version "1.2.15"
resolved "https://registry.yarnpkg.com/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz#e7cc189a81b435324c4949aa3dfb51888684b243"
integrity sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==
dependencies:
sdp "^2.6.0"
run-async@^2.0.0, run-async@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
@ -8754,6 +8775,11 @@ scryptsy@^1.2.1:
dependencies:
pbkdf2 "^3.0.3"
sdp@^2.6.0, sdp@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/sdp/-/sdp-2.9.0.tgz#2eed2d9c0b26c81ff87593107895c68d6fb9a0a6"
integrity sha512-XAVZQO4qsfzVTHorF49zCpkdxiGmPNjA8ps8RcJGtGP3QJ/A8I9/SVg/QnkAFDMXIyGbHZBBFwYBw6WdnhT96w==
seamless-immutable@^7.1.3:
version "7.1.4"
resolved "https://registry.yarnpkg.com/seamless-immutable/-/seamless-immutable-7.1.4.tgz#6e9536def083ddc4dea0207d722e0e80d0f372f8"
@ -10654,6 +10680,14 @@ webpack@^4.16.3:
watchpack "^1.5.0"
webpack-sources "^1.0.1"
webrtc-adapter@^6.4.0:
version "6.4.8"
resolved "https://registry.yarnpkg.com/webrtc-adapter/-/webrtc-adapter-6.4.8.tgz#eeca3f0d5b40c0e629b865ef2a936a0b658274de"
integrity sha512-YM8yl545c/JhYcjGHgaCoA7jRK/KZuMwEDFeP2AcP0Auv5awEd+gZE0hXy9z7Ed3p9HvAXp8jdbe+4ESb1zxAw==
dependencies:
rtcpeerconnection-shim "^1.2.14"
sdp "^2.9.0"
websocket-driver@>=0.5.1:
version "0.7.0"
resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb"