mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2024-11-02 20:36:44 +00:00
add support for bbqr encoding and decoding
This commit is contained in:
parent
6f4d37d3ff
commit
7f3885611a
18 changed files with 730 additions and 68 deletions
|
@ -134,6 +134,7 @@ dependencies {
|
|||
implementation('net.coobird:thumbnailator:0.4.18')
|
||||
implementation('com.github.hervegirod:fxsvgimage:1.0b2')
|
||||
implementation('com.sparrowwallet:toucan:0.9.0')
|
||||
implementation('com.jcraft:jzlib:1.1.3')
|
||||
testImplementation('org.junit.jupiter:junit-jupiter-api:5.10.0')
|
||||
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.10.0')
|
||||
testRuntimeOnly('org.junit.platform:junit-platform-launcher')
|
||||
|
@ -715,4 +716,7 @@ extraJavaModuleInfo {
|
|||
requires('org.bouncycastle.pg')
|
||||
requires('org.slf4j')
|
||||
}
|
||||
module('jzlib-1.1.3.jar', 'com.jcraft.jzlib', '1.1.3') {
|
||||
exports('com.jcraft.jzlib')
|
||||
}
|
||||
}
|
|
@ -100,7 +100,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
|
|||
releaseLink.setOnAction(event -> {
|
||||
if(release.get() != null && release.get().exists()) {
|
||||
if(release.get().getName().toLowerCase(Locale.ROOT).startsWith("sparrow")) {
|
||||
Optional<ButtonType> optType = AppServices.showAlertDialog("Close Sparrow?", "Close Sparrow before installing?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES);
|
||||
Optional<ButtonType> optType = AppServices.showAlertDialog("Exit Sparrow?", "Sparrow must be closed before installation. Exit?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES);
|
||||
if(optType.isPresent() && optType.get() == ButtonType.YES) {
|
||||
javafx.application.Platform.exit();
|
||||
AppServices.get().getApplication().getHostServices().showDocument("file://" + release.get().getAbsolutePath());
|
||||
|
|
|
@ -1,22 +1,28 @@
|
|||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
public enum QRDensity {
|
||||
NORMAL("Normal", 400),
|
||||
LOW("Low", 80);
|
||||
NORMAL("Normal", 400, 2000),
|
||||
LOW("Low", 80, 1000);
|
||||
|
||||
private final String name;
|
||||
private final int maxFragmentLength;
|
||||
private final int maxUrFragmentLength;
|
||||
private final int maxBbqrFragmentLength;
|
||||
|
||||
QRDensity(String name, int maxFragmentLength) {
|
||||
QRDensity(String name, int maxUrFragmentLength, int maxBbqrFragmentLength) {
|
||||
this.name = name;
|
||||
this.maxFragmentLength = maxFragmentLength;
|
||||
this.maxUrFragmentLength = maxUrFragmentLength;
|
||||
this.maxBbqrFragmentLength = maxBbqrFragmentLength;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public int getMaxFragmentLength() {
|
||||
return maxFragmentLength;
|
||||
public int getMaxUrFragmentLength() {
|
||||
return maxUrFragmentLength;
|
||||
}
|
||||
|
||||
public int getMaxBbqrFragmentLength() {
|
||||
return maxBbqrFragmentLength;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,9 @@ import com.sparrowwallet.sparrow.io.Config;
|
|||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import com.sparrowwallet.hummingbird.UR;
|
||||
import com.sparrowwallet.hummingbird.UREncoder;
|
||||
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
|
||||
import com.sparrowwallet.sparrow.io.bbqr.BBQREncoder;
|
||||
import com.sparrowwallet.sparrow.io.bbqr.BBQREncoding;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.scene.Node;
|
||||
|
@ -44,10 +47,16 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
private static final int DEFAULT_QR_SIZE = 580;
|
||||
private static final int REDUCED_QR_SIZE = 520;
|
||||
|
||||
private static final BBQREncoding DEFAULT_BBQR_ENCODING = BBQREncoding.ZLIB;
|
||||
|
||||
private final int qrSize = getQRSize();
|
||||
|
||||
private final UR ur;
|
||||
private UREncoder encoder;
|
||||
private UREncoder urEncoder;
|
||||
|
||||
private final BBQR bbqr;
|
||||
private BBQREncoder bbqrEncoder;
|
||||
private boolean useBbqrEncoding;
|
||||
|
||||
private final ImageView qrImageView;
|
||||
|
||||
|
@ -62,17 +71,26 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
private static boolean initialDensityChange;
|
||||
|
||||
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
|
||||
this(UR.fromBytes(type, data), addLegacyEncodingOption, false);
|
||||
this(UR.fromBytes(type, data), null, addLegacyEncodingOption, false, false);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur) {
|
||||
this(ur, false, false);
|
||||
this(ur, null, false, false, false);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption, boolean addScanButton) {
|
||||
public QRDisplayDialog(UR ur, BBQR bbqr, boolean addLegacyEncodingOption, boolean addScanButton, boolean selectBbqrButton) {
|
||||
this.ur = ur;
|
||||
this.addLegacyEncodingOption = addLegacyEncodingOption;
|
||||
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
|
||||
this.bbqr = bbqr;
|
||||
this.addLegacyEncodingOption = bbqr == null && addLegacyEncodingOption;
|
||||
|
||||
this.urEncoder = new UREncoder(ur, Config.get().getQrDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
|
||||
|
||||
if(bbqr != null) {
|
||||
this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
|
||||
if(selectBbqrButton) {
|
||||
useBbqrEncoding = true;
|
||||
}
|
||||
}
|
||||
|
||||
final DialogPane dialogPane = new QRDisplayDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
|
@ -85,7 +103,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
|
||||
|
||||
nextPart();
|
||||
if(encoder.isSinglePart()) {
|
||||
if(isSinglePart()) {
|
||||
qrImageView.setImage(getQrCode(currentPart));
|
||||
} else {
|
||||
createAnimateQRService();
|
||||
|
@ -94,7 +112,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().add(cancelButtonType);
|
||||
|
||||
if(addLegacyEncodingOption) {
|
||||
if(this.addLegacyEncodingOption) {
|
||||
final ButtonType legacyEncodingButtonType = new javafx.scene.control.ButtonType("Use Legacy Encoding (Cobo Vault)", ButtonBar.ButtonData.LEFT);
|
||||
dialogPane.getButtonTypes().add(legacyEncodingButtonType);
|
||||
} else {
|
||||
|
@ -102,8 +120,13 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
dialogPane.getButtonTypes().add(densityButtonType);
|
||||
}
|
||||
|
||||
if(bbqr != null) {
|
||||
final ButtonType bbqrButtonType = new javafx.scene.control.ButtonType("Show BBQr", ButtonBar.ButtonData.BACK_PREVIOUS);
|
||||
dialogPane.getButtonTypes().add(bbqrButtonType);
|
||||
}
|
||||
|
||||
if(addScanButton) {
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD);
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().add(scanButtonType);
|
||||
}
|
||||
|
||||
|
@ -121,7 +144,9 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
|
||||
public QRDisplayDialog(String data, boolean addScanButton) {
|
||||
this.ur = null;
|
||||
this.encoder = null;
|
||||
this.bbqr = null;
|
||||
this.urEncoder = null;
|
||||
this.bbqrEncoder = null;
|
||||
|
||||
final DialogPane dialogPane = new QRDisplayDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
|
@ -138,7 +163,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
|
||||
if(addScanButton) {
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD);
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().add(scanButtonType);
|
||||
}
|
||||
|
||||
|
@ -163,9 +188,22 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
});
|
||||
}
|
||||
|
||||
private boolean isSinglePart() {
|
||||
if(useBbqrEncoding) {
|
||||
return bbqrEncoder.isSinglePart();
|
||||
} else if(!useLegacyEncoding) {
|
||||
return urEncoder.isSinglePart();
|
||||
} else {
|
||||
return legacyParts.length == 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void nextPart() {
|
||||
if(!useLegacyEncoding) {
|
||||
String fragment = encoder.nextPart();
|
||||
if(useBbqrEncoding) {
|
||||
String fragment = bbqrEncoder.nextPart();
|
||||
currentPart = fragment.toUpperCase(Locale.ROOT);
|
||||
} else if(!useLegacyEncoding) {
|
||||
String fragment = urEncoder.nextPart();
|
||||
currentPart = fragment.toUpperCase(Locale.ROOT);
|
||||
} else {
|
||||
currentPart = legacyParts[legacyPartIndex];
|
||||
|
@ -201,37 +239,23 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
this.legacyParts = legacyEncoder.encode();
|
||||
this.useLegacyEncoding = true;
|
||||
|
||||
if(legacyParts.length == 1) {
|
||||
if(animateQRService != null) {
|
||||
animateQRService.cancel();
|
||||
}
|
||||
|
||||
nextPart();
|
||||
qrImageView.setImage(getQrCode(currentPart));
|
||||
} else if(animateQRService == null) {
|
||||
createAnimateQRService();
|
||||
} else if(!animateQRService.isRunning()) {
|
||||
animateQRService.reset();
|
||||
animateQRService.start();
|
||||
}
|
||||
restartAnimation();
|
||||
} catch(UR.InvalidTypeException e) {
|
||||
//Can't happen
|
||||
}
|
||||
} else {
|
||||
this.useLegacyEncoding = false;
|
||||
restartAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
if(encoder.isSinglePart()) {
|
||||
if(animateQRService != null) {
|
||||
animateQRService.cancel();
|
||||
}
|
||||
|
||||
qrImageView.setImage(getQrCode(currentPart));
|
||||
} else if(animateQRService == null) {
|
||||
createAnimateQRService();
|
||||
} else if(!animateQRService.isRunning()) {
|
||||
animateQRService.reset();
|
||||
animateQRService.start();
|
||||
}
|
||||
private void setUseBbqrEncoding(boolean useBbqrEncoding) {
|
||||
if(useBbqrEncoding) {
|
||||
this.useBbqrEncoding = true;
|
||||
restartAnimation();
|
||||
} else {
|
||||
this.useBbqrEncoding = false;
|
||||
restartAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -240,12 +264,28 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
animateQRService.cancel();
|
||||
}
|
||||
|
||||
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
|
||||
nextPart();
|
||||
if(encoder.isSinglePart()) {
|
||||
if(bbqr != null) {
|
||||
this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
|
||||
}
|
||||
|
||||
this.urEncoder = new UREncoder(ur, Config.get().getQrDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
|
||||
|
||||
restartAnimation();
|
||||
}
|
||||
|
||||
private void restartAnimation() {
|
||||
if(isSinglePart()) {
|
||||
if(animateQRService != null) {
|
||||
animateQRService.cancel();
|
||||
}
|
||||
|
||||
nextPart();
|
||||
qrImageView.setImage(getQrCode(currentPart));
|
||||
} else {
|
||||
} else if(animateQRService == null) {
|
||||
createAnimateQRService();
|
||||
} else if(!animateQRService.isRunning()) {
|
||||
animateQRService.reset();
|
||||
animateQRService.start();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -290,7 +330,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(density, buttonData);
|
||||
density.setOnAction(event -> {
|
||||
if(!initialDensityChange && !encoder.isSinglePart()) {
|
||||
if(!initialDensityChange && !isSinglePart()) {
|
||||
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Discard progress?", "Changing the QR code density means any progress on the receiving device must be discarded. Proceed?", ButtonType.NO, ButtonType.YES);
|
||||
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
|
||||
initialDensityChange = true;
|
||||
|
@ -306,12 +346,25 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
|
|||
|
||||
return density;
|
||||
}
|
||||
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
|
||||
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.OK_DONE) {
|
||||
Button scanButton = (Button)super.createButton(buttonType);
|
||||
scanButton.setGraphicTextGap(5);
|
||||
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
|
||||
|
||||
return scanButton;
|
||||
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.BACK_PREVIOUS) {
|
||||
ToggleButton bbqr = new ToggleButton(buttonType.getText());
|
||||
bbqr.setGraphicTextGap(5);
|
||||
bbqr.setGraphic(getGlyph(FontAwesome5.Glyph.QRCODE));
|
||||
bbqr.setSelected(useBbqrEncoding);
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(bbqr, buttonData);
|
||||
|
||||
bbqr.selectedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
setUseBbqrEncoding(newValue);
|
||||
});
|
||||
|
||||
return bbqr;
|
||||
}
|
||||
|
||||
return super.createButton(buttonType);
|
||||
|
|
|
@ -30,6 +30,8 @@ import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
|
|||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.bbqr.BBQRDecoder;
|
||||
import com.sparrowwallet.sparrow.io.bbqr.BBQRException;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
|
@ -63,8 +65,9 @@ import java.util.stream.IntStream;
|
|||
public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class);
|
||||
|
||||
private final URDecoder decoder;
|
||||
private final LegacyURDecoder legacyDecoder;
|
||||
private final URDecoder urDecoder;
|
||||
private final LegacyURDecoder legacyUrDecoder;
|
||||
private final BBQRDecoder bbqrDecoder;
|
||||
private final WebcamService webcamService;
|
||||
private List<String> parts;
|
||||
|
||||
|
@ -80,8 +83,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
private final ObjectProperty<WebcamDevice> webcamDeviceProperty = new SimpleObjectProperty<>();
|
||||
|
||||
public QRScanDialog() {
|
||||
this.decoder = new URDecoder();
|
||||
this.legacyDecoder = new LegacyURDecoder();
|
||||
this.urDecoder = new URDecoder();
|
||||
this.legacyUrDecoder = new LegacyURDecoder();
|
||||
this.bbqrDecoder = new BBQRDecoder();
|
||||
|
||||
if(Config.get().isHdCapture()) {
|
||||
webcamResolutionProperty.set(WebcamResolution.HD);
|
||||
|
@ -192,23 +196,23 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
|
||||
if(qrtext.toLowerCase(Locale.ROOT).startsWith(UR.UR_PREFIX)) {
|
||||
if(LegacyURDecoder.isLegacyURFragment(qrtext)) {
|
||||
legacyDecoder.receivePart(qrtext);
|
||||
Platform.runLater(() -> percentComplete.setValue(legacyDecoder.getPercentComplete()));
|
||||
legacyUrDecoder.receivePart(qrtext);
|
||||
Platform.runLater(() -> percentComplete.setValue(legacyUrDecoder.getPercentComplete()));
|
||||
|
||||
if(legacyDecoder.isComplete()) {
|
||||
if(legacyUrDecoder.isComplete()) {
|
||||
try {
|
||||
UR ur = legacyDecoder.decode();
|
||||
UR ur = legacyUrDecoder.decode();
|
||||
result = extractResultFromUR(ur);
|
||||
} catch(Exception e) {
|
||||
result = new Result(new URException(e.getMessage()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
decoder.receivePart(qrtext);
|
||||
Platform.runLater(() -> percentComplete.setValue(decoder.getProcessedPartsCount() > 0 ? decoder.getEstimatedPercentComplete() : 0));
|
||||
urDecoder.receivePart(qrtext);
|
||||
Platform.runLater(() -> percentComplete.setValue(urDecoder.getProcessedPartsCount() > 0 ? urDecoder.getEstimatedPercentComplete() : 0));
|
||||
|
||||
if(decoder.getResult() != null) {
|
||||
URDecoder.Result urResult = decoder.getResult();
|
||||
if(urDecoder.getResult() != null) {
|
||||
URDecoder.Result urResult = urDecoder.getResult();
|
||||
if(urResult.type == ResultType.SUCCESS) {
|
||||
result = extractResultFromUR(urResult.ur);
|
||||
Platform.runLater(() -> setResult(result));
|
||||
|
@ -217,6 +221,19 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
}
|
||||
}
|
||||
}
|
||||
} else if(BBQRDecoder.isBBQRFragment(qrtext)) {
|
||||
bbqrDecoder.receivePart(qrtext);
|
||||
Platform.runLater(() -> percentComplete.setValue(bbqrDecoder.getPercentComplete()));
|
||||
|
||||
if(bbqrDecoder.getResult() != null) {
|
||||
BBQRDecoder.Result bbqrResult = bbqrDecoder.getResult();
|
||||
if(bbqrResult.getResultType() == BBQRDecoder.ResultType.SUCCESS) {
|
||||
result = extractResultFromBBQR(bbqrResult);
|
||||
Platform.runLater(() -> setResult(result));
|
||||
} else {
|
||||
result = new Result(new BBQRException(bbqrResult.getError()));
|
||||
}
|
||||
}
|
||||
} else if(partMatcher.matches()) {
|
||||
int m = Integer.parseInt(partMatcher.group(1));
|
||||
int n = Integer.parseInt(partMatcher.group(2));
|
||||
|
@ -651,6 +668,19 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
|||
|
||||
return wallets;
|
||||
}
|
||||
|
||||
private Result extractResultFromBBQR(BBQRDecoder.Result result) {
|
||||
if(result.getPsbt() != null) {
|
||||
return new Result(result.getPsbt());
|
||||
} else if(result.getTransaction() != null) {
|
||||
return new Result(result.getTransaction());
|
||||
} else if(result.toString() != null) {
|
||||
return new Result(result.toString());
|
||||
} else {
|
||||
log.error("Unsupported BBQR type " + result.getBbqrType());
|
||||
return new Result(new URException("BBQR type " + result.getBbqrType() + " is not supported"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class QRScanListener implements WebcamListener {
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
public record BBQR(BBQRType type, byte[] data) {}
|
180
src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRDecoder.java
Normal file
180
src/main/java/com/sparrowwallet/sparrow/io/bbqr/BBQRDecoder.java
Normal file
|
@ -0,0 +1,180 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
public class BBQRDecoder {
|
||||
private static final Logger log = LoggerFactory.getLogger(BBQRDecoder.class);
|
||||
|
||||
private final Map<Integer, byte[]> receivedParts = new TreeMap<>();
|
||||
private int totalParts;
|
||||
|
||||
private BBQRType type;
|
||||
private Result result;
|
||||
|
||||
public static boolean isBBQRFragment(String part) {
|
||||
try {
|
||||
BBQRHeader.fromString(part);
|
||||
return true;
|
||||
} catch(Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void receivePart(String part) {
|
||||
try {
|
||||
BBQRHeader header = BBQRHeader.fromString(part);
|
||||
totalParts = header.seqTotal();
|
||||
type = header.type();
|
||||
byte[] partData = header.decode(part);
|
||||
receivedParts.put(header.seqNumber(), partData);
|
||||
|
||||
if(receivedParts.size() == totalParts) {
|
||||
byte[] data = concatParts();
|
||||
|
||||
if(type == BBQRType.PSBT) {
|
||||
result = new Result(new PSBT(data));
|
||||
} else if(type == BBQRType.TXN) {
|
||||
result = new Result(new Transaction(data));
|
||||
} else if(type == BBQRType.JSON || type == BBQRType.UNICODE) {
|
||||
result = new Result(type, new String(data, StandardCharsets.UTF_8));
|
||||
} else {
|
||||
result = new Result(type, data);
|
||||
}
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.error("Could not parse received QR of type " + type, e);
|
||||
result = new Result(ResultType.FAILURE, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] concatParts() {
|
||||
int totalLength = 0;
|
||||
for(byte[] part : receivedParts.values()) {
|
||||
totalLength += part.length;
|
||||
}
|
||||
|
||||
byte[] data = new byte[totalLength];
|
||||
int index = 0;
|
||||
for(byte[] part : receivedParts.values()) {
|
||||
System.arraycopy(part, 0, data, index, part.length);
|
||||
index += part.length;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public int getProcessedPartsCount() {
|
||||
return receivedParts.size();
|
||||
}
|
||||
|
||||
public double getPercentComplete() {
|
||||
if(totalParts == 0) {
|
||||
return 0d;
|
||||
}
|
||||
|
||||
return (double)getProcessedPartsCount() / totalParts;
|
||||
}
|
||||
|
||||
public Result getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
public static class Result {
|
||||
private final ResultType resultType;
|
||||
private final BBQRType bbqrType;
|
||||
private final PSBT psbt;
|
||||
private final Transaction transaction;
|
||||
private final String strData;
|
||||
private final byte[] data;
|
||||
private final String error;
|
||||
|
||||
public Result(ResultType resultType, String error) {
|
||||
this.resultType = resultType;
|
||||
this.bbqrType = null;
|
||||
this.psbt = null;
|
||||
this.transaction = null;
|
||||
this.strData = null;
|
||||
this.data = null;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public Result(PSBT psbt) {
|
||||
this.resultType = ResultType.SUCCESS;
|
||||
this.bbqrType = BBQRType.PSBT;
|
||||
this.psbt = psbt;
|
||||
this.transaction = null;
|
||||
this.strData = null;
|
||||
this.data = null;
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
public Result(Transaction transaction) {
|
||||
this.resultType = ResultType.SUCCESS;
|
||||
this.bbqrType = BBQRType.TXN;
|
||||
this.psbt = null;
|
||||
this.transaction = transaction;
|
||||
this.strData = null;
|
||||
this.data = null;
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
public Result(BBQRType bbqrType, String strData) {
|
||||
this.resultType = ResultType.SUCCESS;
|
||||
this.bbqrType = bbqrType;
|
||||
this.psbt = null;
|
||||
this.transaction = null;
|
||||
this.strData = strData;
|
||||
this.data = null;
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
public Result(BBQRType bbqrType, byte[] data) {
|
||||
this.resultType = ResultType.SUCCESS;
|
||||
this.bbqrType = bbqrType;
|
||||
this.psbt = null;
|
||||
this.transaction = null;
|
||||
this.strData = null;
|
||||
this.data = data;
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
public ResultType getResultType() {
|
||||
return resultType;
|
||||
}
|
||||
|
||||
public BBQRType getBbqrType() {
|
||||
return bbqrType;
|
||||
}
|
||||
|
||||
public PSBT getPsbt() {
|
||||
return psbt;
|
||||
}
|
||||
|
||||
public Transaction getTransaction() {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public byte[] getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return strData;
|
||||
}
|
||||
|
||||
public String getError() {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ResultType {
|
||||
SUCCESS, FAILURE;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class BBQREncoder {
|
||||
private final String[] parts;
|
||||
private int partIndex;
|
||||
|
||||
public BBQREncoder(BBQRType bbqrType, BBQREncoding bbqrEncoding, byte[] data, int maxFragmentLength, int firstSeqNum) {
|
||||
this.parts = encode(bbqrType, bbqrEncoding, data, maxFragmentLength).toArray(new String[0]);
|
||||
this.partIndex = firstSeqNum;
|
||||
}
|
||||
|
||||
public boolean isSinglePart() {
|
||||
return parts.length == 1;
|
||||
}
|
||||
|
||||
public String nextPart() {
|
||||
String currentPart = parts[partIndex];
|
||||
partIndex++;
|
||||
if(partIndex > parts.length - 1) {
|
||||
partIndex = 0;
|
||||
}
|
||||
|
||||
return currentPart;
|
||||
}
|
||||
|
||||
public int getNumParts() {
|
||||
return parts.length;
|
||||
}
|
||||
|
||||
private List<String> encode(BBQRType type, BBQREncoding desiredEncoding, byte[] data, int desiredChunkSize) {
|
||||
String encoded;
|
||||
BBQREncoding encoding = desiredEncoding;
|
||||
|
||||
try {
|
||||
encoded = encoding.encode(data);
|
||||
if(encoding == BBQREncoding.ZLIB) {
|
||||
String uncompressed = BBQREncoding.BASE32.encode(data);
|
||||
if(encoded.length() > uncompressed.length()) {
|
||||
throw new BBQREncodingException("Compressed data was larger than uncompressed data");
|
||||
}
|
||||
}
|
||||
} catch(BBQREncodingException e) {
|
||||
encoding = BBQREncoding.BASE32;
|
||||
encoded = BBQREncoding.BASE32.encode(data);
|
||||
}
|
||||
|
||||
int inputLength = encoded.length();
|
||||
int numChunks = (inputLength + desiredChunkSize - 1) / desiredChunkSize;
|
||||
int chunkSize = numChunks == 1 ? desiredChunkSize : (int)Math.ceil((double)inputLength / numChunks);
|
||||
|
||||
int modulo = chunkSize % encoding.getPartModulo();
|
||||
if(modulo > 0) {
|
||||
chunkSize += (encoding.getPartModulo() - modulo);
|
||||
}
|
||||
|
||||
List<String> chunks = new ArrayList<>();
|
||||
int startIndex = 0;
|
||||
for(int i = 0; i < numChunks; i ++) {
|
||||
int endIndex = Math.min(startIndex + chunkSize, encoded.length());
|
||||
BBQRHeader bbqrHeader = new BBQRHeader(encoding, type, numChunks, i);
|
||||
String chunk = bbqrHeader + encoded.substring(startIndex, endIndex);
|
||||
startIndex = endIndex;
|
||||
chunks.add(chunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
import com.google.common.io.BaseEncoding;
|
||||
import com.jcraft.jzlib.*;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Locale;
|
||||
|
||||
public enum BBQREncoding {
|
||||
HEX("H") {
|
||||
@Override
|
||||
public String encode(byte[] data) throws BBQREncodingException {
|
||||
return Utils.bytesToHex(data).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decode(String part) throws BBQREncodingException {
|
||||
return Utils.hexToBytes(part);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPartModulo() {
|
||||
return 2;
|
||||
}
|
||||
}, BASE32("2") {
|
||||
@Override
|
||||
public String encode(byte[] data) throws BBQREncodingException {
|
||||
return BaseEncoding.base32().encode(data).replaceAll("=+$", "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decode(String part) throws BBQREncodingException {
|
||||
return BaseEncoding.base32().decode(part);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPartModulo() {
|
||||
return 8;
|
||||
}
|
||||
}, ZLIB("Z") {
|
||||
@Override
|
||||
public String encode(byte[] data) throws BBQREncodingException {
|
||||
try {
|
||||
Deflater deflater = new Deflater(JZlib.Z_BEST_COMPRESSION, 10, true);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
DeflaterOutputStream zOut = new DeflaterOutputStream(out, deflater);
|
||||
zOut.write(data);
|
||||
zOut.close();
|
||||
|
||||
return BASE32.encode(out.toByteArray());
|
||||
} catch(Exception e) {
|
||||
throw new BBQREncodingException("Error deflating with zlib", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decode(String part) throws BBQREncodingException {
|
||||
try {
|
||||
Inflater inflater = new Inflater(10, true);
|
||||
ByteArrayInputStream in = new ByteArrayInputStream(BASE32.decode(part));
|
||||
InflaterInputStream zIn = new InflaterInputStream(in, inflater);
|
||||
byte[] decoded = zIn.readAllBytes();
|
||||
zIn.close();
|
||||
|
||||
return decoded;
|
||||
} catch(Exception e) {
|
||||
throw new BBQREncodingException("Error inflating with zlib", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPartModulo() {
|
||||
return 8;
|
||||
}
|
||||
};
|
||||
|
||||
public static BBQREncoding fromString(String code) {
|
||||
for(BBQREncoding encoding : values()) {
|
||||
if(encoding.getCode().equals(code)) {
|
||||
return encoding;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Could not find encoding for code " + code);
|
||||
}
|
||||
|
||||
private final String code;
|
||||
|
||||
BBQREncoding(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public abstract String encode(byte[] data) throws BBQREncodingException;
|
||||
|
||||
public abstract byte[] decode(String part) throws BBQREncodingException;
|
||||
|
||||
public abstract int getPartModulo();
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
public class BBQREncodingException extends BBQRException {
|
||||
public BBQREncodingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public BBQREncodingException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
public class BBQRException extends RuntimeException {
|
||||
public BBQRException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public BBQRException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
public record BBQRHeader(BBQREncoding encoding, BBQRType type, int seqTotal, int seqNumber) {
|
||||
public static final String HEADER = "B$";
|
||||
|
||||
public String toString() {
|
||||
return HEADER + encoding.getCode() + type.getCode() + encodeToBase36(seqTotal) + encodeToBase36(seqNumber);
|
||||
}
|
||||
|
||||
public byte[] decode(String part) {
|
||||
return encoding.decode(part.substring(8));
|
||||
}
|
||||
|
||||
public static BBQRHeader fromString(String part) {
|
||||
if(part.length() < 8) {
|
||||
throw new IllegalArgumentException("Part too short");
|
||||
}
|
||||
|
||||
if(!HEADER.equals(part.substring(0, 2))) {
|
||||
throw new IllegalArgumentException("Part does not start with " + HEADER);
|
||||
}
|
||||
|
||||
BBQREncoding e = BBQREncoding.fromString(part.substring(2, 3));
|
||||
BBQRType t = BBQRType.fromString(part.substring(3, 4));
|
||||
|
||||
return new BBQRHeader(e, t, decodeFromBase36(part.substring(4, 6)), decodeFromBase36(part.substring(6, 8)));
|
||||
}
|
||||
|
||||
private String encodeToBase36(int number) {
|
||||
String base36Encoded = new BigInteger(String.valueOf(number)).toString(36);
|
||||
return String.format("%2s", base36Encoded).replace(' ', '0');
|
||||
}
|
||||
|
||||
private static int decodeFromBase36(String base36) {
|
||||
BigInteger bigInteger = new BigInteger(base36, 36);
|
||||
return bigInteger.intValue();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
public enum BBQRType {
|
||||
PSBT("P"), TXN("T"), JSON("J"), CBOR("C"), UNICODE("U"), BINARY("B"), EXECUTABLE("X");
|
||||
|
||||
private final String code;
|
||||
|
||||
BBQRType(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static BBQRType fromString(String code) {
|
||||
for(BBQRType type : values()) {
|
||||
if(type.getCode().equals(code)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Could not find type for code " + code);
|
||||
}
|
||||
}
|
|
@ -19,6 +19,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
|||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
|
||||
import com.sparrowwallet.sparrow.io.bbqr.BBQRType;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.payjoin.Payjoin;
|
||||
|
@ -902,16 +904,21 @@ public class HeadersController extends TransactionFormController implements Init
|
|||
ToggleButton toggleButton = (ToggleButton)event.getSource();
|
||||
toggleButton.setSelected(false);
|
||||
|
||||
//TODO: Remove once Cobo Vault has upgraded to UR2.0
|
||||
//TODO: Remove once Cobo Vault support has been removed
|
||||
boolean addLegacyEncodingOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COBO_VAULT));
|
||||
boolean addBbqrOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COLDCARD) || keystore.getSource().equals(KeystoreSource.SW_WATCH));
|
||||
boolean selectBbqrOption = headersForm.getSigningWallet().getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COLDCARD));
|
||||
|
||||
//Don't include non witness utxo fields for segwit wallets when displaying the PSBT as a QR - it can add greatly to the time required for scanning
|
||||
boolean includeNonWitnessUtxos = !Arrays.asList(ScriptType.WITNESS_TYPES).contains(headersForm.getSigningWallet().getScriptType());
|
||||
CryptoPSBT cryptoPSBT = new CryptoPSBT(headersForm.getPsbt().serialize(true, includeNonWitnessUtxos));
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), addLegacyEncodingOption, true);
|
||||
byte[] psbtBytes = headersForm.getPsbt().serialize(true, includeNonWitnessUtxos);
|
||||
|
||||
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
|
||||
BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.PSBT, psbtBytes) : null;
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, addLegacyEncodingOption, true, selectBbqrOption);
|
||||
qrDisplayDialog.initOwner(toggleButton.getScene().getWindow());
|
||||
Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait();
|
||||
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
|
||||
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) {
|
||||
scanPSBT(event);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,4 +63,5 @@ open module com.sparrowwallet.sparrow {
|
|||
requires com.github.hervegirod;
|
||||
requires com.sparrowwallet.toucan;
|
||||
requires java.smartcardio;
|
||||
requires com.jcraft.jzlib;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
import com.sparrowwallet.sparrow.control.QRDensity;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
public class BBQRDecoderTest {
|
||||
@Test
|
||||
public void testDecoder() {
|
||||
Random random = new Random();
|
||||
|
||||
for(QRDensity qrDensity : QRDensity.values()) {
|
||||
for(BBQREncoding encoding : BBQREncoding.values()) {
|
||||
for(int length = qrDensity.getMaxBbqrFragmentLength() / 2; length < qrDensity.getMaxBbqrFragmentLength() * 2.5d; length++) {
|
||||
byte[] data = new byte[length];
|
||||
random.nextBytes(data);
|
||||
|
||||
BBQREncoder encoder = new BBQREncoder(BBQRType.BINARY, encoding, data, qrDensity.getMaxBbqrFragmentLength() + 13, 0);
|
||||
BBQRDecoder decoder = new BBQRDecoder();
|
||||
|
||||
while(decoder.getPercentComplete() < 1d) {
|
||||
String part = encoder.nextPart();
|
||||
Assertions.assertTrue(BBQRDecoder.isBBQRFragment(part));
|
||||
if(random.nextDouble() < 0.7) {
|
||||
decoder.receivePart(part);
|
||||
}
|
||||
}
|
||||
|
||||
Assertions.assertNotNull(decoder.getResult(), "Result was null for encoding " + encoding + " " + qrDensity + " " + length);
|
||||
Assertions.assertArrayEquals(data, decoder.getResult().getData());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
import com.sparrowwallet.sparrow.control.QRDensity;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
public class BBQREncoderTest {
|
||||
@Test
|
||||
public void testEncoding() {
|
||||
Random random = new Random();
|
||||
for(QRDensity qrDensity : QRDensity.values()) {
|
||||
for(BBQREncoding encoding : BBQREncoding.values()) {
|
||||
for(int length = qrDensity.getMaxBbqrFragmentLength() / 2; length < qrDensity.getMaxBbqrFragmentLength() * 2.5d; length++) {
|
||||
byte[] data = new byte[length];
|
||||
random.nextBytes(data);
|
||||
|
||||
BBQREncoder encoder = new BBQREncoder(BBQRType.BINARY, encoding, data, qrDensity.getMaxBbqrFragmentLength(), 0);
|
||||
int partLength = encoder.nextPart().length();
|
||||
for(int i = 1; i < encoder.getNumParts(); i++) {
|
||||
int nextPartLength = encoder.nextPart().length();
|
||||
if(i < encoder.getNumParts() - 1) {
|
||||
Assertions.assertEquals(0, nextPartLength % encoding.getPartModulo(), "Modulo test failed for " + length + " in encoding " + encoding + " on part " + (i+1) + " of " + encoder.getNumParts());
|
||||
Assertions.assertEquals(partLength, nextPartLength);
|
||||
} else {
|
||||
Assertions.assertTrue(nextPartLength <= partLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package com.sparrowwallet.sparrow.io.bbqr;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
public class BBQREncodingTest {
|
||||
@Test
|
||||
public void testHex() {
|
||||
byte[] data = new byte[1000];
|
||||
Random random = new Random();
|
||||
random.nextBytes(data);
|
||||
|
||||
String deflated = BBQREncoding.HEX.encode(data);
|
||||
byte[] inflated = BBQREncoding.HEX.decode(deflated);
|
||||
|
||||
Assertions.assertArrayEquals(data, inflated);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBase32() {
|
||||
byte[] data = new byte[1000];
|
||||
Random random = new Random();
|
||||
random.nextBytes(data);
|
||||
|
||||
String deflated = BBQREncoding.BASE32.encode(data);
|
||||
byte[] inflated = BBQREncoding.BASE32.decode(deflated);
|
||||
|
||||
Assertions.assertArrayEquals(data, inflated);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testZlib() {
|
||||
byte[] data = new byte[1000];
|
||||
Random random = new Random();
|
||||
random.nextBytes(data);
|
||||
|
||||
String deflated = BBQREncoding.ZLIB.encode(data);
|
||||
byte[] inflated = BBQREncoding.ZLIB.decode(deflated);
|
||||
|
||||
Assertions.assertArrayEquals(data, inflated);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue