diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 72c56e32..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/build.gradle b/build.gradle index 0b7b1134..04747959 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/drongo b/drongo index 8d49cebc..97cf4927 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 8d49cebcaca6ccb2ea699fe8141554d1470d0a97 +Subproject commit 97cf49276a5c87425682a3fd0e48ffe081ff71bb diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 76fe4924..7588fc1e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -409,6 +409,28 @@ public class AppController implements Initializable { } } + public void openTransactionFromQR(ActionEvent event) { + QRScanDialog qrScanDialog = new QRScanDialog(); + Optional 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); + } } \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java new file mode 100644 index 00000000..3aa442ae --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java @@ -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 { + 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 { + @Override + public void changed(ObservableValue 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; + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WebcamService.java b/src/main/java/com/sparrowwallet/sparrow/control/WebcamService.java new file mode 100644 index 00000000..bac387a6 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/WebcamService.java @@ -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 { + private final WebcamResolution resolution ; + + private final ObjectProperty resultProperty = new SimpleObjectProperty<>(null); + + public WebcamService(WebcamResolution resolution) { + this.resolution = resolution; + } + + @Override + public Task createTask() { + return new Task() { + @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 resultProperty() { + return resultProperty; + } + + public int getCamWidth() { + return resolution.getSize().width; + } + + public int getCamHeight() { + return resolution.getSize().height; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WebcamView.java b/src/main/java/com/sparrowwallet/sparrow/control/WebcamView.java new file mode 100644 index 00000000..d38a0192 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/WebcamView.java @@ -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; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/RequestQRScanEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/RequestQRScanEvent.java new file mode 100644 index 00000000..aa44b10f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/RequestQRScanEvent.java @@ -0,0 +1,5 @@ +package com.sparrowwallet.sparrow.event; + +public class RequestQRScanEvent { + //Empty event class used to request the QRScanDialog is opened +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/RequestTransactionOpenEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/RequestTransactionOpenEvent.java index e89557df..5b53c007 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/RequestTransactionOpenEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/RequestTransactionOpenEvent.java @@ -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 } diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index 815c60a9..0fe8c8b0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -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) { diff --git a/src/main/java/com/sparrowwallet/sparrow/ur/UR.java b/src/main/java/com/sparrowwallet/sparrow/ur/UR.java index d2198ce3..9d011b3b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/ur/UR.java +++ b/src/main/java/com/sparrowwallet/sparrow/ur/UR.java @@ -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 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; } } diff --git a/src/main/java/com/sparrowwallet/sparrow/ur/UREncoder.java b/src/main/java/com/sparrowwallet/sparrow/ur/UREncoder.java index fa7dba0c..53eae541 100644 --- a/src/main/java/com/sparrowwallet/sparrow/ur/UREncoder.java +++ b/src/main/java/com/sparrowwallet/sparrow/ur/UREncoder.java @@ -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) { diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 76229eb5..796343d3 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -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; } \ No newline at end of file diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.fxml b/src/main/resources/com/sparrowwallet/sparrow/app.fxml index 72767ccb..6495d9ca 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/app.fxml @@ -21,6 +21,7 @@ +