From 387717c6c5411512449ce51485c9d4ca84bde21e Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Sat, 26 Oct 2013 02:28:44 +0200 Subject: [PATCH] Wallet template: various updates. Backport misc improvements from PayFile. Refactor the clickable address out into a custom widget. Use FontAwesome and the wrapper class for icons instead of a custom image. QRcode support. --- .../com/google/bitcoin/uri/BitcoinURI.java | 3 +- wallettemplate/pom.xml | 10 ++ .../main/java/wallettemplate/Controller.java | 71 ++------- .../src/main/java/wallettemplate/Main.java | 52 +++++-- .../wallettemplate/SendMoneyController.java | 1 + .../BitcoinAddressValidator.java | 2 +- .../controls/ClickableBitcoinAddress.java | 144 ++++++++++++++++++ .../java/wallettemplate/utils/GuiUtils.java | 15 +- .../utils/ThrottledRunLater.java | 58 +++++++ .../resources/wallettemplate/copy-icon.png | Bin 1010 -> 0 bytes .../main/resources/wallettemplate/main.fxml | 30 +--- 11 files changed, 280 insertions(+), 106 deletions(-) rename wallettemplate/src/main/java/wallettemplate/{ => controls}/BitcoinAddressValidator.java (97%) create mode 100644 wallettemplate/src/main/java/wallettemplate/controls/ClickableBitcoinAddress.java create mode 100644 wallettemplate/src/main/java/wallettemplate/utils/ThrottledRunLater.java delete mode 100644 wallettemplate/src/main/resources/wallettemplate/copy-icon.png diff --git a/core/src/main/java/com/google/bitcoin/uri/BitcoinURI.java b/core/src/main/java/com/google/bitcoin/uri/BitcoinURI.java index 0dd56d48..d1223580 100644 --- a/core/src/main/java/com/google/bitcoin/uri/BitcoinURI.java +++ b/core/src/main/java/com/google/bitcoin/uri/BitcoinURI.java @@ -306,7 +306,8 @@ public class BitcoinURI { * @param message A message * @return A String containing the Bitcoin URI */ - public static String convertToBitcoinURI(String address, BigInteger amount, String label, String message) { + public static String convertToBitcoinURI(String address, @Nullable BigInteger amount, @Nullable String label, + @Nullable String message) { checkNotNull(address); if (amount != null && amount.compareTo(BigInteger.ZERO) < 0) { throw new IllegalArgumentException("Amount must be positive"); diff --git a/wallettemplate/pom.xml b/wallettemplate/pom.xml index 13815ff0..c54d9ca1 100644 --- a/wallettemplate/pom.xml +++ b/wallettemplate/pom.xml @@ -44,5 +44,15 @@ aquafx 0.1 + + de.jensd + fontawesomefx + 8.0.0 + + + net.glxn + qrgen + 1.3 + \ No newline at end of file diff --git a/wallettemplate/src/main/java/wallettemplate/Controller.java b/wallettemplate/src/main/java/wallettemplate/Controller.java index a9bd30a3..428ec546 100644 --- a/wallettemplate/src/main/java/wallettemplate/Controller.java +++ b/wallettemplate/src/main/java/wallettemplate/Controller.java @@ -1,27 +1,22 @@ package wallettemplate; -import com.google.bitcoin.core.*; -import com.google.bitcoin.uri.BitcoinURI; +import com.google.bitcoin.core.AbstractWalletEventListener; +import com.google.bitcoin.core.DownloadListener; +import com.google.bitcoin.core.Utils; +import com.google.bitcoin.core.Wallet; import javafx.animation.*; import javafx.application.Platform; import javafx.event.ActionEvent; import javafx.scene.control.Button; -import javafx.scene.control.*; +import javafx.scene.control.ContextMenu; import javafx.scene.control.Label; -import javafx.scene.image.ImageView; -import javafx.scene.input.Clipboard; -import javafx.scene.input.ClipboardContent; -import javafx.scene.input.MouseButton; -import javafx.scene.input.MouseEvent; +import javafx.scene.control.ProgressBar; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import javafx.util.Duration; -import wallettemplate.utils.GuiUtils; +import wallettemplate.controls.ClickableBitcoinAddress; -import java.awt.*; -import java.io.IOException; import java.math.BigInteger; -import java.net.URI; import java.util.Date; import static com.google.common.base.Preconditions.checkState; @@ -36,66 +31,23 @@ public class Controller { public ProgressBar syncProgress; public VBox syncBox; public HBox controlsBox; - public Label requestMoneyLink; public Label balance; public ContextMenu addressMenu; - public HBox addressLabelBox; public Button sendMoneyOutBtn; - public ImageView copyWidget; - - private Address primaryAddress; + public ClickableBitcoinAddress addressControl; // Called by FXMLLoader. public void initialize() { syncProgress.setProgress(-1); - addressLabelBox.setOpacity(0.0); - Tooltip tooltip = new Tooltip("Copy address to clipboard"); - Tooltip.install(copyWidget, tooltip); + addressControl.setOpacity(0.0); } public void onBitcoinSetup() { bitcoin.wallet().addEventListener(new BalanceUpdater()); - primaryAddress = bitcoin.wallet().getKeys().get(0).toAddress(Main.params); + addressControl.setAddress(bitcoin.wallet().getKeys().get(0).toAddress(Main.params).toString()); refreshBalanceLabel(); } - public void requestMoney(MouseEvent event) { - // User clicked on the address. - if (event.getButton() == MouseButton.SECONDARY || (event.getButton() == MouseButton.PRIMARY && event.isMetaDown())) { - addressMenu.show(requestMoneyLink, event.getScreenX(), event.getScreenY()); - } else { - String uri = getURI(); - System.out.println("Opening " + uri); - try { - Desktop.getDesktop().browse(URI.create(uri)); - } catch (IOException e) { - // Couldn't open wallet app. - GuiUtils.informationalAlert("Opening wallet app failed", "Perhaps you don't have one installed?"); - } - } - } - - private String getURI() { - return BitcoinURI.convertToBitcoinURI(getAddress(), Utils.COIN, Main.APP_NAME, null); - } - - private String getAddress() { - return primaryAddress.toString(); - } - - public void copyWidgetClicked(MouseEvent event) { - copyAddress(null); - } - - public void copyAddress(ActionEvent event) { - // User clicked icon or menu item. - Clipboard clipboard = Clipboard.getSystemClipboard(); - ClipboardContent content = new ClipboardContent(); - content.putString(getAddress()); - content.putHtml(String.format("%s", getURI(), getAddress())); - clipboard.setContent(content); - } - public void sendMoneyOut(ActionEvent event) { // Hide this UI and show the send money UI. This UI won't be clickable until the user dismisses send_money. Main.instance.overlayUI("send_money.fxml"); @@ -122,8 +74,7 @@ public class Controller { // Buttons slide in and clickable address appears simultaneously. TranslateTransition arrive = new TranslateTransition(Duration.millis(600), controlsBox); arrive.setToY(0.0); - requestMoneyLink.setText(primaryAddress.toString()); - FadeTransition reveal = new FadeTransition(Duration.millis(500), addressLabelBox); + FadeTransition reveal = new FadeTransition(Duration.millis(500), addressControl); reveal.setToValue(1.0); ParallelTransition group = new ParallelTransition(arrive, reveal); // Slide out happens then slide in/fade happens. diff --git a/wallettemplate/src/main/java/wallettemplate/Main.java b/wallettemplate/src/main/java/wallettemplate/Main.java index 94248b7e..1f03c0fd 100644 --- a/wallettemplate/src/main/java/wallettemplate/Main.java +++ b/wallettemplate/src/main/java/wallettemplate/Main.java @@ -24,6 +24,8 @@ import java.io.File; import java.io.IOException; import java.net.URL; +import static wallettemplate.utils.GuiUtils.*; + public class Main extends Application { public static String APP_NAME = "WalletTemplate"; @@ -37,6 +39,7 @@ public class Main extends Application { @Override public void start(Stage mainWindow) throws Exception { instance = this; + // Show the crash dialog for any exceptions that we don't handle and that hit the main loop. GuiUtils.handleCrashesOnThisThread(); try { init(mainWindow); @@ -57,7 +60,7 @@ public class Main extends Application { // Load the GUI. The Controller class will be automagically created and wired up. URL location = getClass().getResource("main.fxml"); FXMLLoader loader = new FXMLLoader(location); - mainUI = (Pane) loader.load(); + mainUI = loader.load(); Controller controller = loader.getController(); // Configure the window with a StackPane so we can overlay things on top of the main UI. uiStack = new StackPane(mainUI); @@ -89,6 +92,7 @@ public class Main extends Application { // or progress widget to keep the user engaged whilst we initialise, but we don't. bitcoin.setDownloadListener(controller.progressBarUpdater()) .setBlockingStartup(false) + .setUserAgent(APP_NAME, "1.0") .startAndWait(); // Don't make the user wait for confirmations for now, as the intention is they're sending it their own money! bitcoin.wallet().allowSpendingUnconfirmedTransactions(); @@ -97,40 +101,58 @@ public class Main extends Application { mainWindow.show(); } - public class OverlayUI { - Node ui; - Object controller; + public class OverlayUI { + public Node ui; + public T controller; - public OverlayUI(Node ui, Object controller) { + public OverlayUI(Node ui, T controller) { this.ui = ui; this.controller = controller; } + public void show() { + blurOut(mainUI); + uiStack.getChildren().add(ui); + fadeIn(ui); + } + public void done() { - GuiUtils.fadeOutAndRemove(ui, uiStack); - GuiUtils.blurIn(mainUI); + checkGuiThread(); + fadeOutAndRemove(ui, uiStack); + blurIn(mainUI); this.ui = null; this.controller = null; } } - /** Loads the FXML file with the given name, blurs out the main UI and puts this one on top. */ - public OverlayUI overlayUI(String name) { + public OverlayUI overlayUI(Node node, T controller) { + checkGuiThread(); + OverlayUI pair = new OverlayUI(node, controller); + // Auto-magically set the overlayUi member, if it's there. try { + controller.getClass().getDeclaredField("overlayUi").set(controller, pair); + } catch (IllegalAccessException | NoSuchFieldException ignored) { + } + pair.show(); + return pair; + } + + /** Loads the FXML file with the given name, blurs out the main UI and puts this one on top. */ + public OverlayUI overlayUI(String name) { + try { + checkGuiThread(); // Load the UI from disk. URL location = getClass().getResource(name); FXMLLoader loader = new FXMLLoader(location); - Pane ui = (Pane) loader.load(); - Object controller = loader.getController(); - OverlayUI pair = new OverlayUI(ui, controller); + Pane ui = loader.load(); + T controller = loader.getController(); + OverlayUI pair = new OverlayUI(ui, controller); // Auto-magically set the overlayUi member, if it's there. try { controller.getClass().getDeclaredField("overlayUi").set(controller, pair); } catch (IllegalAccessException | NoSuchFieldException ignored) { } - GuiUtils.blurOut(mainUI); - uiStack.getChildren().add(ui); - GuiUtils.fadeIn(ui); + pair.show(); return pair; } catch (IOException e) { throw new RuntimeException(e); // Can't happen. diff --git a/wallettemplate/src/main/java/wallettemplate/SendMoneyController.java b/wallettemplate/src/main/java/wallettemplate/SendMoneyController.java index fc36f5a7..b8882ed9 100644 --- a/wallettemplate/src/main/java/wallettemplate/SendMoneyController.java +++ b/wallettemplate/src/main/java/wallettemplate/SendMoneyController.java @@ -11,6 +11,7 @@ import javafx.event.ActionEvent; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.TextField; +import wallettemplate.controls.BitcoinAddressValidator; import wallettemplate.utils.GuiUtils; public class SendMoneyController { diff --git a/wallettemplate/src/main/java/wallettemplate/BitcoinAddressValidator.java b/wallettemplate/src/main/java/wallettemplate/controls/BitcoinAddressValidator.java similarity index 97% rename from wallettemplate/src/main/java/wallettemplate/BitcoinAddressValidator.java rename to wallettemplate/src/main/java/wallettemplate/controls/BitcoinAddressValidator.java index 22f32c3a..486ec199 100644 --- a/wallettemplate/src/main/java/wallettemplate/BitcoinAddressValidator.java +++ b/wallettemplate/src/main/java/wallettemplate/controls/BitcoinAddressValidator.java @@ -1,4 +1,4 @@ -package wallettemplate; +package wallettemplate.controls; import com.google.bitcoin.core.Address; import com.google.bitcoin.core.AddressFormatException; diff --git a/wallettemplate/src/main/java/wallettemplate/controls/ClickableBitcoinAddress.java b/wallettemplate/src/main/java/wallettemplate/controls/ClickableBitcoinAddress.java new file mode 100644 index 00000000..b28450eb --- /dev/null +++ b/wallettemplate/src/main/java/wallettemplate/controls/ClickableBitcoinAddress.java @@ -0,0 +1,144 @@ +package wallettemplate.controls; + +import com.google.bitcoin.uri.BitcoinURI; +import de.jensd.fx.fontawesome.AwesomeDude; +import de.jensd.fx.fontawesome.AwesomeIcon; +import javafx.beans.property.StringProperty; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.effect.DropShadow; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.Pane; +import net.glxn.qrgen.QRCode; +import net.glxn.qrgen.image.ImageType; +import wallettemplate.Main; +import wallettemplate.utils.GuiUtils; + +import java.awt.*; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; + +// This control can be used with Scene Builder as long as we don't use any Java 8 features yet. Once Oracle release +// a new Scene Builder compiled against Java 8, we'll be able to use lambdas and so on here. Until that day comes, +// this file specifically must be recompiled against Java 7 for main.fxml to be editable visually. +// +// From the java directory: +// +// javac -cp $HOME/.m2/repository/net/glxn/qrgen/1.3/qrgen-1.3.jar:$HOME/.m2/repository/de/jensd/fontawesomefx/8.0.0/fontawesomefx-8.0.0.jar:../../../target/classes:../../../../core/target/bitcoinj-0.11-SNAPSHOT.jar -d ../../../target/classes/ -source 1.7 -target 1.7 wallettemplate/controls/ClickableBitcoinAddress.java + + +/** + * A custom control that implements a clickable, copyable Bitcoin address. Clicking it opens a local wallet app. The + * address looks like a blue hyperlink. Next to it there are two icons, one that copies to the clipboard and another + * that shows a QRcode. + */ +public class ClickableBitcoinAddress extends AnchorPane { + @FXML protected Label addressLabel; + @FXML protected ContextMenu addressMenu; + @FXML protected Label copyWidget; + @FXML protected Label qrCode; + + public ClickableBitcoinAddress() { + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("bitcoin_address.fxml")); + loader.setRoot(this); + loader.setController(this); + // The following line is supposed to help Scene Builder, although it doesn't seem to be needed for me. + loader.setClassLoader(getClass().getClassLoader()); + loader.load(); + + AwesomeDude.setIcon(copyWidget, AwesomeIcon.COPY); + Tooltip.install(copyWidget, new Tooltip("Copy address to clipboard")); + + AwesomeDude.setIcon(qrCode, AwesomeIcon.QRCODE); + Tooltip.install(qrCode, new Tooltip("Show a barcode scannable with a mobile phone for this address")); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public String uri() { + return BitcoinURI.convertToBitcoinURI(getAddress(), null, Main.APP_NAME, null); + } + + public String getAddress() { + return addressLabel.getText(); + } + + public void setAddress(String address) { + addressLabel.setText(address); + } + + public StringProperty addressProperty() { + return addressLabel.textProperty(); + } + + @FXML + protected void copyAddress(ActionEvent event) { + // User clicked icon or menu item. + Clipboard clipboard = Clipboard.getSystemClipboard(); + ClipboardContent content = new ClipboardContent(); + content.putString(getAddress()); + content.putHtml(String.format("%s", uri(), getAddress())); + clipboard.setContent(content); + } + + @FXML + protected void requestMoney(MouseEvent event) { + if (event.getButton() == MouseButton.SECONDARY || (event.getButton() == MouseButton.PRIMARY && event.isMetaDown())) { + // User right clicked or the Mac equivalent. Show the context menu. + addressMenu.show(addressLabel, event.getScreenX(), event.getScreenY()); + } else { + // User left clicked. + try { + Desktop.getDesktop().browse(URI.create(uri())); + } catch (IOException e) { + GuiUtils.informationalAlert("Opening wallet app failed", "Perhaps you don't have one installed?"); + } + } + } + + @FXML + protected void copyWidgetClicked(MouseEvent event) { + copyAddress(null); + } + + @FXML + protected void showQRCode(MouseEvent event) { + // Serialize to PNG and back into an image. Pretty lame but it's the shortest code to write and I'm feeling + // lazy tonight. + final byte[] imageBytes = QRCode + .from(uri()) + .withSize(320, 240) + .to(ImageType.PNG) + .stream() + .toByteArray(); + Image qrImage = new Image(new ByteArrayInputStream(imageBytes)); + ImageView view = new ImageView(qrImage); + view.setEffect(new DropShadow()); + // Embed the image in a pane to ensure the drop-shadow interacts with the fade nicely, otherwise it looks weird. + // Then fix the width/height to stop it expanding to fill the parent, which would result in the image being + // non-centered on the screen. Finally fade/blur it in. + Pane pane = new Pane(view); + pane.setMaxSize(qrImage.getWidth(), qrImage.getHeight()); + final Main.OverlayUI overlay = Main.instance.overlayUI(pane, this); + view.setOnMouseClicked(new EventHandler() { + @Override + public void handle(MouseEvent event) { + overlay.done(); + } + }); + } +} diff --git a/wallettemplate/src/main/java/wallettemplate/utils/GuiUtils.java b/wallettemplate/src/main/java/wallettemplate/utils/GuiUtils.java index d1c5d371..87e4deab 100644 --- a/wallettemplate/src/main/java/wallettemplate/utils/GuiUtils.java +++ b/wallettemplate/src/main/java/wallettemplate/utils/GuiUtils.java @@ -15,6 +15,8 @@ import javafx.util.Duration; import java.io.IOException; import java.util.function.BiConsumer; +import static com.google.common.base.Preconditions.checkState; + public class GuiUtils { private static void runAlert(BiConsumer setup) { try { @@ -24,8 +26,8 @@ public class GuiUtils { Stage dialogStage = new Stage(); dialogStage.initModality(Modality.WINDOW_MODAL); FXMLLoader loader = new FXMLLoader(GuiUtils.class.getResource("alert.fxml")); - Pane pane = (Pane) loader.load(); - AlertWindowController controller = (AlertWindowController) loader.getController(); + Pane pane = loader.load(); + AlertWindowController controller = loader.getController(); setup.accept(dialogStage, controller); dialogStage.setScene(new Scene(pane)); dialogStage.showAndWait(); @@ -52,8 +54,9 @@ public class GuiUtils { }); } - public static void informationalAlert(String message, String details) { - Runnable r = () -> runAlert((stage, controller) -> controller.informational(stage, message, details)); + public static void informationalAlert(String message, String details, Object... args) { + String formattedDetails = String.format(details, args); + Runnable r = () -> runAlert((stage, controller) -> controller.informational(stage, message, formattedDetails)); if (Platform.isFxApplicationThread()) r.run(); else @@ -102,4 +105,8 @@ public class GuiUtils { timeline.setOnFinished(actionEvent -> node.setEffect(null)); timeline.play(); } + + public static void checkGuiThread() { + checkState(Platform.isFxApplicationThread()); + } } diff --git a/wallettemplate/src/main/java/wallettemplate/utils/ThrottledRunLater.java b/wallettemplate/src/main/java/wallettemplate/utils/ThrottledRunLater.java new file mode 100644 index 00000000..cae0bcd2 --- /dev/null +++ b/wallettemplate/src/main/java/wallettemplate/utils/ThrottledRunLater.java @@ -0,0 +1,58 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package wallettemplate.utils; + +import javafx.application.Platform; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A simple wrapper around {@link javafx.application.Platform#runLater(Runnable)} which will do nothing if the previous + * invocation of runLater didn't execute on the JavaFX UI thread yet. In this way you can avoid flooding + * the event loop if you have a background thread that for whatever reason wants to update the UI very + * frequently. Without this class you could end up bloating up memory usage and causing the UI to stutter + * if the UI thread couldn't keep up with your background worker. + */ +public class ThrottledRunLater implements Runnable { + private final Runnable runnable; + private final AtomicBoolean pending = new AtomicBoolean(); + + /** Created this way, the no-args runLater will execute this classes run method. */ + public ThrottledRunLater() { + this.runnable = null; + } + + /** Created this way, the no-args runLater will execute the given runnable. */ + public ThrottledRunLater(Runnable runnable) { + this.runnable = runnable; + } + + public void runLater(Runnable runnable) { + if (!pending.getAndSet(true)) { + Platform.runLater(() -> { + pending.set(false); + runnable.run(); + }); + } + } + + public void runLater() { + runLater(runnable != null ? runnable : this); + } + + @Override + public void run() { + } +} diff --git a/wallettemplate/src/main/resources/wallettemplate/copy-icon.png b/wallettemplate/src/main/resources/wallettemplate/copy-icon.png deleted file mode 100644 index 8a9efc6f24c22804dc3e78425459bf3ad86c021f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1010 zcmV^8xj$e|1aTFC^ROEUHuAKl#-=C=BPBREi$?4^ zM$WbOa9+?=RaLc)kB|4CO7N_<)}|1EqJRz@8&Mk*s-yYJ%F2$3i3x|~8Rs-28bNz| z`$%nFosAOJNn`~Y4oj+FaB$Fjq^iHn1Yh0mZ*I4{%^;|I{@k!gNn}wR0n-TxY;tmv z=jZ1$T@t0voKEMc-EO~a5WJ|bx9Q-TO-j5?j2V{12NFO*L4hB!Z@@TOrxn<&z`Wkw-7PC>g91d$hDJd!7{W;HXmEM#r zk_b9GI}HUVHkYo3z;}0dVPj*12ZKTKo~Nd!vOpjJ>FMb_CnrZD=<4c{33LUL?_y9P zF>CPq{jj>a3fS`y4u@cFZVt|zISbj@*+8c?B|$+rQWu#Bq^yM+e3@-;Z^PQ!8f>-7SDwD36<78jR}?%lul#Pi+r{_yaS8(%aWdFI}>TCJDy0L#_(67n4#sR$l~ zYghAlMn;BKv~3+2wNe znmdXVS}Yc^gZa+R4x^&4tgP@07te=k@U!Ug{CJC<#f>8ck_0Mh85 + + @@ -10,6 +12,8 @@ + + @@ -59,31 +63,7 @@ - - - - - - - - - - - - - +