mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2024-12-23 20:36:44 +00:00
add qr scanning support
This commit is contained in:
parent
709c65ec20
commit
c9c00cc74e
14 changed files with 448 additions and 6 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
|
@ -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
2
drongo
|
@ -1 +1 @@
|
|||
Subproject commit 8d49cebcaca6ccb2ea699fe8141554d1470d0a97
|
||||
Subproject commit 97cf49276a5c87425682a3fd0e48ffe081ff71bb
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class RequestQRScanEvent {
|
||||
//Empty event class used to request the QRScanDialog is opened
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue