replace the qr display density toggle with low, medium and high levels that produce the same qr code version for ur and bbqr
What changed, and why it matters
This commit changes how Sparrow Wallet lets users pick QR code density when displaying animated QR codes. It replaces a two-option toggle (Normal/Low) with three levels (Low/Medium/High) and makes sure both supported QR formats (UR and BBQr) produce QR codes of the same physical size at each level. It also adds a test to verify that consistency. There is no security vulnerability visible in the change.
No security action required. Treat as a normal feature/usability improvement. Reviewers may optionally verify that the new default-density migration logic behaves as intended for existing users.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch refactors QRDensity enum to three levels and assigns UR and BBQr fragment lengths so that the resulting QR code version is identical for both encodings at each level (versions 7, 17, 27). QRDisplayDialog now uses a ComboBox for density selection, derives a default density from the legacy setting, and recreates encoders when density changes. Config.java separates the legacy density field from the new density level field. A unit test iterates over random payload sizes and asserts matching QR versions for UR and BBQr at each density.
Changed components
QR code display dialogUR/BBQr animated QR encodingApplication configuration persistenceQRDensity unit testsInspect captured patch +98 / −30
### src/main/java/com/sparrowwallet/sparrow/control/QRDensity.java
@@ -1,8 +1,13 @@
package com.sparrowwallet.sparrow.control;
+/**
+ * QR code density levels applying to all encodings. For each level, the UR and BBQr fragment lengths are chosen to produce the same maximum QR code version
+ * (7, 17 and 27 respectively), since scanner capability does not depend on the encoding.
+ */
public enum QRDensity {
- NORMAL("Normal", 400, 2000),
- LOW("Low", 80, 1000);
+ LOW("Low", 80, 216),
+ NORMAL("Medium", 400, 928),
+ HIGH("High", 1000, 2000);
private final String name;
private final int maxUrFragmentLength;
@@ -25,4 +30,9 @@ public int getMaxUrFragmentLength() {
public int getMaxBbqrFragmentLength() {
return maxBbqrFragmentLength;
}
+
+ @Override
+ public String toString() {
+ return name + " Density";
+ }
}
### src/main/java/com/sparrowwallet/sparrow/control/QRDisplayDialog.java
@@ -10,7 +10,6 @@
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
-import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.hummingbird.UR;
@@ -63,6 +62,8 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
private QREncoding encoding = QREncoding.UR;
+ private QRDensity defaultDensity;
+
private final ImageView qrImageView;
private AnimateQRService animateQRService;
@@ -92,13 +93,22 @@ public QRDisplayDialog(UR ur, BBQR bbqr, String raw, boolean addLegacyEncodingOp
this.bbqr = bbqr;
this.addLegacyEncodingOption = bbqr == null && addLegacyEncodingOption;
- this.urEncoder = new UREncoder(ur, Config.get().getQrDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
+ if(bbqr != null && (defaultEncoding == QREncoding.BBQR || Config.get().getQrEncoding() == QREncoding.BBQR)) {
+ encoding = QREncoding.BBQR;
+ }
+
+ //Until a density level is chosen, use the level closest to the previous density setting, which applied to each encoding separately
+ QRDensity legacyDensity = Config.get().getLegacyQrDensity();
+ if(encoding == QREncoding.BBQR) {
+ this.defaultDensity = legacyDensity == QRDensity.LOW ? QRDensity.NORMAL : QRDensity.HIGH;
+ } else {
+ this.defaultDensity = legacyDensity == QRDensity.LOW ? QRDensity.LOW : QRDensity.NORMAL;
+ }
+
+ this.urEncoder = new UREncoder(ur, 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(defaultEncoding == QREncoding.BBQR || Config.get().getQrEncoding() == QREncoding.BBQR) {
- encoding = QREncoding.BBQR;
- }
+ this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), getQRDensity().getMaxBbqrFragmentLength(), 0);
}
this.raw = raw;
@@ -302,16 +312,20 @@ private void setEncoding(QREncoding encoding) {
restartAnimation();
}
+ private QRDensity getQRDensity() {
+ return Config.get().getQrDensity() == null ? defaultDensity : Config.get().getQrDensity();
+ }
+
private void changeQRDensity() {
if(animateQRService != null) {
animateQRService.cancel();
}
if(bbqr != null) {
- this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
+ this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), getQRDensity().getMaxBbqrFragmentLength(), 0);
}
- this.urEncoder = new UREncoder(ur, Config.get().getQrDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
+ this.urEncoder = new UREncoder(ur, getQRDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
restartAnimation();
}
@@ -365,29 +379,32 @@ protected Node createButton(ButtonType buttonType) {
return legacy;
} else {
- Button density = new Button(buttonType.getText());
- density.setPrefWidth(160);
- density.setGraphicTextGap(5);
- updateDensityButton(density);
+ ComboBox<QRDensity> densityComboBox = new ComboBox<>();
+ densityComboBox.getItems().addAll(QRDensity.values());
+ densityComboBox.setValue(getQRDensity());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
- ButtonBar.setButtonData(density, buttonData);
- density.setOnAction(event -> {
+ ButtonBar.setButtonData(densityComboBox, buttonData);
+ densityComboBox.setOnAction(_ -> {
+ if(densityComboBox.getValue() == getQRDensity()) {
+ return;
+ }
+
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;
} else {
+ densityComboBox.setValue(getQRDensity());
return;
}
}
- Config.get().setQrDensity(Config.get().getQrDensity() == QRDensity.NORMAL ? QRDensity.LOW : QRDensity.NORMAL);
- updateDensityButton(density);
+ Config.get().setQrDensity(densityComboBox.getValue());
changeQRDensity();
});
- return density;
+ return densityComboBox;
}
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.OK_DONE) {
Button scanButton = (Button)super.createButton(buttonType);
@@ -431,15 +448,6 @@ private void setLegacyGraphic(ToggleButton legacy, boolean useLegacyEncoding) {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.BAN));
}
}
-
- private void updateDensityButton(Button density) {
- density.setText(Config.get().getQrDensity() == QRDensity.NORMAL ? "Less Dense" : "More Dense");
- if(Config.get().getQrDensity() == QRDensity.NORMAL) {
- density.setGraphic(getGlyph(FontAwesome5.Glyph.MAGNIFYING_GLASS_PLUS));
- } else {
- density.setGraphic(getGlyph(FontAwesome5.Glyph.MAGNIFYING_GLASS_MINUS));
- }
- }
}
protected static Glyph getGlyph(FontAwesome5.Glyph glyphName) {
### src/main/java/com/sparrowwallet/sparrow/io/Config.java
@@ -68,6 +68,7 @@ public class Config {
private long dustAttackThresholdSp = DUST_ATTACK_THRESHOLD_SP_SATS;
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private QRDensity qrDensity;
+ private QRDensity qrDensityLevel;
private QREncoding qrEncoding;
private WebcamResolution webcamResolution;
private boolean mirrorCapture = true;
@@ -467,12 +468,16 @@ public int getEnumerateHwPeriod() {
return enumerateHwPeriod;
}
+ public QRDensity getLegacyQrDensity() {
+ return qrDensity;
+ }
+
public QRDensity getQrDensity() {
- return qrDensity == null ? QRDensity.NORMAL : qrDensity;
+ return qrDensityLevel;
}
public void setQrDensity(QRDensity qrDensity) {
- this.qrDensity = qrDensity;
+ this.qrDensityLevel = qrDensity;
flush();
}
### src/test/java/com/sparrowwallet/sparrow/control/QRDensityTest.java
@@ -0,0 +1,45 @@
+package com.sparrowwallet.sparrow.control;
+
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+import com.google.zxing.qrcode.encoder.Encoder;
+import com.sparrowwallet.hummingbird.UR;
+import com.sparrowwallet.hummingbird.UREncoder;
+import com.sparrowwallet.sparrow.io.bbqr.BBQREncoder;
+import com.sparrowwallet.sparrow.io.bbqr.BBQREncoding;
+import com.sparrowwallet.sparrow.io.bbqr.BBQRType;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Locale;
+import java.util.Map;
+import java.util.Random;
+
+public class QRDensityTest {
+ private static final Map<QRDensity, Integer> QR_VERSIONS = Map.of(QRDensity.LOW, 7, QRDensity.NORMAL, 17, QRDensity.HIGH, 27);
+
+ @Test
+ public void testEncodingsShareQrVersion() throws Exception {
+ Random random = new Random(1);
+ for(QRDensity density : QRDensity.values()) {
+ int maxUrVersion = 0;
+ int maxBbqrVersion = 0;
+ for(int size = 50; size < 12000; size += 37) {
+ byte[] data = new byte[size];
+ random.nextBytes(data);
+
+ UREncoder urEncoder = new UREncoder(UR.fromBytes("crypto-psbt", data), density.getMaxUrFragmentLength(), 10, 0);
+ maxUrVersion = Math.max(maxUrVersion, getQrVersion(urEncoder.nextPart().toUpperCase(Locale.ROOT)));
+
+ BBQREncoder bbqrEncoder = new BBQREncoder(BBQRType.PSBT, BBQREncoding.ZLIB, data, density.getMaxBbqrFragmentLength(), 0);
+ maxBbqrVersion = Math.max(maxBbqrVersion, getQrVersion(bbqrEncoder.nextPart().toUpperCase(Locale.ROOT)));
+ }
+
+ Assertions.assertEquals(QR_VERSIONS.get(density), maxUrVersion, density + " UR");
+ Assertions.assertEquals(QR_VERSIONS.get(density), maxBbqrVersion, density + " BBQr");
+ }
+ }
+
+ private static int getQrVersion(String part) throws Exception {
+ return Encoder.encode(part, ErrorCorrectionLevel.L).getVersion().getVersionNumber();
+ }
+}Why this scored 18/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.