Compare commits

...
20 Commits
Author SHA1 Message Date
Craig Raw be86b4feaa fix access issue with macos show/hide windowing commands 2025-02-27 11:08:01 +02:00
Craig Raw 37763e9557 verify dropped release file instead of first platform specific release file found 2025-02-27 11:03:53 +02:00
Craig Raw 80c4f4f5f6 make wallet labels export and import scannable 2025-02-26 12:01:05 +02:00
Craig Raw 6c3fe93d1e exclude heights of confirming txes from wallet labels export 2025-02-26 11:44:27 +02:00
Craig Raw 76eff2de48 merge wallet labels optional fields draft implementation 2025-02-26 10:47:17 +02:00
Craig Raw 07a6818823 use default key origin information when importing a descriptor without key origin info 2025-02-25 10:55:37 +02:00
Craig Raw 2253a1bb97 add support for onekey pro and classic 1s hardware wallets 2025-02-20 17:04:56 +02:00
Craig Raw 36ee8add08 add bip47 notification transaction test 2025-02-19 11:31:32 +02:00
Craig Raw 883e75c0df add copy payment code to transaction diagram outputs context menu 2025-02-19 08:45:52 +02:00
Craig Raw cc908b09c7 upgrade to libusb 1.0.27 on all platforms 2025-02-18 15:30:34 +02:00
Craig Raw ce963ed5b6 add specific handling for invalid windows device drivers on trezor devices 2025-02-18 13:32:02 +02:00
Craig Raw 951e33dc06 fix handling of high account numbers on ledger devices 2025-02-18 12:45:50 +02:00
Craig Raw 6a6a6b1cca additionally check for trezor model against internal name, improve exception handling on no match 2025-02-16 08:43:45 +02:00
Craig Raw 8953d404fa fix stripping leading zeros from master fingerprint on importing some trezor models 2025-02-14 18:42:44 +02:00
Thauan AmorimandGitHub b366177782 add show transaction as qr button to signed transaction tab when offline
* [feature/1630] Add QR code button on signed transaction screen

* [feature/1630] Button positioning improvements

* [feature/1630] Added owner to qrDisplayDialog
2025-02-13 09:05:11 +02:00
Craig Raw d0c827c2c7 fix various minor issues around multisig keystore labelling and export button visibility 2025-02-13 08:43:55 +02:00
Craig Raw 5c29bf51b7 handle scanning and pasting server urls in the electrum format 2025-02-11 14:03:43 +02:00
Craig Raw d426703dcc fix account discovery on bitbox02 2025-02-11 13:18:21 +02:00
Craig Raw 78f0721168 bump to v2.1.3 2025-02-08 16:12:38 +02:00
Craig Raw 20d3f07059 draft implementation of optional bip329 fields 2025-02-08 11:43:59 +02:00
29 changed files with 365 additions and 85 deletions
+3 -1
View File
@@ -5,7 +5,7 @@ plugins {
id 'org.gradlex.extra-java-module-info' version '1.9'
}
def sparrowVersion = '2.1.2'
def sparrowVersion = '2.1.3'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -189,6 +189,7 @@ application {
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx",
"--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",
@@ -237,6 +238,7 @@ jlink {
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx",
"--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",
+1 -1
Submodule drongo updated: f7d5b4fb8f...5fd8e9416a
+1 -1
Submodule lark updated: c4c7ca8da3...b41e83a002
@@ -1,5 +1,5 @@
Package: sparrow
Version: 2.1.2-1
Version: 2.1.3-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
@@ -1,5 +1,5 @@
Package: sparrow
Version: 2.1.2-1
Version: 2.1.3-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
+1 -1
View File
@@ -1,6 +1,6 @@
Summary: Sparrow
Name: sparrow
Version: 2.1.2
Version: 2.1.3
Release: 1
License: ASL 2.0
Vendor: Unknown
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.1.2</string>
<string>2.1.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -1,9 +1,7 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.google.common.base.Charsets;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.ByteSource;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.policy.PolicyType;
@@ -1369,7 +1367,7 @@ public class AppController implements Initializable {
public void exportWallet(ActionEvent event) {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm);
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm, getSelectedWalletForms());
dlg.initOwner(rootStack.getScene().getWindow());
Optional<Wallet> wallet = dlg.showAndWait();
if(wallet.isPresent()) {
@@ -1471,6 +1469,7 @@ public class AppController implements Initializable {
stage.setAlwaysOnTop(true);
stage.setAlwaysOnTop(false);
if(event.getSource() instanceof File file) {
downloadVerifierDialog.setInitialFile(file);
downloadVerifierDialog.setSignatureFile(file);
}
return;
@@ -1123,7 +1123,8 @@ public class AppServices {
public static boolean isWhirlpoolPostmixCompatible(Wallet wallet) {
return WHIRLPOOL_NETWORKS.contains(Network.get())
&& wallet.getScriptType() != ScriptType.P2TR //Taproot not yet supported
&& wallet.getKeystores().size() == 1;
&& wallet.getKeystores().size() == 1
&& wallet.getKeystores().getFirst().getWalletModel() != WalletModel.BITBOX_02; //BitBox02 does not support high account numbers
}
public static List<Wallet> addWhirlpoolWallets(Wallet decryptedWallet, String walletId, Storage storage) {
@@ -18,7 +18,7 @@ import java.util.*;
public class SparrowWallet {
public static final String APP_ID = "sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "2.1.2";
public static final String APP_VERSION = "2.1.3";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -62,6 +62,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
private static final List<String> ARCHIVE_EXTENSIONS = List.of("zip", "tar.gz", "tar.bz2", "tar.xz", "rar", "7z");
private static final String SPARROW_RELEASE_PREFIX = "sparrow-";
private static final String SPARROW_RELEASE_ALT_PREFIX = "sparrow_";
private static final String SPARROW_SIGNATURE_SUFFIX = "-manifest.txt.asc";
private static final Pattern SPARROW_RELEASE_VERSION = Pattern.compile("[0-9]+(\\.[0-9]+)*");
private static final long MIN_VALID_SPARROW_RELEASE_SIZE = 10 * 1024 * 1024;
@@ -70,6 +71,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
private final ObjectProperty<File> manifest = new SimpleObjectProperty<>();
private final ObjectProperty<File> publicKey = new SimpleObjectProperty<>();
private final ObjectProperty<File> release = new SimpleObjectProperty<>();
private final ObjectProperty<File> initial = new SimpleObjectProperty<>();
private final BooleanProperty manifestDisabled = new SimpleBooleanProperty();
private final BooleanProperty publicKeyDisabled = new SimpleBooleanProperty();
@@ -81,7 +83,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
private static File lastFileParent;
public DownloadVerifierDialog(File initialSignatureFile) {
public DownloadVerifierDialog(File initialFile) {
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
@@ -223,11 +225,17 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
});
release.addListener((observable, oldValue, releaseFile) -> {
if(releaseFile != null) {
initial.set(null);
}
verify();
});
if(initialSignatureFile != null) {
javafx.application.Platform.runLater(() -> signature.set(initialSignatureFile));
if(initialFile != null) {
javafx.application.Platform.runLater(() -> {
initial.set(initialFile);
signature.set(initialFile);
});
}
}
@@ -455,7 +463,8 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
}
}
if(providedFile.getName().toLowerCase(Locale.ROOT).startsWith(SPARROW_RELEASE_PREFIX)) {
String providedName = providedFile.getName().toLowerCase(Locale.ROOT);
if(providedName.startsWith(SPARROW_RELEASE_PREFIX) || providedName.startsWith(SPARROW_RELEASE_ALT_PREFIX)) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(providedFile.getName());
if(matcher.find()) {
String version = matcher.group();
@@ -482,6 +491,15 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
}
private File findReleaseFile(File manifestFile, Map<File, String> manifestMap) {
File initialFile = initial.get();
if(initialFile != null) {
for(File file : manifestMap.keySet()) {
if(initialFile.getName().equals(file.getName())) {
return initialFile;
}
}
}
List<String> releaseExtensions = getReleaseFileExtensions();
List<List<String>> extensionLists = List.of(releaseExtensions, DISK_IMAGE_EXTENSIONS, ARCHIVE_EXTENSIONS, List.of(""));
@@ -565,7 +583,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
}
}
if(name.startsWith(SPARROW_RELEASE_PREFIX) && file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
if((name.startsWith(SPARROW_RELEASE_PREFIX) || name.startsWith(SPARROW_RELEASE_ALT_PREFIX)) && file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(name);
return matcher.find();
}
@@ -578,6 +596,10 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
signature.set(signatureFile);
}
public void setInitialFile(File initialFile) {
initial.set(initialFile);
}
private static class Header extends GridPane {
public Header() {
setMaxWidth(Double.MAX_VALUE);
@@ -168,7 +168,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || exporter instanceof JadeMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
} else if(exporter instanceof Bip129) {
} else if(exporter instanceof Bip129 || exporter instanceof WalletLabels) {
UR ur = UR.fromBytes(outputStream.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false);
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
@@ -702,7 +703,9 @@ public class TransactionDiagram extends GridPane {
paymentBox.getChildren().addAll(region, amountLabel);
}
outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount()));
Wallet bip47Wallet = toWallet != null && toWallet.isBip47() ? toWallet : (toBip47Wallet != null && toBip47Wallet.isBip47() ? toBip47Wallet : null);
PaymentCode paymentCode = bip47Wallet == null ? null : bip47Wallet.getKeystores().getFirst().getExternalPaymentCode();
outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount(), paymentCode));
}
Set<Integer> seenIndexes = new HashSet<>();
@@ -766,7 +769,7 @@ public class TransactionDiagram extends GridPane {
outputsBox.getChildren().add(outputNode.outputLabel);
outputsBox.getChildren().add(createSpacer());
ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount);
ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount, outputNode.paymentCode);
if(!outputNode.outputLabel.getChildren().isEmpty() && outputNode.outputLabel.getChildren().get(0) instanceof Label outputLabelControl) {
outputLabelControl.setContextMenu(contextMenu);
}
@@ -1073,16 +1076,26 @@ public class TransactionDiagram extends GridPane {
public Pane outputLabel;
public Address address;
public long amount;
public PaymentCode paymentCode;
public OutputNode(Pane outputLabel, Address address, long amount) {
this(outputLabel, address, amount, null);
}
public OutputNode(Pane outputLabel, Address address, long amount, PaymentCode paymentCode) {
this.outputLabel = outputLabel;
this.address = address;
this.amount = amount;
this.paymentCode = paymentCode;
}
}
private class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Address address, long value) {
this(address, value, null);
}
public LabelContextMenu(Address address, long value, PaymentCode paymentCode) {
if(address != null) {
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(event -> {
@@ -1119,6 +1132,17 @@ public class TransactionDiagram extends GridPane {
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copySatsValue, copyBtcValue);
if(paymentCode != null) {
MenuItem copyPaymentCode = new MenuItem("Copy Payment Code");
copyPaymentCode.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(paymentCode.toString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyPaymentCode);
}
}
}
}
@@ -28,7 +28,8 @@ public class UsbStatusButton extends MenuButton {
for(Device device : devices) {
MenuItem deviceItem = new MenuItem(device.getModel().toDisplayString());
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T || device.getModel() == WalletModel.TREZOR_SAFE_3 ||
device.getModel() == WalletModel.TREZOR_SAFE_5 || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02)) {
device.getModel() == WalletModel.TREZOR_SAFE_5 || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02 ||
device.getModel() == WalletModel.ONEKEY_CLASSIC_1S || device.getModel() == WalletModel.ONEKEY_PRO)) {
deviceItem = new Menu(device.getModel().toDisplayString());
MenuItem toggleItem = new MenuItem("Toggle Passphrase" + (!device.getModel().externalPassphraseEntry() ? "" : (device.isNeedsPassphraseSent() ? " Off" : " On")));
toggleItem.setOnAction(event -> {
@@ -18,8 +18,8 @@ import java.util.List;
public class WalletExportDialog extends Dialog<Wallet> {
private Wallet wallet;
public WalletExportDialog(WalletForm walletForm) {
this.wallet = walletForm.getWallet();
public WalletExportDialog(WalletForm selectedWalletForm, List<WalletForm> allWalletForms) {
this.wallet = selectedWalletForm.getWallet();
EventManager.get().register(this);
setOnCloseRequest(event -> {
@@ -45,10 +45,10 @@ public class WalletExportDialog extends Dialog<Wallet> {
List<WalletExport> exporters;
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm));
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm));
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(),
new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm));
new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm));
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -1,9 +1,12 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.wallet.KeystoreController;
import java.io.*;
import java.nio.charset.StandardCharsets;
@@ -92,7 +95,7 @@ public class Descriptor implements WalletImport, WalletExport {
InputStream secondClone = new ByteArrayInputStream(baos.toByteArray());
try {
return PdfUtils.getOutputDescriptor(firstClone).toWallet();
return ensureKeyDerivations(PdfUtils.getOutputDescriptor(firstClone).toWallet());
} catch(Exception e) {
//ignore
}
@@ -100,7 +103,7 @@ public class Descriptor implements WalletImport, WalletExport {
List<String> paragraphs = getParagraphs(secondClone);
for(String paragraph : paragraphs) {
OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor(paragraph);
return descriptor.toWallet();
return ensureKeyDerivations(descriptor.toWallet());
}
throw new ImportException("Could not find an output descriptor in the file");
@@ -116,7 +119,7 @@ public class Descriptor implements WalletImport, WalletExport {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
for(String line : reader.lines().map(String::trim).toArray(String[]::new)) {
if(line.isEmpty()) {
if(paragraph.length() > 0) {
if(!paragraph.isEmpty()) {
paragraphs.add(paragraph.toString());
paragraph.setLength(0);
}
@@ -127,13 +130,23 @@ public class Descriptor implements WalletImport, WalletExport {
}
}
if(paragraph.length() > 0) {
if(!paragraph.isEmpty()) {
paragraphs.add(paragraph.toString());
}
return paragraphs;
}
private static Wallet ensureKeyDerivations(Wallet wallet) {
for(Keystore keystore : wallet.getKeystores()) {
if(keystore.getKeyDerivation().getMasterFingerprint() == null || keystore.getKeyDerivation().getDerivationPath() == null) {
keystore.setKeyDerivation(new KeyDerivation(KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT, wallet.getScriptType().getDefaultDerivationPath()));
}
}
return wallet;
}
@Override
public boolean isWalletImportScannable() {
return true;
@@ -56,7 +56,7 @@ public class Hwi {
return lark.enumerate().stream().map(Device::fromHardwareClient).toList();
} catch(Throwable e) {
log.error("Error enumerating USB devices", e);
throw new ImportException("Error scanning" + (e.getMessage() == null || e.getMessage().isEmpty() ? ", check devices are ready" : ": " + e.getMessage()), e);
throw new ImportException(e.getMessage() == null || e.getMessage().isEmpty() ? "Error scanning, check devices are ready" : e.getMessage(), e);
} finally {
isPromptActive = false;
}
@@ -1,33 +1,41 @@
package com.sparrowwallet.sparrow.io;
import com.csvreader.CsvReader;
import com.google.gson.Gson;
import com.google.gson.*;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.wallet.*;
import org.apache.commons.lang3.time.DateUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class WalletLabels implements WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(WalletLabels.class);
private static final long ONE_DAY = 24*60*60*1000L;
private final List<WalletForm> walletForms;
public WalletLabels() {
this.walletForms = Collections.emptyList();
}
public WalletLabels(List<WalletForm> walletForms) {
this.walletForms = walletForms;
}
@@ -50,8 +58,9 @@ public class WalletLabels implements WalletImport, WalletExport {
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
List<Label> labels = new ArrayList<>();
List<Wallet> allWallets = wallet.isMasterWallet() ? wallet.getAllWallets() : wallet.getMasterWallet().getAllWallets();
for(Wallet exportWallet : allWallets) {
Map<Date, Double> fiatRates = getFiatRates(walletForms);
for(WalletForm exportWalletForm : walletForms) {
Wallet exportWallet = exportWalletForm.getWallet();
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet);
String origin = outputDescriptor.toString(true, false, false);
@@ -61,34 +70,43 @@ public class WalletLabels implements WalletImport, WalletExport {
}
}
for(BlockTransaction blkTx : exportWallet.getWalletTransactions().values()) {
if(blkTx.getLabel() != null && !blkTx.getLabel().isEmpty()) {
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin, null));
Set<Sha256Hash> confirmingTxs = new HashSet<>();
WalletTransactionsEntry walletTransactionsEntry = exportWalletForm.getWalletTransactionsEntry();
for(Entry entry : walletTransactionsEntry.getChildren()) {
TransactionEntry txEntry = (TransactionEntry)entry;
BlockTransaction blkTx = txEntry.getBlockTransaction();
labels.add(new TransactionLabel(blkTx.getHashAsString(), blkTx.getLabel(), origin,
txEntry.isConfirming() ? null : blkTx.getHeight(), blkTx.getDate(),
blkTx.getFee() == null || blkTx.getFee() == 0 ? null : blkTx.getFee(), txEntry.getValue(),
getFiatValue(blkTx.getDate(), Transaction.SATOSHIS_PER_BITCOIN, fiatRates)));
if(txEntry.isConfirming()) {
confirmingTxs.add(blkTx.getHash());
}
}
for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) {
if(addressNode.getLabel() != null && !addressNode.getLabel().isEmpty()) {
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null, null));
}
labels.add(new AddressLabel(addressNode.getAddress().toString(), addressNode.getLabel(), origin, addressNode.getDerivationPath().substring(1),
addressNode.getTransactionOutputs().stream().flatMap(txo -> txo.isSpent() ? Stream.of(txo, txo.getSpentBy()) : Stream.of(txo))
.filter(ref -> !confirmingTxs.contains(ref.getHash())).map(BlockTransactionHash::getHeight).toList()));
}
for(BlockTransactionHashIndex txo : exportWallet.getWalletTxos().keySet()) {
String spendable = (txo.isSpent() ? null : txo.getStatus() == Status.FROZEN ? "false" : "true");
if(txo.getLabel() != null && !txo.getLabel().isEmpty()) {
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null, spendable));
} else if(!txo.isSpent()) {
labels.add(new Label(Type.output, txo.toString(), null, null, spendable));
}
for(Map.Entry<BlockTransactionHashIndex, WalletNode> txoEntry : exportWallet.getWalletTxos().entrySet()) {
BlockTransactionHashIndex txo = txoEntry.getKey();
WalletNode addressNode = txoEntry.getValue();
Boolean spendable = (txo.isSpent() ? null : txo.getStatus() != Status.FROZEN);
labels.add(new InputOutputLabel(Type.output, txo.toString(), txo.getLabel(), origin, spendable, addressNode.getDerivationPath().substring(1), txo.getValue(),
confirmingTxs.contains(txo.getHash()) ? null : txo.getHeight(), txo.getDate(), getFiatValue(txo, fiatRates)));
if(txo.isSpent() && txo.getSpentBy().getLabel() != null && !txo.getSpentBy().getLabel().isEmpty()) {
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null, null));
if(txo.isSpent()) {
BlockTransactionHashIndex txi = txo.getSpentBy();
labels.add(new InputOutputLabel(Type.input, txi.toString(), txi.getLabel(), origin, null, addressNode.getDerivationPath().substring(1), txi.getValue(),
confirmingTxs.contains(txi.getHash()) ? null : txi.getHeight(), txi.getDate(), getFiatValue(txi, fiatRates)));
}
}
}
try {
Gson gson = new Gson();
Gson gson = new GsonBuilder().registerTypeAdapter(Date.class, new GsonUTCDateAdapter()).create();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
for(Label label : labels) {
@@ -114,7 +132,7 @@ public class WalletLabels implements WalletImport, WalletExport {
@Override
public boolean isWalletExportScannable() {
return false;
return true;
}
@Override
@@ -247,11 +265,11 @@ public class WalletLabels implements WalletImport, WalletExport {
addChangedEntry(changedWalletEntries, txioEntry);
}
if(label.type == Type.output && !reference.isSpent()) {
if("false".equalsIgnoreCase(label.spendable) && reference.getStatus() != Status.FROZEN) {
if(label.type == Type.output && !reference.isSpent() && label.spendable != null) {
if(!label.spendable && reference.getStatus() != Status.FROZEN) {
reference.setStatus(Status.FROZEN);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
} else if("true".equalsIgnoreCase(label.spendable) && reference.getStatus() == Status.FROZEN) {
} else if(label.spendable && reference.getStatus() == Status.FROZEN) {
reference.setStatus(null);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
}
@@ -316,7 +334,7 @@ public class WalletLabels implements WalletImport, WalletExport {
@Override
public boolean isWalletImportScannable() {
return false;
return true;
}
@Override
@@ -324,12 +342,77 @@ public class WalletLabels implements WalletImport, WalletExport {
return true;
}
private Map<Date, Double> getFiatRates(List<WalletForm> walletForms) {
ExchangeSource exchangeSource = getExchangeSource();
Currency fiatCurrency = getFiatCurrency();
Map<Date, Double> fiatRates = new HashMap<>();
if(fiatCurrency != null) {
long min = Long.MAX_VALUE;
long max = Long.MIN_VALUE;
for(WalletForm walletForm : walletForms) {
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
if(!walletTransactionsEntry.getChildren().isEmpty()) {
LongSummaryStatistics stats = walletTransactionsEntry.getChildren().stream()
.map(entry -> ((TransactionEntry)entry).getBlockTransaction().getDate())
.filter(Objects::nonNull)
.collect(Collectors.summarizingLong(Date::getTime));
min = Math.min(min, stats.getMin());
max = Math.max(max, stats.getMax());
}
}
if(max > min) {
fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(min - ONE_DAY), new Date(max));
}
}
return fiatRates;
}
private static ExchangeSource getExchangeSource() {
return Config.get().getExchangeSource() == null ? ExchangeSource.COINGECKO : Config.get().getExchangeSource();
}
private static Currency getFiatCurrency() {
return getExchangeSource() == ExchangeSource.NONE || !AppServices.onlineProperty().get() ? null : Config.get().getFiatCurrency();
}
private Map<Currency, BigDecimal> getFiatValue(TransactionEntry txEntry, Map<Date, Double> fiatRates) {
return getFiatValue(txEntry.getBlockTransaction().getDate(), txEntry.getValue(), fiatRates);
}
private Map<Currency, BigDecimal> getFiatValue(BlockTransactionHashIndex ref, Map<Date, Double> fiatRates) {
return getFiatValue(ref.getDate(), ref.getValue(), fiatRates);
}
private Map<Currency, BigDecimal> getFiatValue(Date date, long value, Map<Date, Double> fiatRates) {
Currency fiatCurrency = getFiatCurrency();
if(fiatCurrency != null) {
Double dayRate = null;
if(date == null) {
if(AppServices.getFiatCurrencyExchangeRate() != null) {
dayRate = AppServices.getFiatCurrencyExchangeRate().getBtcRate();
}
} else {
dayRate = fiatRates.get(DateUtils.truncate(date, Calendar.DAY_OF_MONTH));
}
if(dayRate != null) {
BigDecimal fiatValue = BigDecimal.valueOf(dayRate * value / Transaction.SATOSHIS_PER_BITCOIN);
return Map.of(fiatCurrency, fiatValue.setScale(fiatCurrency.getDefaultFractionDigits(), RoundingMode.HALF_UP));
}
}
return null;
}
private enum Type {
tx, addr, pubkey, input, output, xpub
}
private static class Label {
public Label(Type type, String ref, String label, String origin, String spendable) {
public Label(Type type, String ref, String label, String origin, Boolean spendable) {
this.type = type;
this.ref = ref;
this.label = label;
@@ -341,6 +424,74 @@ public class WalletLabels implements WalletImport, WalletExport {
String ref;
String label;
String origin;
String spendable;
Boolean spendable;
}
private static class TransactionLabel extends Label {
public TransactionLabel(String ref, String label, String origin, Integer height, Date time, Long fee, Long value, Map<Currency, BigDecimal> rate) {
super(Type.tx, ref, label, origin, null);
this.height = height;
this.time = time;
this.fee = fee;
this.value = value;
this.rate = rate;
}
Integer height;
Date time;
Long fee;
Long value;
Map<Currency, BigDecimal> rate;
}
private static class AddressLabel extends Label {
public AddressLabel(String ref, String label, String origin, String keypath, List<Integer> heights) {
super(Type.addr, ref, label, origin, null);
this.keypath = keypath;
this.heights = heights;
}
String keypath;
List<Integer> heights;
}
private static class InputOutputLabel extends Label {
public InputOutputLabel(Type type, String ref, String label, String origin, Boolean spendable, String keypath, Long value, Integer height, Date time, Map<Currency, BigDecimal> fmv) {
super(type, ref, label, origin, spendable);
this.keypath = keypath;
this.value = value;
this.height = height;
this.time = time;
this.fmv = fmv;
}
String keypath;
Long value;
Integer height;
Date time;
Map<Currency, BigDecimal> fmv;
}
public static class GsonUTCDateAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {
private final DateFormat dateFormat;
public GsonUTCDateAdapter() {
dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
}
@Override
public JsonElement serialize(Date src, java.lang.reflect.Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(dateFormat.format(src));
}
@Override
public Date deserialize(JsonElement json, java.lang.reflect.Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
try {
return dateFormat.parse(json.getAsString());
} catch (ParseException e) {
throw new JsonParseException(e);
}
}
}
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.keystoreimport;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -10,6 +11,7 @@ import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.wallet.KeystoreController;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.*;
import org.controlsfx.tools.Borders;
@@ -20,16 +22,18 @@ import java.util.List;
public class KeystoreImportDialog extends Dialog<Keystore> {
private final KeystoreImportController keystoreImportController;
private Keystore keystore;
private final ScriptType scriptType;
private final String existingLabel;
public KeystoreImportDialog(Wallet wallet) {
this(wallet, KeystoreSource.HW_USB);
}
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource) {
this(wallet, initialSource, null, null, false);
this(wallet, initialSource, null, null, Keystore.DEFAULT_LABEL, false);
}
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource, KeyDerivation currentDerivation, WalletModel currentModel, boolean restrictImport) {
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource, KeyDerivation currentDerivation, WalletModel currentModel, String currentLabel, boolean restrictImport) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
@@ -37,6 +41,8 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
scriptType = wallet.getScriptType();
existingLabel = currentLabel;
try {
FXMLLoader ksiLoader = new FXMLLoader(AppServices.class.getResource("keystoreimport/keystoreimport.fxml"));
@@ -73,9 +79,10 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
private Keystore getWatchOnlyKeystore() {
this.keystore = new Keystore();
keystore.setLabel(existingLabel);
keystore.setSource(KeystoreSource.SW_WATCH);
keystore.setWalletModel(WalletModel.SPARROW);
keystore.setKeyDerivation(new KeyDerivation("",""));
keystore.setKeyDerivation(new KeyDerivation(KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT, scriptType.getDefaultDerivationPath()));
return keystore;
}
@@ -129,7 +129,7 @@ public enum Protocol {
public abstract CloseableTransport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException;
public HostAndPort getServerHostAndPort(String url) {
String lessProtocol = url.substring(this.toUrlString().length());
String lessProtocol = url.endsWith(":t") || url.endsWith(":s") ? url.substring(0, url.length() - 2) : url.substring(this.toUrlString().length());
int pathStart = lessProtocol.indexOf('/');
return HostAndPort.fromString(pathStart < 0 ? lessProtocol : lessProtocol.substring(0, pathStart));
}
@@ -178,10 +178,10 @@ public enum Protocol {
}
public static Protocol getProtocol(String url) {
if(url.startsWith("tcp://")) {
if(url.startsWith("tcp://") || url.endsWith(":t")) {
return TCP;
}
if(url.startsWith("ssl://")) {
if(url.startsWith("ssl://") || url.endsWith(":s")) {
return SSL;
}
if(url.startsWith("http://")) {
@@ -9,6 +9,7 @@ import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.registry.CryptoPSBT;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@@ -227,6 +228,9 @@ public class HeadersController extends TransactionFormController implements Init
@FXML
private Button broadcastButton;
@FXML
private Button showTransactionButton;
@FXML
private Button saveFinalButton;
@@ -461,6 +465,8 @@ public class HeadersController extends TransactionFormController implements Init
broadcastProgressBar.visibleProperty().bind(signaturesProgressBar.visibleProperty().not());
broadcastButton.managedProperty().bind(broadcastButton.visibleProperty());
showTransactionButton.managedProperty().bind(showTransactionButton.visibleProperty());
showTransactionButton.visibleProperty().bind(broadcastButton.visibleProperty().not());
saveFinalButton.managedProperty().bind(saveFinalButton.visibleProperty());
saveFinalButton.visibleProperty().bind(broadcastButton.visibleProperty().not());
broadcastButton.visibleProperty().bind(AppServices.onlineProperty());
@@ -1255,6 +1261,21 @@ public class HeadersController extends TransactionFormController implements Init
broadcastTransactionService.start();
}
public void showTransaction(ActionEvent event) {
try {
Transaction transaction = headersForm.getPsbt().extractTransaction();
byte[] txBytes = transaction.bitcoinSerialize();
UR ur = UR.fromBytes(txBytes);
BBQR bbqr = new BBQR(BBQRType.TXN, txBytes);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false);
qrDisplayDialog.initOwner(showTransactionButton.getScene().getWindow());
qrDisplayDialog.showAndWait();
} catch (Exception exception) {
log.error("Error creating UR", exception);
AppServices.showErrorDialog("Error displaying transaction QR code", exception.getMessage());
}
}
public void saveFinalTransaction(ActionEvent event) {
Stage window = new Stage();
@@ -36,10 +36,7 @@ import tornadofx.control.Field;
import javax.smartcardio.CardException;
import java.net.URL;
import java.util.Base64;
import java.util.Locale;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable;
@@ -371,7 +368,8 @@ public class KeystoreController extends WalletFormController implements Initiali
boolean restrictImport = keystore.getSource() != KeystoreSource.SW_WATCH && keystoreSourceToggleGroup.getToggles().stream().anyMatch(toggle -> ((ToggleButton)toggle).isDisabled());
KeyDerivation currentDerivation = keystore.getKeyDerivation();
WalletModel currentModel = keystore.getWalletModel();
KeystoreImportDialog dlg = new KeystoreImportDialog(getWalletForm().getWallet(), initialSource, currentDerivation, currentModel, restrictImport);
String currentLabel = keystore.getLabel();
KeystoreImportDialog dlg = new KeystoreImportDialog(getWalletForm().getWallet(), initialSource, currentDerivation, currentModel, currentLabel, restrictImport);
dlg.initOwner(selectSourcePane.getScene().getWindow());
Optional<Keystore> result = dlg.showAndWait();
if(result.isPresent()) {
@@ -386,7 +384,12 @@ public class KeystoreController extends WalletFormController implements Initiali
return;
}
}
walletForm.getWallet().makeLabelsUnique(importedKeystore);
if(!keystore.getLabel().equals(importedKeystore.getLabel())) {
List<Keystore> changedKeystores = walletForm.getWallet().makeLabelsUnique(importedKeystore);
if(!changedKeystores.isEmpty()) {
EventManager.get().post(new SettingsChangedEvent(walletForm.getWallet(), SettingsChangedEvent.Type.KEYSTORE_LABEL));
}
}
keystore.setSource(importedKeystore.getSource());
keystore.setWalletModel(importedKeystore.getWalletModel());
keystore.setLabel(importedKeystore.getLabel());
@@ -396,7 +399,7 @@ public class KeystoreController extends WalletFormController implements Initiali
keystore.setSeed(importedKeystore.getSeed());
keystore.setBip47ExtendedPrivateKey(importedKeystore.getBip47ExtendedPrivateKey());
updateType(true);
updateType(keystore.isValid());
label.setText(keystore.getLabel());
fingerprint.setText(keystore.getKeyDerivation().getMasterFingerprint());
derivation.setText(keystore.getKeyDerivation().getDerivationPath());
@@ -653,18 +656,31 @@ public class KeystoreController extends WalletFormController implements Initiali
@Subscribe
public void update(SettingsChangedEvent event) {
if(walletForm.getWallet().equals(event.getWallet()) && event.getType().equals(SettingsChangedEvent.Type.SCRIPT_TYPE)) {
if(keystore.getSource() == KeystoreSource.SW_WATCH && derivation.getPromptText().equals(derivation.getText())) {
derivation.setText(event.getWallet().getScriptType().getDefaultDerivationPath());
if(walletForm.getWallet().equals(event.getWallet())) {
if(event.getType().equals(SettingsChangedEvent.Type.SCRIPT_TYPE)) {
if(keystore.getSource() == KeystoreSource.SW_WATCH && derivation.getPromptText().equals(derivation.getText())) {
derivation.setText(event.getWallet().getScriptType().getDefaultDerivationPath());
}
derivation.setPromptText(event.getWallet().getScriptType().getDefaultDerivationPath());
if(derivation.getText() != null && !derivation.getText().isEmpty()) {
String derivationPath = derivation.getText();
derivation.setText(derivationPath + " ");
derivation.setText(derivationPath);
}
if(keystore.getExtendedPublicKey() != null) {
setXpubContext(keystore.getExtendedPublicKey());
}
} else if(event.getType().equals(SettingsChangedEvent.Type.KEYSTORE_LABEL)) {
if(!keystore.getLabel().equals(label.getText())) {
label.setText(keystore.getLabel());
}
}
derivation.setPromptText(event.getWallet().getScriptType().getDefaultDerivationPath());
if(derivation.getText() != null && !derivation.getText().isEmpty()) {
String derivationPath = derivation.getText();
derivation.setText(derivationPath + " ");
derivation.setText(derivationPath);
}
if(keystore.getExtendedPublicKey() != null) {
setXpubContext(keystore.getExtendedPublicKey());
if(event.getType().equals(SettingsChangedEvent.Type.KEYSTORE_LABEL) || event.getType().equals(SettingsChangedEvent.Type.KEYSTORE_FINGERPRINT) ||
event.getType().equals(SettingsChangedEvent.Type.KEYSTORE_DERIVATION) || event.getType().equals(SettingsChangedEvent.Type.KEYSTORE_XPUB)) {
if(keystore.getSource() == KeystoreSource.SW_WATCH) {
exportButton.setVisible(keystore.isValid() && getWalletForm().getWallet().getPolicyType() == PolicyType.MULTI);
}
}
}
}
@@ -591,7 +591,7 @@ public class SettingsController extends WalletFormController implements Initiali
}
if(walletForm instanceof SettingsWalletForm settingsWalletForm) {
WalletExportDialog dlg = new WalletExportDialog(settingsWalletForm.getAppWalletForm());
WalletExportDialog dlg = new WalletExportDialog(settingsWalletForm.getAppWalletForm(), List.of(settingsWalletForm.getAppWalletForm()));
dlg.initOwner(export.getScene().getWindow());
dlg.showAndWait();
} else {
@@ -285,6 +285,11 @@
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="SATELLITE_DISH" />
</graphic>
</Button>
<Button fx:id="showTransactionButton" HBox.hgrow="ALWAYS" text="Show Transaction as QR" contentDisplay="TOP" wrapText="true" textAlignment="CENTER" onAction="#showTransaction">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="QRCODE" />
</graphic>
</Button>
<Button fx:id="saveFinalButton" defaultButton="true" HBox.hgrow="ALWAYS" text="Save Final Transaction" contentDisplay="TOP" wrapText="true" textAlignment="CENTER" onAction="#saveFinalTransaction">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="ARROW_DOWN" />
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="15px" height="15px" viewBox="0 0 15 15" version="1.1">
<g id="surface1">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(92.941177%,92.941177%,92.941177%);fill-opacity:1;" d="M 7.5 12.898438 C 11.226562 12.898438 12.898438 11.226562 12.898438 7.5 C 12.898438 3.773438 11.226562 2.101562 7.5 2.101562 C 3.773438 2.101562 2.101562 3.773438 2.101562 7.5 C 2.101562 11.226562 3.773438 12.898438 7.5 12.898438 Z M 7.5 12.898438 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(12.941177%,12.941177%,12.941177%);fill-opacity:1;" d="M 6.484375 4.390625 L 7.988281 4.390625 L 7.988281 6.867188 L 7.054688 6.867188 L 7.054688 5.1875 L 6.222656 5.1875 Z M 6.484375 4.390625 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(12.941177%,12.941177%,12.941177%);fill-opacity:1;" d="M 7.5 10.609375 C 8.445312 10.609375 9.214844 9.84375 9.214844 8.898438 C 9.214844 7.949219 8.445312 7.183594 7.5 7.183594 C 6.554688 7.183594 5.785156 7.949219 5.785156 8.898438 C 5.785156 9.84375 6.554688 10.609375 7.5 10.609375 Z M 7.5 10.609375 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(92.941177%,92.941177%,92.941177%);fill-opacity:1;" d="M 7.5 9.832031 C 8.015625 9.832031 8.433594 9.414062 8.433594 8.898438 C 8.433594 8.378906 8.015625 7.960938 7.5 7.960938 C 6.984375 7.960938 6.566406 8.378906 6.566406 8.898438 C 6.566406 9.414062 6.984375 9.832031 7.5 9.832031 Z M 7.5 9.832031 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="15px" height="15px" viewBox="0 0 15 15" version="1.1">
<g id="surface1">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(20%,20%,20%);fill-opacity:1;" d="M 7.5 12.898438 C 11.226562 12.898438 12.898438 11.226562 12.898438 7.5 C 12.898438 3.773438 11.226562 2.101562 7.5 2.101562 C 3.773438 2.101562 2.101562 3.773438 2.101562 7.5 C 2.101562 11.226562 3.773438 12.898438 7.5 12.898438 Z M 7.5 12.898438 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(92.941177%,92.941177%,92.941177%);fill-opacity:1;" d="M 6.484375 4.390625 L 7.988281 4.390625 L 7.988281 6.867188 L 7.054688 6.867188 L 7.054688 5.1875 L 6.222656 5.1875 Z M 6.484375 4.390625 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(92.941177%,92.941177%,92.941177%);fill-opacity:1;" d="M 7.5 10.609375 C 8.445312 10.609375 9.214844 9.84375 9.214844 8.898438 C 9.214844 7.949219 8.445312 7.183594 7.5 7.183594 C 6.554688 7.183594 5.785156 7.949219 5.785156 8.898438 C 5.785156 9.84375 6.554688 10.609375 7.5 10.609375 Z M 7.5 10.609375 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(20%,20%,20%);fill-opacity:1;" d="M 7.5 9.832031 C 8.015625 9.832031 8.433594 9.414062 8.433594 8.898438 C 8.433594 8.378906 8.015625 7.960938 7.5 7.960938 C 6.984375 7.960938 6.566406 8.378906 6.566406 8.898438 C 6.566406 9.414062 6.984375 9.832031 7.5 9.832031 Z M 7.5 9.832031 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB