diff --git a/build.gradle b/build.gradle index e212e58a..6907be64 100644 --- a/build.gradle +++ b/build.gradle @@ -160,6 +160,7 @@ run { "--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx", "--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow", "--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow", + "--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow", "--add-opens=java.base/java.net=com.sparrowwallet.sparrow", "--add-opens=java.base/java.io=com.google.gson", "--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow"] @@ -208,6 +209,7 @@ jlink { "--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx", "--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx", "--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow", + "--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow", "--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow", "--add-opens=java.base/java.net=com.sparrowwallet.sparrow", "--add-opens=java.base/java.io=com.google.gson", diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MnemonicGridDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicGridDialog.java new file mode 100644 index 00000000..fe8133d6 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicGridDialog.java @@ -0,0 +1,196 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import com.sparrowwallet.sparrow.io.PdfUtils; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.StackPane; +import javafx.stage.FileChooser; +import org.controlsfx.control.spreadsheet.*; +import org.controlsfx.glyphfont.Glyph; +import org.controlsfx.tools.Platform; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + + +public class MnemonicGridDialog extends Dialog> { + private final SpreadsheetView spreadsheetView; + + private final BooleanProperty initializedProperty = new SimpleBooleanProperty(false); + private final BooleanProperty wordsSelectedProperty = new SimpleBooleanProperty(false); + + public MnemonicGridDialog() { + DialogPane dialogPane = new MnemonicGridDialogPane(); + setDialogPane(dialogPane); + setTitle("Border Wallets Grid"); + dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); + dialogPane.getStylesheets().add(AppServices.class.getResource("grid.css").toExternalForm()); + dialogPane.setHeaderText("Load a Border Wallets PDF, and select 11 or 23 words in the grid.\nThe order of selection is important!"); + javafx.scene.image.Image image = new Image("/image/border-wallets.png"); + dialogPane.setGraphic(new ImageView(image)); + + String[][] emptyWordGrid = new String[128][16]; + Grid grid = getGrid(emptyWordGrid); + + spreadsheetView = new SpreadsheetView(grid); + spreadsheetView.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { + try { + Field f = event.getClass().getDeclaredField(Platform.getCurrent() == Platform.OSX ? "metaDown" : "controlDown"); + f.setAccessible(true); + f.set(event, true); + } catch(IllegalAccessException | NoSuchFieldException e) { + //ignore + } + }); + spreadsheetView.setId("grid"); + spreadsheetView.setEditable(false); + spreadsheetView.setFixingColumnsAllowed(false); + spreadsheetView.setFixingRowsAllowed(false); + + spreadsheetView.getSelectionModel().getSelectedCells().addListener(new ListChangeListener<>() { + @Override + public void onChanged(Change c) { + int numWords = c.getList().size(); + wordsSelectedProperty.set(numWords == 11 || numWords == 23); + } + }); + + StackPane stackPane = new StackPane(); + stackPane.getChildren().add(spreadsheetView); + dialogPane.setContent(stackPane); + + stackPane.widthProperty().addListener((observable, oldValue, newValue) -> { + if(newValue != null) { + for(SpreadsheetColumn column : spreadsheetView.getColumns()) { + column.setPrefWidth((newValue.doubleValue() - spreadsheetView.getRowHeaderWidth() - 3) / 17); + } + } + }); + + dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load PDF", ButtonBar.ButtonData.LEFT); + dialogPane.getButtonTypes().add(loadCsvButtonType); + + Button okButton = (Button)dialogPane.lookupButton(ButtonType.OK); + okButton.disableProperty().bind(Bindings.not(Bindings.and(initializedProperty, wordsSelectedProperty))); + + setResultConverter((dialogButton) -> { + ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData(); + return data == ButtonBar.ButtonData.OK_DONE ? getSelectedWords() : null; + }); + + dialogPane.setPrefWidth(850); + dialogPane.setPrefHeight(500); + AppServices.setStageIcon(dialogPane.getScene().getWindow()); + AppServices.moveToActiveWindowScreen(this); + } + + private Grid getGrid(String[][] wordGrid) { + int rowCount = wordGrid.length; + int columnCount = wordGrid[0].length; + GridBase grid = new GridBase(rowCount, columnCount); + ObservableList> rows = FXCollections.observableArrayList(); + grid.getColumnHeaders().setAll("A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P"); + for(int i = 0; i < rowCount; i++) { + final ObservableList list = FXCollections.observableArrayList(); + for(int j = 0; j < columnCount; j++) { + list.add(createCell(i, j, wordGrid[i][j])); + } + rows.add(list); + grid.getRowHeaders().add(String.format("%03d", i + 1)); + } + grid.setRows(rows); + + return grid; + } + + private SpreadsheetCell createCell(int row, int column, String word) { + return SpreadsheetCellType.STRING.createCell(row, column, 1, 1, word == null ? "" : word); + } + + private List getSelectedWords() { + List abbreviations = spreadsheetView.getSelectionModel().getSelectedCells().stream() + .map(position -> (String)spreadsheetView.getGrid().getRows().get(position.getRow()).get(position.getColumn()).getItem()).collect(Collectors.toList()); + + List words = new ArrayList<>(); + for(String abbreviation : abbreviations) { + for(String word : Bip39MnemonicCode.INSTANCE.getWordList()) { + if((abbreviation.length() == 3 && word.equals(abbreviation)) || (abbreviation.length() >= 4 && word.startsWith(abbreviation))) { + words.add(word); + break; + } + } + } + + if(words.size() != abbreviations.size()) { + abbreviations.removeIf(abbr -> words.stream().anyMatch(w -> w.startsWith(abbr))); + throw new IllegalStateException("Could not find words for abbreviations: " + abbreviations); + } + + return words; + } + + private class MnemonicGridDialogPane extends DialogPane { + @Override + protected Node createButton(ButtonType buttonType) { + Node button; + if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) { + Button loadButton = new Button(buttonType.getText()); + loadButton.setGraphicTextGap(5); + loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP)); + final ButtonBar.ButtonData buttonData = buttonType.getButtonData(); + ButtonBar.setButtonData(loadButton, buttonData); + loadButton.setOnAction(event -> { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Open PDF"); + fileChooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"), + new FileChooser.ExtensionFilter("PDF", "*.pdf") + ); + + AppServices.moveToActiveWindowScreen(this.getScene().getWindow(), 800, 450); + File file = fileChooser.showOpenDialog(this.getScene().getWindow()); + if(file != null) { + try(BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file))) { + String[][] wordGrid = PdfUtils.getWordGrid(inputStream); + spreadsheetView.setGrid(getGrid(wordGrid)); + initializedProperty.set(true); + } catch(Exception e) { + AppServices.showErrorDialog("Cannot load PDF", e.getMessage()); + } + } + }); + + button = loadButton; + } else { + button = super.createButton(buttonType); + } + + return button; + } + + private Glyph getGlyph(FontAwesome5.Glyph glyphName) { + Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName); + glyph.setFontSize(11); + return glyph; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystorePane.java b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystorePane.java index 35dc2568..7ef944c8 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystorePane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/MnemonicKeystorePane.java @@ -12,6 +12,8 @@ import javafx.beans.property.SimpleStringProperty; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; +import javafx.concurrent.ScheduledService; +import javafx.concurrent.Task; import javafx.geometry.Insets; import javafx.geometry.Orientation; import javafx.geometry.Pos; @@ -20,6 +22,7 @@ import javafx.scene.control.*; import javafx.scene.input.Clipboard; import javafx.scene.layout.*; import javafx.util.Callback; +import javafx.util.Duration; import org.controlsfx.control.textfield.AutoCompletionBinding; import org.controlsfx.control.textfield.TextFields; import org.controlsfx.glyphfont.Glyph; @@ -78,6 +81,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane { enterMnemonicButton.getItems().add(item); } enterMnemonicButton.getItems().add(new SeparatorMenuItem()); + MenuItem gridItem = new MenuItem("Border Wallets..."); + gridItem.setOnAction(event -> { + showGrid(); + }); + enterMnemonicButton.getItems().add(gridItem); + MenuItem scanItem = new MenuItem("Scan QR..."); scanItem.setOnAction(event -> { scanQR(); @@ -86,6 +95,42 @@ public class MnemonicKeystorePane extends TitledDescriptionPane { enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty()); } + protected void showGrid() { + MnemonicGridDialog mnemonicGridDialog = new MnemonicGridDialog(); + Optional> optWords = mnemonicGridDialog.showAndWait(); + if(optWords.isPresent()) { + List words = optWords.get(); + setContent(getMnemonicWordsEntry(words.size() + 1, true)); + setExpanded(true); + + for(int i = 0; i < wordsPane.getChildren().size(); i++) { + WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i); + if(i < words.size()) { + wordEntry.getEditor().setText(words.get(i)); + wordEntry.getEditor().setEditable(false); + } else { + ScheduledService service = new ScheduledService<>() { + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected Void call() { + return null; + } + }; + } + }; + service.setDelay(Duration.millis(500)); + service.setOnSucceeded(event1 -> { + service.cancel(); + Platform.runLater(() -> wordEntry.getEditor().requestFocus()); + }); + service.start(); + } + } + } + } + protected void scanQR() { QRScanDialog qrScanDialog = new QRScanDialog(); Optional optionalResult = qrScanDialog.showAndWait(); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/PdfUtils.java b/src/main/java/com/sparrowwallet/sparrow/io/PdfUtils.java index 67b730be..636422e9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/PdfUtils.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/PdfUtils.java @@ -13,6 +13,7 @@ import com.lowagie.text.pdf.PdfReader; import com.lowagie.text.pdf.PdfWriter; import com.lowagie.text.pdf.parser.PdfTextExtractor; import com.sparrowwallet.drongo.OutputDescriptor; +import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.hummingbird.UREncoder; @@ -25,8 +26,8 @@ import org.slf4j.LoggerFactory; import java.awt.*; import java.io.*; -import java.util.Locale; -import java.util.Scanner; +import java.util.*; +import java.util.List; public class PdfUtils { private static final Logger log = LoggerFactory.getLogger(PdfUtils.class); @@ -108,4 +109,33 @@ public class PdfUtils { ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); return new javafx.scene.image.Image(bais); } + + public static String[][] getWordGrid(InputStream inputStream) { + try { + PdfReader pdfReader = new PdfReader(inputStream); + String allText = ""; + for(int page = 1; page <= pdfReader.getNumberOfPages(); page++) { + PdfTextExtractor textExtractor = new PdfTextExtractor(pdfReader); + allText += textExtractor.getTextFromPage(page) + "\n"; + } + + List rows = new ArrayList<>(); + Scanner scanner = new Scanner(allText); + while(scanner.hasNextLine()) { + String line = scanner.nextLine().trim(); + String[] words = line.split(" "); + if(words.length > 16 && Utils.isNumber(words[0])) { + rows.add(Arrays.copyOfRange(words, 1, 17)); + } + } + + if(rows.size() < 128) { + throw new IllegalArgumentException("Not a valid Border Wallets PDF"); + } + + return rows.toArray(new String[][]{new String[0]}); + } catch(Exception e) { + throw new IllegalArgumentException("Not a valid Border Wallets PDF"); + } + } } diff --git a/src/main/resources/com/sparrowwallet/sparrow/grid.css b/src/main/resources/com/sparrowwallet/sparrow/grid.css new file mode 100644 index 00000000..58fb44b8 --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/grid.css @@ -0,0 +1,5 @@ +#grid .spreadsheet-cell:selected, +#grid .spreadsheet-cell:focused:selected, +#grid .spreadsheet-cell:focused:selected:hover { + -fx-background-color: rgb(238, 210, 2); +} \ No newline at end of file diff --git a/src/main/resources/image/border-wallets.png b/src/main/resources/image/border-wallets.png new file mode 100644 index 00000000..660f466c Binary files /dev/null and b/src/main/resources/image/border-wallets.png differ diff --git a/src/main/resources/image/border-wallets@2x.png b/src/main/resources/image/border-wallets@2x.png new file mode 100644 index 00000000..0a3093c4 Binary files /dev/null and b/src/main/resources/image/border-wallets@2x.png differ diff --git a/src/main/resources/image/border-wallets@3x.png b/src/main/resources/image/border-wallets@3x.png new file mode 100644 index 00000000..0e3d1149 Binary files /dev/null and b/src/main/resources/image/border-wallets@3x.png differ