add qr scanning support

This commit is contained in:
Craig Raw 2020-08-04 12:47:29 +02:00
parent 709c65ec20
commit c9c00cc74e
14 changed files with 448 additions and 6 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -12,6 +12,8 @@ sourceCompatibility = 1.9
repositories {
mavenCentral()
maven { url 'https://oss.sonatype.org/content/groups/public' }
maven { url 'https://mymavenrepo.com/repo/29EACwkkGcoOKnbx3bxN/' }
}
javafx {
@ -38,6 +40,10 @@ dependencies {
exclude group: 'org.slf4j'
}
implementation('co.nstant.in:cbor:0.9')
implementation('com.nativelibs4java:bridj:0.7-20200803')
implementation('com.github.sarxos:webcam-capture:0.3.13-SNAPSHOT') {
exclude group: 'com.nativelibs4java', module: 'bridj'
}
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
implementation('org.controlsfx:controlsfx:11.0.1' ) {
exclude group: 'org.openjfx', module: 'javafx-base'

2
drongo

@ -1 +1 @@
Subproject commit 8d49cebcaca6ccb2ea699fe8141554d1470d0a97
Subproject commit 97cf49276a5c87425682a3fd0e48ffe081ff71bb

View file

@ -409,6 +409,28 @@ public class AppController implements Initializable {
}
}
public void openTransactionFromQR(ActionEvent event) {
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.transaction != null) {
Tab tab = addTransactionTab(null, result.transaction);
tabs.getSelectionModel().select(tab);
}
if(result.psbt != null) {
Tab tab = addTransactionTab(null, result.psbt);
tabs.getSelectionModel().select(tab);
}
if(result.error != null) {
showErrorDialog("Invalid QR Code", result.error);
}
if(result.exception != null) {
showErrorDialog("Error opening webcam", result.exception.getMessage());
}
}
}
public void saveTransaction(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
@ -784,7 +806,10 @@ public class AppController implements Initializable {
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
if(transactionTabData.getPsbt() != null && psbt != null && !transactionTabData.getPsbt().isFinalized() && !psbt.isFinalized()) {
transactionTabData.getPsbt().combine(psbt);
tab.setText(name);
if(name != null && !name.isEmpty()) {
tab.setText(name);
}
EventManager.get().post(new PSBTCombinedEvent(transactionTabData.getPsbt()));
}
@ -1012,4 +1037,9 @@ public class AppController implements Initializable {
public void requestTransactionOpen(RequestTransactionOpenEvent event) {
openTransactionFromFile(null);
}
@Subscribe
public void requestQRScan(RequestQRScanEvent event) {
openTransactionFromQR(null);
}
}

View file

@ -0,0 +1,191 @@
package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.WebcamResolution;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.Base43;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.sparrow.ur.ResultType;
import com.sparrowwallet.sparrow.ur.UR;
import com.sparrowwallet.sparrow.ur.URDecoder;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.layout.StackPane;
import org.controlsfx.tools.Borders;
public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private final URDecoder decoder;
private final WebcamService webcamService;
private boolean isUr;
private QRScanDialog.Result result;
public QRScanDialog() {
this.decoder = new URDecoder();
this.webcamService = new WebcamService(WebcamResolution.VGA);
WebcamView webcamView = new WebcamView(webcamService);
final DialogPane dialogPane = getDialogPane();
StackPane stackPane = new StackPane();
stackPane.getChildren().add(webcamView.getView());
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().outerPadding(0).innerPadding(0).buildAll());
webcamService.resultProperty().addListener(new QRResultListener());
webcamService.setOnFailed(failedEvent -> {
Platform.runLater(() -> setResult(new Result(failedEvent.getSource().getException())));
});
webcamService.start();
setOnCloseRequest(event -> {
Platform.runLater(webcamService::cancel);
});
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(660);
dialogPane.setPrefHeight(550);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null);
}
private class QRResultListener implements ChangeListener<com.google.zxing.Result> {
@Override
public void changed(ObservableValue<? extends com.google.zxing.Result> observable, com.google.zxing.Result oldValue, com.google.zxing.Result qrResult) {
if(result != null) {
Platform.runLater(() -> setResult(result));
}
//Try text first
String qrtext = qrResult.getText();
if(isUr || qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) {
isUr = true;
decoder.receivePart(qrtext);
if(decoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult();
if(urResult.type == ResultType.SUCCESS) {
if(urResult.ur.getType().equals(UR.BYTES_TYPE)) {
try {
PSBT psbt = new PSBT(urResult.ur.toBytes());
result = new Result(psbt);
return;
} catch(Exception e) {
//ignore, bytes not parsable as PSBT
}
try {
Transaction transaction = new Transaction(urResult.ur.toBytes());
result = new Result(transaction);
return;
} catch(Exception e) {
//ignore, bytes not parsable as tx
}
result = new Result("Parsed UR of type " + urResult.ur.getType() + " was not a PSBT or transaction");
} else {
result = new Result("Cannot parse UR type of " + urResult.ur.getType());
}
} else {
result = new Result(urResult.error);
}
}
} else {
PSBT psbt;
Transaction transaction;
try {
psbt = PSBT.fromString(qrtext);
result = new Result(psbt);
return;
} catch(Exception e) {
//Ignore, not parseable as Base64 or hex
}
try {
psbt = new PSBT(qrResult.getRawBytes());
result = new Result(psbt);
return;
} catch(Exception e) {
//Ignore, not parseable as raw bytes
}
try {
transaction = new Transaction(Utils.hexToBytes(qrtext));
result = new Result(transaction);
return;
} catch(Exception e) {
//Ignore, not parseable as hex
}
try {
transaction = new Transaction(qrResult.getRawBytes());
result = new Result(transaction);
return;
} catch(Exception e) {
//Ignore, not parseable as raw bytes
}
//Try Base43 used by Electrum
byte[] base43 = Base43.decode(qrResult.getText());
try {
psbt = new PSBT(base43);
result = new Result(psbt);
return;
} catch(Exception e) {
//Ignore, not parseable as base43 decoded bytes
}
try {
transaction = new Transaction(base43);
result = new Result(transaction);
return;
} catch(Exception e) {
//Ignore, not parseable as base43 decoded bytes
}
result = new Result("Cannot parse QR code into a PSBT or transaction");
}
}
}
public static class Result {
public final Transaction transaction;
public final PSBT psbt;
public final String error;
public final Throwable exception;
public Result(Transaction transaction) {
this.transaction = transaction;
this.psbt = null;
this.error = null;
this.exception = null;
}
public Result(PSBT psbt) {
this.transaction = null;
this.psbt = psbt;
this.error = null;
this.exception = null;
}
public Result(String error) {
this.transaction = null;
this.psbt = null;
this.error = error;
this.exception = null;
}
public Result(Throwable exception) {
this.transaction = null;
this.psbt = null;
this.error = null;
this.exception = exception;
}
}
}

View file

@ -0,0 +1,81 @@
package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.Webcam;
import com.github.sarxos.webcam.WebcamResolution;
import com.google.zxing.*;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import java.awt.image.BufferedImage;
import java.util.concurrent.TimeUnit;
public class WebcamService extends Service<Image> {
private final WebcamResolution resolution ;
private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null);
public WebcamService(WebcamResolution resolution) {
this.resolution = resolution;
}
@Override
public Task<Image> createTask() {
return new Task<Image>() {
@Override
protected Image call() throws Exception {
Webcam cam = Webcam.getWebcams(1, TimeUnit.MINUTES).get(0);
try {
cam.setCustomViewSizes(resolution.getSize());
cam.setViewSize(resolution.getSize());
cam.open();
while(!isCancelled()) {
if(cam.isImageNew()) {
BufferedImage bimg = cam.getImage();
updateValue(SwingFXUtils.toFXImage(bimg, null));
readQR(bimg);
}
}
cam.close();
return getValue();
} finally {
cam.close();
}
}
};
}
private void readQR(BufferedImage bufferedImage) {
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
try {
Result result = new MultiFormatReader().decode(bitmap);
resultProperty.set(result);
} catch(NotFoundException e) {
// fall thru, it means there is no QR code in image
}
}
public Result getResult() {
return resultProperty.get();
}
public ObjectProperty<Result> resultProperty() {
return resultProperty;
}
public int getCamWidth() {
return resolution.getSize().width;
}
public int getCamHeight() {
return resolution.getSize().height;
}
}

View file

@ -0,0 +1,96 @@
package com.sparrowwallet.sparrow.control;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Region;
public class WebcamView {
private final ImageView imageView;
private final WebcamService service;
private final Region view;
private final Label statusPlaceholder ;
public WebcamView(WebcamService service) {
this.service = service ;
this.imageView = new ImageView();
imageView.setPreserveRatio(true);
// make the cam behave like a mirror:
imageView.setScaleX(-1);
this.statusPlaceholder = new Label();
this.view = new Region() {
{
service.stateProperty().addListener((obs, oldState, newState) -> {
switch (newState) {
case READY:
statusPlaceholder.setText("Initializing");
getChildren().setAll(statusPlaceholder);
break ;
case SCHEDULED:
statusPlaceholder.setText("Waiting");
getChildren().setAll(statusPlaceholder);
break ;
case RUNNING:
imageView.imageProperty().unbind();
imageView.imageProperty().bind(service.valueProperty());
getChildren().setAll(imageView);
break ;
case CANCELLED:
imageView.imageProperty().unbind();
imageView.setImage(null);
statusPlaceholder.setText("Stopped");
getChildren().setAll(statusPlaceholder);
break;
case FAILED:
imageView.imageProperty().unbind();
statusPlaceholder.setText("Error");
getChildren().setAll(statusPlaceholder);
service.getException().printStackTrace();
break;
case SUCCEEDED:
// unreachable...
imageView.imageProperty().unbind();
statusPlaceholder.setText("");
getChildren().clear();
}
requestLayout();
});
}
@Override
protected void layoutChildren() {
super.layoutChildren();
double w = getWidth();
double h = getHeight();
if (service.isRunning()) {
imageView.setFitWidth(w);
imageView.setFitHeight(h);
imageView.resizeRelocate(0, 0, w, h);
} else {
double labelHeight = statusPlaceholder.prefHeight(w);
double labelWidth = statusPlaceholder.prefWidth(labelHeight);
statusPlaceholder.resizeRelocate((w - labelWidth)/2, (h-labelHeight)/2, labelWidth, labelHeight);
}
}
@Override
protected double computePrefWidth(double height) {
return service.getCamWidth();
}
@Override
protected double computePrefHeight(double width) {
return service.getCamHeight();
}
};
}
public WebcamService getService() {
return service;
}
public Node getView() {
return view;
}
}

View file

@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.event;
public class RequestQRScanEvent {
//Empty event class used to request the QRScanDialog is opened
}

View file

@ -1,5 +1,5 @@
package com.sparrowwallet.sparrow.event;
public class RequestTransactionOpenEvent {
//Empty event class used to request the transaction open dialog
//Empty event class used to request the transaction open file dialog
}

View file

@ -537,6 +537,8 @@ public class HeadersController extends TransactionFormController implements Init
public void scanPSBT(ActionEvent event) {
ToggleButton toggleButton = (ToggleButton)event.getSource();
toggleButton.setSelected(false);
EventManager.get().post(new RequestQRScanEvent());
}
public void savePSBT(ActionEvent event) {

View file

@ -1,12 +1,25 @@
package com.sparrowwallet.sparrow.ur;
import co.nstant.in.cbor.CborBuilder;
import co.nstant.in.cbor.CborDecoder;
import co.nstant.in.cbor.CborEncoder;
import co.nstant.in.cbor.CborException;
import co.nstant.in.cbor.model.ByteString;
import co.nstant.in.cbor.model.DataItem;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
/**
* Ported from https://github.com/BlockchainCommons/URKit
*/
public class UR {
public static final String UR_PREFIX = "ur";
public static final String BYTES_TYPE = "bytes";
private final String type;
private final byte[] data;
@ -27,6 +40,16 @@ public class UR {
return data;
}
public byte[] toBytes() throws InvalidTypeException, CborException {
if(!BYTES_TYPE.equals(getType())) {
throw new InvalidTypeException("Not a " + BYTES_TYPE + " type");
}
ByteArrayInputStream bais = new ByteArrayInputStream(getCbor());
List<DataItem> dataItems = new CborDecoder(bais).decode();
return ((ByteString)dataItems.get(0)).getBytes();
}
public static boolean isURType(String type) {
for(char c : type.toCharArray()) {
if('a' <= c && c <= 'z') {
@ -45,8 +68,14 @@ public class UR {
public static UR fromBytes(byte[] data) {
try {
return new UR("bytes", data);
} catch(UR.InvalidTypeException e) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
new CborEncoder(baos).encode(new CborBuilder()
.add(data)
.build());
byte[] cbor = baos.toByteArray();
return new UR("bytes", cbor);
} catch(InvalidTypeException | CborException e) {
return null;
}
}

View file

@ -53,7 +53,7 @@ public class UREncoder {
}
private static String encodeUR(String... pathComponents) {
return encodeURI("ur", pathComponents);
return encodeURI(UR.UR_PREFIX, pathComponents);
}
private static String encodeURI(String scheme, String... pathComponents) {

View file

@ -18,6 +18,7 @@ open module com.sparrowwallet.sparrow {
requires org.jetbrains.annotations;
requires com.fasterxml.jackson.databind;
requires cbor;
requires webcam.capture;
requires centerdevice.nsmenufx;
requires javafx.swing;
}

View file

@ -21,6 +21,7 @@
<MenuItem text="File..." onAction="#openTransactionFromFile"/>
<MenuItem fx:id="openTransactionIdItem" text="From ID..." onAction="#openTransactionFromId"/>
<MenuItem text="From Text..." onAction="#openTransactionFromText"/>
<MenuItem text="From QR..." onAction="#openTransactionFromQR"/>
<MenuItem text="Examples" onAction="#openExamples"/>
</items>
</Menu>