mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-07-31 12:06:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
095518e858 | ||
|
|
ac470f64c1 | ||
|
|
30215b0dac | ||
|
|
563252c823 | ||
|
|
3a7ffe95e8 | ||
|
|
b588a4ee02 | ||
|
|
ab0611fabc | ||
|
|
a121bb5f26 | ||
|
|
2fc01e0345 | ||
|
|
650c3657a5 | ||
|
|
d836eb515e | ||
|
|
717bc9fec2 | ||
|
|
1ad6da86b1 | ||
|
|
54680a5692 | ||
|
|
6ef5e79b39 | ||
|
|
1dbf51b406 | ||
|
|
8a3aa0d06e | ||
|
|
ca99efa215 | ||
|
|
cc202ce4ca | ||
|
|
ad44fe2335 | ||
|
|
452242f208 | ||
|
|
ff83af2e0d | ||
|
|
aacecc8517 | ||
|
|
a9a3eef157 | ||
|
|
7fc37c21fd | ||
|
|
ff828b483b | ||
|
|
64a9cce837 | ||
|
|
caa7ef429c | ||
|
|
38818d8222 | ||
|
|
fd7d2232a1 | ||
|
|
b5eb59344c | ||
|
|
3093e4906c | ||
|
|
2faddc1133 | ||
|
|
a2ead56593 | ||
|
|
03adc1f9f0 | ||
|
|
7bc707bcaa | ||
|
|
7ae74e3000 | ||
|
|
94da3b37b6 | ||
|
|
c6689228d0 | ||
|
|
d847da9d61 | ||
|
|
6637ea09bf | ||
|
|
99959470e2 | ||
|
|
826162ba9f | ||
|
|
546ccd66b3 | ||
|
|
628b15a3b5 | ||
|
|
72281233e1 | ||
|
|
c68722e420 | ||
|
|
901a4520fd | ||
|
|
3e083b907d | ||
|
|
3c4b25ecee | ||
|
|
c23bbb9e02 | ||
|
|
94897e9744 | ||
|
|
d722c4972a | ||
|
|
e93df8273b | ||
|
|
a1c65cff75 | ||
|
|
18bc7bf302 | ||
|
|
17bb442c78 | ||
|
|
3134a9ad42 | ||
|
|
eda96af607 | ||
|
|
038069f6e6 | ||
|
|
ee6d8028f8 | ||
|
|
5d404f8c30 | ||
|
|
94662922a8 | ||
|
|
582065e7f0 | ||
|
|
ccead92388 | ||
|
|
2b47740539 | ||
|
|
668dd88436 | ||
|
|
76820377ae | ||
|
|
6a1c3fa3da | ||
|
|
dfa781e16c | ||
|
|
722fd84ec1 | ||
|
|
39d1e686f4 | ||
|
|
7461b47466 | ||
|
|
572e451279 | ||
|
|
88dfde3c67 | ||
|
|
64c3db3656 | ||
|
|
f0ee99b78b | ||
|
|
48336bb894 | ||
|
|
c804737563 | ||
|
|
d395c7897e | ||
|
|
6e2e5d7a9c | ||
|
|
99a39aa83f | ||
|
|
2ca8b91283 | ||
|
|
94960567b5 | ||
|
|
e14349a645 | ||
|
|
ee7b741a69 | ||
|
|
02c8415a7b | ||
|
|
f305e0bdc2 | ||
|
|
d8b7938a63 | ||
|
|
44ac7f3c0e | ||
|
|
2b55b5feb3 | ||
|
|
43bf6ab265 | ||
|
|
80b7ee803b | ||
|
|
8e23bd64c7 | ||
|
|
bf6fbebd9e | ||
|
|
0b9052dee9 | ||
|
|
6460cb88a5 | ||
|
|
90ac786707 | ||
|
|
c86006335b | ||
|
|
a444e2af2f | ||
|
|
bcd2c966a5 | ||
|
|
f826c2d423 | ||
|
|
37bb1c5d97 | ||
|
|
68da8e6027 | ||
|
|
87fe0accd5 | ||
|
|
8d413e839c | ||
|
|
1392199f5c | ||
|
|
dc33b64a9c | ||
|
|
1b220c72ea | ||
|
|
bcf6f77340 | ||
|
|
0aac8bbea7 | ||
|
|
e1acaa8a78 | ||
|
|
13a486597c | ||
|
|
389ce2180d | ||
|
|
a7a3ad012c | ||
|
|
9f5eb41d90 | ||
|
|
3a4d385560 | ||
|
|
d3881f76b9 | ||
|
|
be5a48ce71 | ||
|
|
c8cd2d1cd9 | ||
|
|
5789e1c215 | ||
|
|
f48fb610e8 | ||
|
|
3aeba79268 | ||
|
|
4a030e9b6e | ||
|
|
2cf653702c | ||
|
|
ac438ec023 | ||
|
|
ee9247c066 | ||
|
|
4ea5ec6c0a | ||
|
|
63a9a798f3 | ||
|
|
194f0f5c1b | ||
|
|
bda5c1a709 | ||
|
|
32327928b5 | ||
|
|
b448927a6e | ||
|
|
ff578c938e | ||
|
|
998100505d | ||
|
|
a893b37bb0 | ||
|
|
4c58ebf138 | ||
|
|
76e148a107 | ||
|
|
20f5e4d8db | ||
|
|
5cd15f21ad | ||
|
|
b50cf77f3b | ||
|
|
5e8148e5b9 | ||
|
|
fa5ac7917b | ||
|
|
eda712e269 | ||
|
|
3c6adafc15 | ||
|
|
639e145672 | ||
|
|
eb8d66bf25 | ||
|
|
3a885b3a28 | ||
|
|
7e93b6b198 | ||
|
|
f949bcc1cf | ||
|
|
9e5937d2fe | ||
|
|
82a8f08440 | ||
|
|
c806959199 | ||
|
|
f7fb62fcea | ||
|
|
ea2783e51b | ||
|
|
ff7a35e68b | ||
|
|
c68cda5b2f | ||
|
|
2cb667a671 | ||
|
|
eb09076604 | ||
|
|
32bc9f6a99 | ||
|
|
dc8a8dd857 | ||
|
|
d5a26fdf5d | ||
|
|
e48e40da0d | ||
|
|
e878c4ea78 | ||
|
|
ead8fa2207 | ||
|
|
9a395f5dd1 | ||
|
|
a99114cc6f | ||
|
|
ea36c63ebb | ||
|
|
77d3c848f9 | ||
|
|
f22312e04f | ||
|
|
b9db4421df | ||
|
|
b7aa73e99f | ||
|
|
4bad46c9e3 | ||
|
|
084a3c76af | ||
|
|
2e0ca1b4fa | ||
|
|
4bc446724d | ||
|
|
0c56f5a9a1 | ||
|
|
38b27bb091 | ||
|
|
b8c3bf1bea | ||
|
|
e65f1ef3cc | ||
|
|
f25d2618b6 | ||
|
|
0adf94bf62 | ||
|
|
7d7c5e5064 | ||
|
|
10e4d8843d | ||
|
|
6e10784e49 | ||
|
|
885efd7985 | ||
|
|
ec9534f8ae | ||
|
|
6c90a7e0ba | ||
|
|
9dd52f120f | ||
|
|
4cdffec060 | ||
|
|
ed9bc882b7 | ||
|
|
5054ab9564 | ||
|
|
7751f940f2 | ||
|
|
3f68ca43c2 | ||
|
|
68239e9472 | ||
|
|
10edf31e13 | ||
|
|
61c3c4a3ac | ||
|
|
5d6bb5e4f9 | ||
|
|
b808222faa | ||
|
|
4d7d51f3f8 | ||
|
|
546076ea48 | ||
|
|
2bebb0925e | ||
|
|
47855228d3 | ||
|
|
a6e41e3f9d | ||
|
|
04722204a6 | ||
|
|
452afe4731 | ||
|
|
af36d7b5a5 | ||
|
|
7e225e794f | ||
|
|
ffce18919f | ||
|
|
ddcabf721e | ||
|
|
7eb4b9c61d | ||
|
|
6dca03f8b4 | ||
|
|
135c1d1719 | ||
|
|
c9a4fb4ec4 | ||
|
|
06957b12e6 | ||
|
|
d1fbe94d90 | ||
|
|
1fea119173 | ||
|
|
1ac264600a | ||
|
|
00db59c44f | ||
|
|
2c27f702bf | ||
|
|
3c2dfed96d | ||
|
|
eb23cc8e61 | ||
|
|
fa0c74f06b | ||
|
|
ba017b2fcb | ||
|
|
0a094332d2 | ||
|
|
2cca6049be | ||
|
|
5be1f066df | ||
|
|
d519c033ac | ||
|
|
f8d15f73f6 | ||
|
|
350bddac84 | ||
|
|
d1326cb621 | ||
|
|
ae0e5894ca | ||
|
|
1a931c72bd | ||
|
|
92db6d9396 | ||
|
|
5fd59cc68a | ||
|
|
ea9a310b22 | ||
|
|
ee22d4a81d | ||
|
|
c2fb6bcfee | ||
|
|
8cde670efd | ||
|
|
f95cb67912 | ||
|
|
750f6483cb | ||
|
|
53a590140d | ||
|
|
189ac75467 | ||
|
|
fa082e892f | ||
|
|
c814b8e350 | ||
|
|
2b48b4cd6e | ||
|
|
d49f85fe5b | ||
|
|
af525797ff | ||
|
|
e9e10de266 | ||
|
|
ab5fdef919 | ||
|
|
f723d20c9a | ||
|
|
03c488cf4d | ||
|
|
ca64a1d307 | ||
|
|
4c8f7bb711 | ||
|
|
77dd8ca5d7 | ||
|
|
d5aba35184 | ||
|
|
30e2ab5e2a | ||
|
|
e93fcf7fbd | ||
|
|
198672a06e | ||
|
|
133d771c09 | ||
|
|
9a80065605 | ||
|
|
ccf4e98968 | ||
|
|
df15129f64 | ||
|
|
e878648587 | ||
|
|
75bf63d936 | ||
|
|
304bdd5c48 | ||
|
|
e1934c99bc | ||
|
|
fa7d09c5a6 | ||
|
|
79c2e29e34 | ||
|
|
bb635a6c68 | ||
|
|
7d8774c94c | ||
|
|
f35b3d46a1 | ||
|
|
f3a7d583a5 | ||
|
|
37c3d573e0 | ||
|
|
b2f48a1b05 | ||
|
|
f0b7409c4a | ||
|
|
a8612da463 | ||
|
|
f2afd7cb69 | ||
|
|
42d40b8171 | ||
|
|
cda4e30e3f | ||
|
|
b43b25769e | ||
|
|
84d08fea18 | ||
|
|
8aed206a34 | ||
|
|
08498b1b71 | ||
|
|
10a684cf41 | ||
|
|
32d25286a6 | ||
|
|
2a6a296cf3 | ||
|
|
53478d9b22 | ||
|
|
ce60fe3dc6 | ||
|
|
03abc59ed3 | ||
|
|
e7c83e1d3f | ||
|
|
827d35cba3 | ||
|
|
3556e4abc9 | ||
|
|
73a1ab13a1 | ||
|
|
c9c00cc74e | ||
|
|
709c65ec20 | ||
|
|
4068a6c541 | ||
|
|
2f8d40198e | ||
|
|
d50ed46caf | ||
|
|
e5327b3d3d | ||
|
|
39e5031a78 | ||
|
|
928731b383 | ||
|
|
c9d7b03afc | ||
|
|
75f5fd2e12 | ||
|
|
29cfda7908 | ||
|
|
658ab2f81c | ||
|
|
62618e5da5 | ||
|
|
b8edfd9731 | ||
|
|
7428d21cac | ||
|
|
1c03e1935e | ||
|
|
ef5124a9b9 | ||
|
|
b0f4d3b4c9 | ||
|
|
7d3aabb050 | ||
|
|
1fd1b28994 | ||
|
|
33d6da8a99 | ||
|
|
279b0e0ac1 | ||
|
|
a188f70dbe | ||
|
|
fca55779bf | ||
|
|
6ef333ae2a | ||
|
|
c86f7d32cb | ||
|
|
8e02f7103e | ||
|
|
907062ffa0 | ||
|
|
e79f5ba977 | ||
|
|
d2291ef870 | ||
|
|
bd721be1a2 | ||
|
|
3cee45223e | ||
|
|
4d51bb40c2 | ||
|
|
e7b511fc74 | ||
|
|
603f3036fa | ||
|
|
0ffb6e377b | ||
|
|
a37028b53f | ||
|
|
9b43429de9 | ||
|
|
d6218f2a58 | ||
|
|
c477d31d3d | ||
|
|
539013919b | ||
|
|
d8c19ac0f8 | ||
|
|
fc5bff9788 | ||
|
|
e21dbf87ef | ||
|
|
765a4f4c82 | ||
|
|
6a5b346608 | ||
|
|
6ecebd9cf6 | ||
|
|
c8e38de5aa | ||
|
|
3305d0630a | ||
|
|
5860a6e49f | ||
|
|
66e558faee | ||
|
|
48e741733b | ||
|
|
013ed89e98 | ||
|
|
571c515a46 | ||
|
|
1d0b66c45a | ||
|
|
72d14c15a8 | ||
|
|
8aa9d15620 | ||
|
|
d8c94504c3 | ||
|
|
67d624f049 | ||
|
|
766afae9c1 | ||
|
|
07458d3dff | ||
|
|
cd7fc06819 | ||
|
|
7444a87d89 | ||
|
|
183d0ded2f | ||
|
|
ef2e2a1855 | ||
|
|
829be40243 | ||
|
|
0f89327f35 | ||
|
|
150f65e7bd | ||
|
|
d4d61b8d41 | ||
|
|
a8f16c15e0 | ||
|
|
21f642bb5c | ||
|
|
d33ccfb672 | ||
|
|
efba293564 | ||
|
|
b1e8e25837 | ||
|
|
44be6e7203 | ||
|
|
e93ec08ba7 | ||
|
|
800512ee5b | ||
|
|
0cd97649cc | ||
|
|
9cd8a9b9ee | ||
|
|
82ca9552bd | ||
|
|
7cc330fde9 | ||
|
|
7f03778ec7 | ||
|
|
6b12888111 | ||
|
|
ebc2f9442e | ||
|
|
51b2608abc | ||
|
|
09c4a01ca4 | ||
|
|
b1b4734094 | ||
|
|
86eb8b8294 | ||
|
|
a3a20a788d | ||
|
|
936d42e7fd | ||
|
|
70c4eeaf5e | ||
|
|
9aa8b10898 | ||
|
|
50ef1c1a07 | ||
|
|
3a65261326 | ||
|
|
c5bcefd331 | ||
|
|
f5a857317d | ||
|
|
1e250193fd | ||
|
|
6731823bef | ||
|
|
df1ed196be | ||
|
|
e97f652769 | ||
|
|
a6e340eef5 | ||
|
|
0836273c8f | ||
|
|
b1785f352b | ||
|
|
7e8496915a | ||
|
|
5223fe8d8c | ||
|
|
331b83f7a0 | ||
|
|
c115f6e729 | ||
|
|
3db7fc1e99 | ||
|
|
cabd62166a | ||
|
|
3a8581da13 | ||
|
|
6f2a0cd3c3 | ||
|
|
7fc0e9b530 | ||
|
|
fdd8327464 | ||
|
|
2acc922b06 | ||
|
|
b52a7137c3 | ||
|
|
969771d377 | ||
|
|
1ed59bb935 | ||
|
|
68aeb5946a | ||
|
|
ae01fe9ee6 | ||
|
|
6b09dc0293 | ||
|
|
1bf8c85a65 | ||
|
|
d0e5da0ec8 | ||
|
|
bb2ec1882d | ||
|
|
02258dea8d | ||
|
|
d516eaa9d6 | ||
|
|
bc5690346c | ||
|
|
84888b4a43 | ||
|
|
d7c6f5d587 | ||
|
|
9241f4381f | ||
|
|
82a113afc3 | ||
|
|
6872e069a9 | ||
|
|
9af17bcac8 | ||
|
|
ef5399008f | ||
|
|
a409c28b20 | ||
|
|
910dfcdeb1 | ||
|
|
2de90dfdc1 | ||
|
|
6b651ec63a | ||
|
|
2ee9f3d10a | ||
|
|
60c1c17d26 | ||
|
|
981b379615 | ||
|
|
14aa328f6d | ||
|
|
7a554df619 | ||
|
|
ae03da257a | ||
|
|
728b6efbdf | ||
|
|
40d31ff025 | ||
|
|
9adfcf5806 | ||
|
|
431a170ab0 | ||
|
|
6d202f1522 | ||
|
|
94eca5a83d | ||
|
|
98b1aa0b1d | ||
|
|
fb621963dd | ||
|
|
1f93d574eb | ||
|
|
72511fb184 | ||
|
|
e717589c9f | ||
|
|
8da6226545 | ||
|
|
17b03a6750 | ||
|
|
950ae07df5 |
@@ -0,0 +1,39 @@
|
||||
name: Package
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: true
|
||||
- name: Set up JDK 15.0.2
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
uses: joschi/setup-jdk@v2
|
||||
with:
|
||||
java-version: 15
|
||||
- name: Set up JDK 14.0.2
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 14.0.2
|
||||
- name: Show Build Versions
|
||||
run: ./gradlew -v
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
|
||||
restore-keys: ${{ runner.os }}-gradle
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew jpackage
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: Sparrow Build - ${{ runner.os }}
|
||||
path: build/jpackage/*
|
||||
+3
-1
@@ -4,4 +4,6 @@
|
||||
build
|
||||
/*.properties
|
||||
out
|
||||
*.log
|
||||
*.log
|
||||
build-*.sh
|
||||
.DS_Store
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[submodule "drongo"]
|
||||
path = drongo
|
||||
url = git@github.com:craigraw/drongo.git
|
||||
url = git@github.com:sparrowwallet/drongo.git
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -1,11 +1,82 @@
|
||||
# Sparrow
|
||||
Bitcoin Wallet & Transaction Editor
|
||||
# Sparrow Bitcoin Wallet
|
||||
|
||||
To clone this project, use `git clone --recursive git@github.com:craigraw/sparrow.git`
|
||||
Sparrow is a modern desktop Bitcoin wallet application supporting most hardware wallets and built on common standards such as PSBT, with an emphasis on transparency and usability.
|
||||
|
||||
## Various ways to hex dump a file without spaces:
|
||||
`xxd -p file | tr -d '\n'`
|
||||
More information (and release binaries) can be found at https://sparrowwallet.com. Release binaries are also available directly from [Github](https://github.com/sparrowwallet/sparrow/releases).
|
||||
|
||||
`hexdump -ve '1/1 "%.2x"'`
|
||||

|
||||
|
||||
`od -t x1 -An file | tr -d '\n '`
|
||||
## Building
|
||||
|
||||
To clone this project, use
|
||||
|
||||
`git clone --recursive git@github.com:sparrowwallet/sparrow.git`
|
||||
|
||||
In order to build, Sparrow requires Java 14 to be installed. The release packages can be built using
|
||||
|
||||
`./gradlew jpackage`
|
||||
|
||||
When updating to the latest HEAD
|
||||
|
||||
`git pull --recurse-submodules`
|
||||
|
||||
All jar files created are reproducible builds.
|
||||
|
||||
## Running
|
||||
|
||||
If you prefer to run Sparrow directly from source, it can be launched with
|
||||
|
||||
`./sparrow`
|
||||
|
||||
Java 14 must be installed.
|
||||
|
||||
## Configuration
|
||||
|
||||
Sparrow has a number of command line options, for example to change its home folder or use testnet:
|
||||
|
||||
```
|
||||
./sparrow -h
|
||||
|
||||
Usage: sparrow [options]
|
||||
Options:
|
||||
--dir, -d
|
||||
Path to Sparrow home folder
|
||||
--help, -h
|
||||
Show usage
|
||||
--network, -n
|
||||
Network to use
|
||||
Possible Values: [mainnet, testnet, regtest]
|
||||
```
|
||||
|
||||
As a fallback, the network (mainnet, testnet or regtest) can also be set using an environment variable `SPARROW_NETWORK`. For example:
|
||||
|
||||
`export SPARROW_NETWORK=testnet`
|
||||
|
||||
A final fallback which can be useful when running the Sparrow binary is to create a file called ``network-testnet`` in the Sparrow home folder (see below) to configure the testnet network.
|
||||
|
||||
Note that if you are connecting to an Electrum server when using testnet, that server will need to running on testnet configuration as well.
|
||||
|
||||
When not explicitly configured using the command line argument above, Sparrow stores its mainnet config file, log file and wallets in a home folder location appropriate to the operating system:
|
||||
|
||||
Platform | Location
|
||||
-------- | --------
|
||||
OSX | ~/.sparrow
|
||||
Linux | ~/.sparrow
|
||||
Windows | %APPDATA%/Sparrow
|
||||
|
||||
Testnet and regtest configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Please use the [Issues](https://github.com/sparrowwallet/sparrow/issues) tab above to report an issue. If possible, look in the sparrow.log file in the configuration directory for information helpful in debugging.
|
||||
|
||||
## License
|
||||
|
||||
Sparrow is licensed under the Apache 2 software licence.
|
||||
|
||||
## Credit
|
||||
|
||||

|
||||
|
||||
Sparrow Wallet uses the [Yourkit Java Profiler](https://www.yourkit.com/java/profiler/) to profile and improve performance.
|
||||
YourKit supports open source projects with useful tools for monitoring and profiling Java and .NET applications.
|
||||
|
||||
+118
-22
@@ -1,22 +1,36 @@
|
||||
plugins {
|
||||
id 'application'
|
||||
id 'org.openjfx.javafxplugin' version '0.0.8'
|
||||
id 'org.openjfx.javafxplugin' version '0.0.9'
|
||||
id 'org.kordamp.gradle.jdeps' version '0.9.0'
|
||||
id 'org.beryx.jlink' version '2.17.4'
|
||||
id 'org.beryx.jlink' version '2.22.0'
|
||||
}
|
||||
|
||||
group 'com.sparrowwallet'
|
||||
version '1.0-SNAPSHOT'
|
||||
def sparrowVersion = '1.0.0'
|
||||
def os = org.gradle.internal.os.OperatingSystem.current()
|
||||
def osName = os.getFamilyName()
|
||||
if(os.macOsX) {
|
||||
osName = "osx"
|
||||
}
|
||||
|
||||
sourceCompatibility = 1.9
|
||||
group "com.sparrowwallet"
|
||||
version "${sparrowVersion}"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url 'https://oss.sonatype.org/content/groups/public' }
|
||||
maven { url 'https://mymavenrepo.com/repo/29EACwkkGcoOKnbx3bxN/' }
|
||||
maven { url 'https://jitpack.io' }
|
||||
maven { url 'http://maven.openimaj.org/' }
|
||||
}
|
||||
|
||||
tasks.withType(AbstractArchiveTask) {
|
||||
preserveFileTimestamps = false
|
||||
reproducibleFileOrder = true
|
||||
}
|
||||
|
||||
javafx {
|
||||
version = "14"
|
||||
modules = [ 'javafx.controls', 'javafx.fxml' ]
|
||||
version = "15"
|
||||
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
|
||||
}
|
||||
|
||||
java {
|
||||
@@ -29,9 +43,24 @@ dependencies {
|
||||
exclude group: 'junit'
|
||||
}
|
||||
implementation('com.google.guava:guava:28.2-jre')
|
||||
implementation('com.google.code.gson:gson:2.8.6')
|
||||
implementation('org.fxmisc.richtext:richtextfx:0.10.4')
|
||||
implementation('no.tornado:tornadofx-controls:1.0.4')
|
||||
implementation('org.controlsfx:controlsfx:11.0.1' ) {
|
||||
implementation('com.google.zxing:javase:3.4.0')
|
||||
implementation('com.github.arteam:simple-json-rpc-client:1.0')
|
||||
implementation('com.github.arteam:simple-json-rpc-server:1.0') {
|
||||
exclude group: 'org.slf4j'
|
||||
}
|
||||
implementation('com.sparrowwallet:hummingbird:1.5.2')
|
||||
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
|
||||
exclude group: 'com.google.android.tools', module: 'dx'
|
||||
}
|
||||
implementation('com.github.sarxos:webcam-capture:0.3.13-SNAPSHOT') {
|
||||
exclude group: 'com.nativelibs4java', module: 'bridj'
|
||||
}
|
||||
implementation("com.sparrowwallet:netlayer-jpms-${osName}:0.6.8")
|
||||
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
|
||||
implementation('org.controlsfx:controlsfx:11.0.2' ) {
|
||||
exclude group: 'org.openjfx', module: 'javafx-base'
|
||||
exclude group: 'org.openjfx', module: 'javafx-graphics'
|
||||
exclude group: 'org.openjfx', module: 'javafx-controls'
|
||||
@@ -40,43 +69,110 @@ dependencies {
|
||||
exclude group: 'org.openjfx', module: 'javafx-web'
|
||||
exclude group: 'org.openjfx', module: 'javafx-media'
|
||||
}
|
||||
implementation('dev.bwt:bwt-jni:0.1.7')
|
||||
testImplementation('junit:junit:4.12')
|
||||
}
|
||||
|
||||
mainClassName = 'com.sparrowwallet.sparrow/com.sparrowwallet.sparrow.MainApp'
|
||||
|
||||
compileJava {
|
||||
options.with {
|
||||
fork = true
|
||||
compilerArgs.addAll(["--add-exports", "org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
|
||||
"--add-exports", "javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
|
||||
"--add-exports", "javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow"])
|
||||
}
|
||||
}
|
||||
|
||||
processResources {
|
||||
doLast {
|
||||
delete fileTree("$buildDir/resources/main/native").matching {
|
||||
exclude "${osName}/**"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run {
|
||||
applicationDefaultJvmArgs = ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow.png", "--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls"]
|
||||
applicationDefaultJvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
|
||||
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.scene.traversal=org.controlsfx.controls",
|
||||
"--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls",
|
||||
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
|
||||
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
|
||||
"--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.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/com.sun.javafx.application=com.sparrowwallet.sparrow",
|
||||
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow"]
|
||||
|
||||
if(os.macOsX) {
|
||||
applicationDefaultJvmArgs += ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow.png",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
|
||||
}
|
||||
}
|
||||
|
||||
jlink {
|
||||
mergedModule {
|
||||
requires 'javafx.graphics';
|
||||
requires 'javafx.controls';
|
||||
requires 'java.xml';
|
||||
requires 'javafx.graphics'
|
||||
requires 'javafx.controls'
|
||||
requires 'java.xml'
|
||||
requires 'java.logging'
|
||||
requires 'javafx.base';
|
||||
requires 'javafx.base'
|
||||
requires 'com.fasterxml.jackson.databind'
|
||||
requires 'jdk.crypto.cryptoki'
|
||||
requires 'java.management'
|
||||
}
|
||||
|
||||
options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png']
|
||||
launcher {
|
||||
name = 'sparrow'
|
||||
jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls"]
|
||||
jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
|
||||
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.scene.traversal=org.controlsfx.controls",
|
||||
"--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls",
|
||||
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
|
||||
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.tk=com.sparrowwallet.merged.module",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=com.sparrowwallet.merged.module",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.merged.module",
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control=com.sparrowwallet.merged.module",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.menu=com.sparrowwallet.merged.module",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
|
||||
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
|
||||
"--add-reads=com.sparrowwallet.merged.module=java.desktop"]
|
||||
|
||||
if(os.macOsX) {
|
||||
jvmArgs += "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"
|
||||
}
|
||||
}
|
||||
addExtraDependencies("javafx")
|
||||
jpackage {
|
||||
imageName = "Sparrow"
|
||||
installerName = "Sparrow"
|
||||
appVersion = "0.5"
|
||||
appVersion = "${sparrowVersion}"
|
||||
skipInstaller = os.macOsX
|
||||
imageOptions = []
|
||||
installerOptions = [
|
||||
'--file-associations', 'src/main/resources/associations.properties',
|
||||
]
|
||||
if (org.gradle.internal.os.OperatingSystem.current().windows) {
|
||||
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu']
|
||||
installerOptions = ['--file-associations', 'src/main/deploy/associations.properties', '--license-file', 'LICENSE']
|
||||
if(os.windows) {
|
||||
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-shortcut']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico']
|
||||
installerType = "exe"
|
||||
}
|
||||
if (org.gradle.internal.os.OperatingSystem.current().macOsX) {
|
||||
imageOptions += ['--icon', 'src/main/resources/sparrow.icns']
|
||||
if(os.linux) {
|
||||
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-rpm-license-type', 'ASL 2.0']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/']
|
||||
}
|
||||
if(os.macOsX) {
|
||||
installerOptions += ['--mac-sign', '--mac-signing-key-user-name', 'Craig Raw (UPLVMSK9D7)']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/osx/sparrow.icns', '--resource-dir', 'src/main/deploy/package/osx/']
|
||||
installerType = "dmg"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule drongo updated: 0cebee3d22...a38206f17c
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
args="$*"
|
||||
args="${args%"${args##*[![:space:]]}"}"
|
||||
|
||||
if [ -n "$args" ]
|
||||
then
|
||||
./gradlew run --args="$args"
|
||||
else
|
||||
./gradlew run
|
||||
fi
|
||||
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
set ARGS=%*
|
||||
|
||||
if "%ARGS%" == "" (
|
||||
gradlew.bat run
|
||||
) else (
|
||||
gradlew.bat run --args="%ARGS%"
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" ?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.9</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleAllowMixedLocalizations</key>
|
||||
<true/>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Sparrow</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Sparrow.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.sparrowwallet.sparrow</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Sparrow</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>Unknown</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>100</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright (C) 2021</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Sparrow requires access to the camera in order to scan QR codes</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,29 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
public class AboutController {
|
||||
private Stage stage;
|
||||
|
||||
@FXML
|
||||
private Label title;
|
||||
|
||||
public void initializeView() {
|
||||
title.setText(MainApp.APP_NAME + " " + MainApp.APP_VERSION);
|
||||
}
|
||||
|
||||
public void setStage(Stage stage) {
|
||||
this.stage = stage;
|
||||
}
|
||||
|
||||
public void close(ActionEvent event) {
|
||||
stage.close();
|
||||
}
|
||||
|
||||
public void openDonate(ActionEvent event) {
|
||||
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/donate");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
import com.sparrowwallet.sparrow.io.Hwi;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.net.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Worker;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.Window;
|
||||
import javafx.util.Duration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class AppServices {
|
||||
private static final Logger log = LoggerFactory.getLogger(AppServices.class);
|
||||
|
||||
private static final int SERVER_PING_PERIOD = 1 * 60 * 1000;
|
||||
private static final int ENUMERATE_HW_PERIOD = 30 * 1000;
|
||||
private static final int RATES_PERIOD = 5 * 60 * 1000;
|
||||
private static final int VERSION_CHECK_PERIOD_HOURS = 24;
|
||||
private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO;
|
||||
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
|
||||
|
||||
private static AppServices INSTANCE;
|
||||
|
||||
private final MainApp application;
|
||||
|
||||
private final Map<Window, List<WalletTabData>> walletWindows = new LinkedHashMap<>();
|
||||
|
||||
private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false);
|
||||
|
||||
private ExchangeSource.RatesService ratesService;
|
||||
|
||||
private ElectrumServer.ConnectionService connectionService;
|
||||
|
||||
private Hwi.ScheduledEnumerateService deviceEnumerateService;
|
||||
|
||||
private VersionCheckService versionCheckService;
|
||||
|
||||
private static Integer currentBlockHeight;
|
||||
|
||||
private static Map<Integer, Double> targetBlockFeeRates;
|
||||
|
||||
private static final Map<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>();
|
||||
|
||||
private static Double minimumRelayFeeRate;
|
||||
|
||||
private static CurrencyRate fiatCurrencyExchangeRate;
|
||||
|
||||
private static List<Device> devices;
|
||||
|
||||
private static final Map<Address, BitcoinURI> payjoinURIs = new HashMap<>();
|
||||
|
||||
private final ChangeListener<Boolean> onlineServicesListener = new ChangeListener<>() {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean online) {
|
||||
if(online) {
|
||||
restartService(connectionService);
|
||||
|
||||
if(ratesService.getExchangeSource() != ExchangeSource.NONE) {
|
||||
restartService(ratesService);
|
||||
}
|
||||
|
||||
if(Config.get().isCheckNewVersions()) {
|
||||
restartService(versionCheckService);
|
||||
}
|
||||
} else {
|
||||
connectionService.cancel();
|
||||
ratesService.cancel();
|
||||
versionCheckService.cancel();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private void restartService(ScheduledService<?> service) {
|
||||
if(service.isRunning()) {
|
||||
service.cancel();
|
||||
}
|
||||
|
||||
if(service.getState() == Worker.State.CANCELLED || service.getState() == Worker.State.FAILED) {
|
||||
service.reset();
|
||||
}
|
||||
|
||||
if(!service.isRunning()) {
|
||||
service.start();
|
||||
}
|
||||
}
|
||||
|
||||
public AppServices(MainApp application) {
|
||||
this.application = application;
|
||||
EventManager.get().register(this);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
Config config = Config.get();
|
||||
connectionService = createConnectionService();
|
||||
if(config.getMode() == Mode.ONLINE && config.getServerAddress() != null && !config.getServerAddress().isEmpty()) {
|
||||
connectionService.start();
|
||||
}
|
||||
|
||||
ExchangeSource source = config.getExchangeSource() != null ? config.getExchangeSource() : DEFAULT_EXCHANGE_SOURCE;
|
||||
Currency currency = config.getFiatCurrency() != null ? config.getFiatCurrency() : DEFAULT_FIAT_CURRENCY;
|
||||
ratesService = createRatesService(source, currency);
|
||||
if(config.getMode() == Mode.ONLINE && source != ExchangeSource.NONE) {
|
||||
ratesService.start();
|
||||
}
|
||||
|
||||
versionCheckService = createVersionCheckService();
|
||||
if(config.getMode() == Mode.ONLINE && config.isCheckNewVersions()) {
|
||||
versionCheckService.start();
|
||||
}
|
||||
|
||||
onlineProperty.addListener(onlineServicesListener);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if(connectionService != null) {
|
||||
connectionService.cancel();
|
||||
}
|
||||
|
||||
if(ratesService != null) {
|
||||
ratesService.cancel();
|
||||
}
|
||||
|
||||
if(versionCheckService != null) {
|
||||
versionCheckService.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private ElectrumServer.ConnectionService createConnectionService() {
|
||||
ElectrumServer.ConnectionService connectionService = new ElectrumServer.ConnectionService();
|
||||
connectionService.setPeriod(new Duration(SERVER_PING_PERIOD));
|
||||
connectionService.setRestartOnFailure(true);
|
||||
|
||||
EventManager.get().register(connectionService);
|
||||
connectionService.statusProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(connectionService.isRunning()) {
|
||||
EventManager.get().post(new StatusEvent(newValue));
|
||||
}
|
||||
});
|
||||
|
||||
connectionService.setOnSucceeded(successEvent -> {
|
||||
connectionService.setRestartOnFailure(true);
|
||||
|
||||
onlineProperty.removeListener(onlineServicesListener);
|
||||
onlineProperty.setValue(true);
|
||||
onlineProperty.addListener(onlineServicesListener);
|
||||
|
||||
if(connectionService.getValue() != null) {
|
||||
EventManager.get().post(connectionService.getValue());
|
||||
}
|
||||
});
|
||||
connectionService.setOnFailed(failEvent -> {
|
||||
//Close connection here to create a new transport next time we try
|
||||
connectionService.resetConnection();
|
||||
|
||||
if(failEvent.getSource().getException() instanceof ServerConfigException) {
|
||||
connectionService.setRestartOnFailure(false);
|
||||
}
|
||||
|
||||
onlineProperty.removeListener(onlineServicesListener);
|
||||
onlineProperty.setValue(false);
|
||||
onlineProperty.addListener(onlineServicesListener);
|
||||
|
||||
log.debug("Connection failed", failEvent.getSource().getException());
|
||||
EventManager.get().post(new ConnectionFailedEvent(failEvent.getSource().getException()));
|
||||
});
|
||||
|
||||
return connectionService;
|
||||
}
|
||||
|
||||
private ExchangeSource.RatesService createRatesService(ExchangeSource exchangeSource, Currency currency) {
|
||||
ExchangeSource.RatesService ratesService = new ExchangeSource.RatesService(exchangeSource, currency);
|
||||
ratesService.setPeriod(new Duration(RATES_PERIOD));
|
||||
ratesService.setRestartOnFailure(true);
|
||||
|
||||
ratesService.setOnSucceeded(successEvent -> {
|
||||
EventManager.get().post(ratesService.getValue());
|
||||
});
|
||||
|
||||
return ratesService;
|
||||
}
|
||||
|
||||
private VersionCheckService createVersionCheckService() {
|
||||
VersionCheckService versionCheckService = new VersionCheckService();
|
||||
versionCheckService.setDelay(Duration.seconds(10));
|
||||
versionCheckService.setPeriod(Duration.hours(VERSION_CHECK_PERIOD_HOURS));
|
||||
versionCheckService.setRestartOnFailure(true);
|
||||
|
||||
versionCheckService.setOnSucceeded(successEvent -> {
|
||||
VersionUpdatedEvent event = versionCheckService.getValue();
|
||||
if(event != null) {
|
||||
EventManager.get().post(event);
|
||||
}
|
||||
});
|
||||
|
||||
return versionCheckService;
|
||||
}
|
||||
|
||||
private Hwi.ScheduledEnumerateService createDeviceEnumerateService() {
|
||||
Hwi.ScheduledEnumerateService enumerateService = new Hwi.ScheduledEnumerateService(null);
|
||||
enumerateService.setPeriod(new Duration(ENUMERATE_HW_PERIOD));
|
||||
enumerateService.setOnSucceeded(workerStateEvent -> {
|
||||
List<Device> devices = enumerateService.getValue();
|
||||
|
||||
//Null devices are returned if the app is currently prompting for a pin. Otherwise, the enumerate clears the pin screen
|
||||
if(devices != null) {
|
||||
//If another instance of HWI is currently accessing the usb interface, HWI returns empty device models. Ignore this run if that happens
|
||||
List<Device> validDevices = devices.stream().filter(device -> device.getModel() != null).collect(Collectors.toList());
|
||||
if(validDevices.size() == devices.size()) {
|
||||
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return enumerateService;
|
||||
}
|
||||
|
||||
static void initialize(MainApp application) {
|
||||
INSTANCE = new AppServices(application);
|
||||
}
|
||||
|
||||
public static AppServices get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public static AppController newAppWindow(Stage stage) {
|
||||
try {
|
||||
FXMLLoader appLoader = new FXMLLoader(AppServices.class.getResource("app.fxml"));
|
||||
Parent root = appLoader.load();
|
||||
AppController appController = appLoader.getController();
|
||||
|
||||
Scene scene = new Scene(root);
|
||||
scene.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
|
||||
|
||||
stage.setTitle("Sparrow");
|
||||
stage.setMinWidth(650);
|
||||
stage.setMinHeight(730);
|
||||
stage.setScene(scene);
|
||||
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/image/sparrow.png")));
|
||||
|
||||
appController.initializeView();
|
||||
stage.show();
|
||||
return appController;
|
||||
} catch(IOException e) {
|
||||
log.error("Could not load app FXML", e);
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isReducedWindowHeight(Node node) {
|
||||
return (node.getScene() != null && node.getScene().getWindow().getHeight() < 768);
|
||||
}
|
||||
|
||||
public MainApp getApplication() {
|
||||
return application;
|
||||
}
|
||||
|
||||
public Map<Wallet, Storage> getOpenWallets() {
|
||||
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
|
||||
for(List<WalletTabData> walletTabDataList : walletWindows.values()) {
|
||||
for(WalletTabData walletTabData : walletTabDataList) {
|
||||
openWallets.put(walletTabData.getWallet(), walletTabData.getStorage());
|
||||
}
|
||||
}
|
||||
|
||||
return openWallets;
|
||||
}
|
||||
|
||||
public Window getWindowForWallet(Storage storage) {
|
||||
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getStorage().getWalletFile().equals(storage.getWalletFile()))).map(Map.Entry::getKey).findFirst();
|
||||
return optWindow.orElse(null);
|
||||
}
|
||||
|
||||
public Window getWindowForPSBT(PSBT psbt) {
|
||||
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getWallet().canSign(psbt))).map(Map.Entry::getKey).findFirst();
|
||||
return optWindow.orElse(null);
|
||||
}
|
||||
|
||||
public double getWalletWindowMaxX() {
|
||||
return walletWindows.keySet().stream().mapToDouble(Window::getX).max().orElse(0d);
|
||||
}
|
||||
|
||||
public static boolean isConnecting() {
|
||||
return get().connectionService != null && get().connectionService.isConnecting();
|
||||
}
|
||||
|
||||
public static boolean isConnected() {
|
||||
return onlineProperty.get() && get().connectionService != null && get().connectionService.isConnected();
|
||||
}
|
||||
|
||||
public static BooleanProperty onlineProperty() {
|
||||
return onlineProperty;
|
||||
}
|
||||
|
||||
public static Integer getCurrentBlockHeight() {
|
||||
return currentBlockHeight;
|
||||
}
|
||||
|
||||
public static Map<Integer, Double> getTargetBlockFeeRates() {
|
||||
return targetBlockFeeRates;
|
||||
}
|
||||
|
||||
public static Map<Date, Set<MempoolRateSize>> getMempoolHistogram() {
|
||||
return mempoolHistogram;
|
||||
}
|
||||
|
||||
private void addMempoolRateSizes(Set<MempoolRateSize> rateSizes) {
|
||||
LocalDateTime dateMinute = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);
|
||||
if(mempoolHistogram.isEmpty()) {
|
||||
mempoolHistogram.put(Date.from(dateMinute.minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
|
||||
}
|
||||
|
||||
mempoolHistogram.put(Date.from(dateMinute.atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
|
||||
}
|
||||
|
||||
public static Double getMinimumRelayFeeRate() {
|
||||
return minimumRelayFeeRate == null ? Transaction.DEFAULT_MIN_RELAY_FEE : minimumRelayFeeRate;
|
||||
}
|
||||
|
||||
public static CurrencyRate getFiatCurrencyExchangeRate() {
|
||||
return fiatCurrencyExchangeRate;
|
||||
}
|
||||
|
||||
public static List<Device> getDevices() {
|
||||
return devices == null ? new ArrayList<>() : devices;
|
||||
}
|
||||
|
||||
public static BitcoinURI getPayjoinURI(Address address) {
|
||||
return payjoinURIs.get(address);
|
||||
}
|
||||
|
||||
public static void addPayjoinURI(BitcoinURI bitcoinURI) {
|
||||
if(bitcoinURI.getPayjoinUrl() == null) {
|
||||
throw new IllegalArgumentException("Not a payjoin URI");
|
||||
}
|
||||
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
|
||||
}
|
||||
|
||||
public static Optional<ButtonType> showWarningDialog(String title, String content, ButtonType... buttons) {
|
||||
return showAlertDialog(title, content, Alert.AlertType.WARNING, buttons);
|
||||
}
|
||||
|
||||
public static Optional<ButtonType> showErrorDialog(String title, String content, ButtonType... buttons) {
|
||||
return showAlertDialog(title, content, Alert.AlertType.ERROR, buttons);
|
||||
}
|
||||
|
||||
public static Optional<ButtonType> showAlertDialog(String title, String content, Alert.AlertType alertType, ButtonType... buttons) {
|
||||
Alert alert = new Alert(alertType, content, buttons);
|
||||
setStageIcon(alert.getDialogPane().getScene().getWindow());
|
||||
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
alert.setTitle(title);
|
||||
alert.setHeaderText(title);
|
||||
return alert.showAndWait();
|
||||
}
|
||||
|
||||
public static void setStageIcon(Window window) {
|
||||
Stage stage = (Stage)window;
|
||||
stage.getIcons().add(new Image(AppServices.class.getResourceAsStream("/image/sparrow.png")));
|
||||
|
||||
if(stage.getScene() != null && Config.get().getTheme() == Theme.DARK) {
|
||||
stage.getScene().getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
|
||||
}
|
||||
}
|
||||
|
||||
public static Font getMonospaceFont() {
|
||||
return Font.font("Roboto Mono", 13);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void newConnection(ConnectionEvent event) {
|
||||
currentBlockHeight = event.getBlockHeight();
|
||||
targetBlockFeeRates = event.getTargetBlockFeeRates();
|
||||
addMempoolRateSizes(event.getMempoolRateSizes());
|
||||
minimumRelayFeeRate = event.getMinimumRelayFeeRate();
|
||||
String banner = event.getServerBanner();
|
||||
String status = "Connected to " + Config.get().getServerAddress() + " at height " + event.getBlockHeight();
|
||||
EventManager.get().post(new StatusEvent(status));
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void connectionFailed(ConnectionFailedEvent event) {
|
||||
String reason = event.getException().getCause() != null ? event.getException().getCause().getMessage() : event.getException().getMessage();
|
||||
String status = "Connection error: " + reason;
|
||||
EventManager.get().post(new StatusEvent(status));
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void usbDevicesFound(UsbDeviceEvent event) {
|
||||
devices = Collections.unmodifiableList(event.getDevices());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void newBlock(NewBlockEvent event) {
|
||||
currentBlockHeight = event.getHeight();
|
||||
String status = "Updating to new block height " + event.getHeight();
|
||||
EventManager.get().post(new StatusEvent(status));
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void feesUpdated(FeeRatesUpdatedEvent event) {
|
||||
targetBlockFeeRates = event.getTargetBlockFeeRates();
|
||||
addMempoolRateSizes(event.getMempoolRateSizes());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) {
|
||||
ElectrumServer.FeeRatesService feeRatesService = new ElectrumServer.FeeRatesService();
|
||||
feeRatesService.setOnSucceeded(workerStateEvent -> {
|
||||
EventManager.get().post(feeRatesService.getValue());
|
||||
});
|
||||
//Perform once-off fee rates retrieval to immediately change displayed rates
|
||||
feeRatesService.start();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void fiatCurrencySelected(FiatCurrencySelectedEvent event) {
|
||||
if(ratesService != null) {
|
||||
ratesService.cancel();
|
||||
|
||||
if(Config.get().getMode() != Mode.OFFLINE && event.getExchangeSource() != ExchangeSource.NONE) {
|
||||
ratesService = createRatesService(event.getExchangeSource(), event.getCurrency());
|
||||
ratesService.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
|
||||
fiatCurrencyExchangeRate = event.getCurrencyRate();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void versionCheckStatus(VersionCheckStatusEvent event) {
|
||||
versionCheckService.cancel();
|
||||
|
||||
if(Config.get().getMode() != Mode.OFFLINE && event.isEnabled()) {
|
||||
versionCheckService = createVersionCheckService();
|
||||
versionCheckService.start();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void openWallets(OpenWalletsEvent event) {
|
||||
if(event.getWalletTabDataList().isEmpty()) {
|
||||
walletWindows.remove(event.getWindow());
|
||||
} else {
|
||||
walletWindows.put(event.getWindow(), event.getWalletTabDataList());
|
||||
}
|
||||
|
||||
List<WalletTabData> allWallets = walletWindows.values().stream().flatMap(Collection::stream).collect(Collectors.toList());
|
||||
|
||||
Platform.runLater(() -> {
|
||||
if(!Window.getWindows().isEmpty()) {
|
||||
List<File> walletFiles = allWallets.stream().map(walletTabData -> walletTabData.getStorage().getWalletFile()).collect(Collectors.toList());
|
||||
Config.get().setRecentWalletFiles(walletFiles);
|
||||
}
|
||||
});
|
||||
|
||||
boolean usbWallet = false;
|
||||
for(WalletTabData walletTabData : allWallets) {
|
||||
Wallet wallet = walletTabData.getWallet();
|
||||
Storage storage = walletTabData.getStorage();
|
||||
|
||||
if(!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB)) {
|
||||
usbWallet = true;
|
||||
|
||||
if(deviceEnumerateService == null) {
|
||||
deviceEnumerateService = createDeviceEnumerateService();
|
||||
}
|
||||
|
||||
if(deviceEnumerateService.getState() == Worker.State.CANCELLED) {
|
||||
deviceEnumerateService.reset();
|
||||
}
|
||||
|
||||
if(!deviceEnumerateService.isRunning()) {
|
||||
deviceEnumerateService.start();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(!usbWallet && deviceEnumerateService != null && deviceEnumerateService.isRunning()) {
|
||||
deviceEnumerateService.cancel();
|
||||
EventManager.get().post(new UsbDeviceEvent(Collections.emptyList()));
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void requestConnect(RequestConnectEvent event) {
|
||||
onlineProperty.set(true);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void requestDisconnect(RequestDisconnectEvent event) {
|
||||
onlineProperty.set(false);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
|
||||
restartBwt(event.getWallet());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletOpening(WalletOpeningEvent event) {
|
||||
restartBwt(event.getWallet());
|
||||
}
|
||||
|
||||
private void restartBwt(Wallet wallet) {
|
||||
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && isConnected() && wallet.isValid()) {
|
||||
connectionService.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void bwtShutdown(BwtShutdownEvent event) {
|
||||
if(onlineProperty().get() && !connectionService.isRunning()) {
|
||||
connectionService.reset();
|
||||
connectionService.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.beust.jcommander.Parameter;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
|
||||
public class Args {
|
||||
@Parameter(names = { "--dir", "-d" }, description = "Path to Sparrow home folder")
|
||||
public String dir;
|
||||
|
||||
@Parameter(names = { "--network", "-n" }, description = "Network to use")
|
||||
public Network network;
|
||||
|
||||
@Parameter(names = { "--help", "-h" }, description = "Show usage", help = true)
|
||||
public boolean help;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptChunk;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.sparrow.control.DescriptorArea;
|
||||
import com.sparrowwallet.sparrow.control.ScriptArea;
|
||||
import javafx.geometry.Point2D;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.stage.Popup;
|
||||
import org.fxmisc.richtext.event.MouseOverTextEvent;
|
||||
import org.fxmisc.richtext.model.TwoDimensional;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward;
|
||||
|
||||
public abstract class BaseController {
|
||||
protected void initializeScriptField(ScriptArea scriptArea) {
|
||||
Popup popup = new Popup();
|
||||
Label popupMsg = new Label();
|
||||
popupMsg.getStyleClass().add("tooltip");
|
||||
popup.getContent().add(popupMsg);
|
||||
|
||||
scriptArea.setMouseOverTextDelay(Duration.ofMillis(150));
|
||||
scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
|
||||
TwoDimensional.Position position = scriptArea.getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex(), Backward);
|
||||
if(position.getMajor() % 2 == 0) {
|
||||
ScriptChunk hoverChunk = scriptArea.getScript().getChunks().get(position.getMajor()/2);
|
||||
if(!hoverChunk.isOpCode()) {
|
||||
Point2D pos = e.getScreenPosition();
|
||||
popupMsg.setText(describeScriptChunk(hoverChunk));
|
||||
popup.show(scriptArea, pos.getX(), pos.getY() + 10);
|
||||
}
|
||||
}
|
||||
});
|
||||
scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_END, e -> {
|
||||
popup.hide();
|
||||
});
|
||||
}
|
||||
|
||||
protected String describeScriptChunk(ScriptChunk chunk) {
|
||||
return chunk.toString();
|
||||
}
|
||||
|
||||
protected void initializeDescriptorField(DescriptorArea descriptorArea) {
|
||||
Popup popup = new Popup();
|
||||
Label popupMsg = new Label();
|
||||
popupMsg.getStyleClass().add("tooltip");
|
||||
popup.getContent().add(popupMsg);
|
||||
|
||||
descriptorArea.setMouseOverTextDelay(Duration.ofMillis(150));
|
||||
descriptorArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
|
||||
TwoDimensional.Position position = descriptorArea.getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex(), Backward);
|
||||
int index = descriptorArea.getWallet().getPolicyType() == PolicyType.SINGLE ? position.getMajor() - 1 : ((position.getMajor() - 1) / 2);
|
||||
if(position.getMajor() > 0 && index >= 0 && index < descriptorArea.getWallet().getKeystores().size()) {
|
||||
Keystore hoverKeystore = descriptorArea.getWallet().getKeystores().get(index);
|
||||
Point2D pos = e.getScreenPosition();
|
||||
popupMsg.setText(describeKeystore(hoverKeystore));
|
||||
popup.show(descriptorArea, pos.getX(), pos.getY() + 10);
|
||||
}
|
||||
});
|
||||
descriptorArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_END, e -> {
|
||||
popup.hide();
|
||||
});
|
||||
}
|
||||
|
||||
protected String describeKeystore(Keystore keystore) {
|
||||
if(keystore.isValid()) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("[");
|
||||
builder.append(keystore.getKeyDerivation().getMasterFingerprint());
|
||||
builder.append("/");
|
||||
builder.append(keystore.getKeyDerivation().getDerivationPath().replaceFirst("^m?/", ""));
|
||||
builder.append("]");
|
||||
builder.append(keystore.getExtendedPublicKey().toString());
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
return "Invalid";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import java.util.Currency;
|
||||
|
||||
public class CurrencyRate {
|
||||
private final Currency currency;
|
||||
private final Double btcRate;
|
||||
|
||||
public CurrencyRate(Currency currency, Double btcRate) {
|
||||
this.currency = currency;
|
||||
this.btcRate = btcRate;
|
||||
}
|
||||
|
||||
public Currency getCurrency() {
|
||||
return currency;
|
||||
}
|
||||
|
||||
public boolean isAvailable() {
|
||||
return btcRate != null && btcRate > 0.0;
|
||||
}
|
||||
|
||||
public Double getBtcRate() {
|
||||
return btcRate;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,149 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.beust.jcommander.JCommander;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.sparrow.control.WelcomeDialog;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.FileType;
|
||||
import com.sparrowwallet.sparrow.io.IOUtils;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.net.Bwt;
|
||||
import com.sparrowwallet.sparrow.net.ServerType;
|
||||
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
|
||||
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
|
||||
import javafx.application.Application;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.stage.Stage;
|
||||
import org.controlsfx.glyphfont.GlyphFontRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class MainApp extends Application {
|
||||
public static final String APP_NAME = "Sparrow";
|
||||
public static final String APP_VERSION = "1.0.0";
|
||||
public static final String APP_HOME_PROPERTY = "sparrow.home";
|
||||
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
|
||||
|
||||
private Stage mainStage;
|
||||
|
||||
@Override
|
||||
public void init() throws Exception {
|
||||
Thread.setDefaultUncaughtExceptionHandler((t, e) -> LoggerFactory.getLogger(MainApp.class).error("Exception in thread \"" + t.getName() + "\"", e));
|
||||
super.init();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(Stage stage) throws Exception {
|
||||
FXMLLoader transactionLoader = new FXMLLoader(getClass().getResource("app.fxml"));
|
||||
Parent root = transactionLoader.load();
|
||||
AppController appController = transactionLoader.getController();
|
||||
this.mainStage = stage;
|
||||
|
||||
Scene scene = new Scene(root);
|
||||
scene.getStylesheets().add(getClass().getResource("app.css").toExternalForm());
|
||||
GlyphFontRegistry.register(new FontAwesome5());
|
||||
GlyphFontRegistry.register(new FontAwesome5Brands());
|
||||
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
|
||||
|
||||
stage.setTitle("Sparrow");
|
||||
stage.setMinWidth(650);
|
||||
stage.setMinHeight(700);
|
||||
stage.setScene(scene);
|
||||
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/sparrow.png")));
|
||||
AppServices.initialize(this);
|
||||
|
||||
appController.initializeView();
|
||||
boolean createNewWallet = false;
|
||||
Mode mode = Config.get().getMode();
|
||||
if(mode == null) {
|
||||
WelcomeDialog welcomeDialog = new WelcomeDialog(getHostServices());
|
||||
Optional<Mode> optionalMode = welcomeDialog.showAndWait();
|
||||
if(optionalMode.isPresent()) {
|
||||
mode = optionalMode.get();
|
||||
Config.get().setMode(mode);
|
||||
Config.get().setCoreWallet(Bwt.DEFAULT_CORE_WALLET);
|
||||
|
||||
stage.show();
|
||||
if(mode.equals(Mode.ONLINE)) {
|
||||
PreferencesDialog preferencesDialog = new PreferencesDialog(PreferenceGroup.SERVER, true);
|
||||
Optional<Boolean> optNewWallet = preferencesDialog.showAndWait();
|
||||
createNewWallet = optNewWallet.isPresent() && optNewWallet.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(Config.get().getServerType() == null && Config.get().getCoreServer() == null && Config.get().getElectrumServer() != null) {
|
||||
Config.get().setServerType(ServerType.ELECTRUM_SERVER);
|
||||
} else if(Config.get().getServerType() == ServerType.BITCOIN_CORE && Config.get().getCoreWallet() == null) {
|
||||
Config.get().setCoreMultiWallet(Boolean.TRUE);
|
||||
Config.get().setCoreWallet("");
|
||||
}
|
||||
|
||||
AppController appController = AppServices.newAppWindow(stage);
|
||||
|
||||
if(createNewWallet) {
|
||||
appController.newWallet(null);
|
||||
}
|
||||
|
||||
List<File> recentWalletFiles = Config.get().getRecentWalletFiles();
|
||||
if(recentWalletFiles != null) {
|
||||
//Re-sort to preserve wallet order as far as possible. Unencrypted wallets will still be opened first.
|
||||
List<File> encryptedWalletFiles = recentWalletFiles.stream().filter(file -> FileType.BINARY.equals(IOUtils.getFileType(file))).collect(Collectors.toList());
|
||||
Collections.reverse(encryptedWalletFiles);
|
||||
List<File> sortedWalletFiles = new ArrayList<>(recentWalletFiles);
|
||||
sortedWalletFiles.removeAll(encryptedWalletFiles);
|
||||
sortedWalletFiles.addAll(encryptedWalletFiles);
|
||||
|
||||
for(File walletFile : sortedWalletFiles) {
|
||||
if(walletFile.exists()) {
|
||||
appController.openWalletFile(walletFile, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppServices.get().start();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
launch(args);
|
||||
@Override
|
||||
public void stop() throws Exception {
|
||||
AppServices.get().stop();
|
||||
mainStage.close();
|
||||
}
|
||||
|
||||
public static void main(String[] argv) {
|
||||
Args args = new Args();
|
||||
JCommander jCommander = JCommander.newBuilder().addObject(args).programName(APP_NAME.toLowerCase()).acceptUnknownOptions(true).build();
|
||||
jCommander.parse(argv);
|
||||
if(args.help) {
|
||||
jCommander.usage();
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
if(args.dir != null) {
|
||||
System.setProperty(APP_HOME_PROPERTY, args.dir);
|
||||
getLogger().info("Using configured Sparrow home folder of " + args.dir);
|
||||
}
|
||||
|
||||
if(args.network != null) {
|
||||
Network.set(args.network);
|
||||
} else {
|
||||
String envNetwork = System.getenv(NETWORK_ENV_PROPERTY);
|
||||
if(envNetwork != null) {
|
||||
try {
|
||||
Network.set(Network.valueOf(envNetwork.toUpperCase()));
|
||||
} catch(Exception e) {
|
||||
getLogger().warn("Invalid " + NETWORK_ENV_PROPERTY + " property: " + envNetwork);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File testnetFlag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET.getName());
|
||||
if(testnetFlag.exists()) {
|
||||
Network.set(Network.TESTNET);
|
||||
}
|
||||
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
getLogger().info("Using " + Network.get() + " configuration");
|
||||
}
|
||||
|
||||
com.sun.javafx.application.LauncherImpl.launchApplication(MainApp.class, MainAppPreloader.class, argv);
|
||||
}
|
||||
|
||||
private static Logger getLogger() {
|
||||
return LoggerFactory.getLogger(MainApp.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import javafx.application.Preloader;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
public class MainAppPreloader extends Preloader {
|
||||
@Override
|
||||
public void start(Stage stage) {
|
||||
com.sun.glass.ui.Application.GetApplication().setName("Sparrow");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
public enum Mode {
|
||||
OFFLINE, ONLINE
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class TabData {
|
||||
private TabType type;
|
||||
private File file;
|
||||
private String text;
|
||||
private final TabType type;
|
||||
|
||||
public TabData(TabType type) {
|
||||
this.type = type;
|
||||
@@ -15,23 +11,7 @@ public class TabData {
|
||||
return type;
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public void setFile(File file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public void setText(String text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
public enum TabType {
|
||||
TRANSACTION
|
||||
WALLET, TRANSACTION
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
public enum Theme {
|
||||
LIGHT, DARK
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.sparrow.transaction.TransactionData;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class TransactionTabData extends TabData {
|
||||
private final File file;
|
||||
private final TransactionData transactionData;
|
||||
|
||||
public TransactionTabData(TabType type, File file, TransactionData transactionData) {
|
||||
super(type);
|
||||
this.file = file;
|
||||
this.transactionData = transactionData;
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public TransactionData getTransactionData() {
|
||||
return transactionData;
|
||||
}
|
||||
|
||||
public Transaction getTransaction() {
|
||||
return transactionData.getTransaction();
|
||||
}
|
||||
|
||||
public PSBT getPsbt() {
|
||||
return transactionData.getPsbt();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletForm;
|
||||
|
||||
public class WalletTabData extends TabData {
|
||||
private final WalletForm walletForm;
|
||||
|
||||
public WalletTabData(TabType type, WalletForm walletForm) {
|
||||
super(type);
|
||||
this.walletForm = walletForm;
|
||||
}
|
||||
|
||||
public WalletForm getWalletForm() {
|
||||
return walletForm;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return walletForm.getWallet();
|
||||
}
|
||||
|
||||
public Storage getStorage() {
|
||||
return walletForm.getStorage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.ContentDisplay;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.control.TreeTableCell;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
public class AddressCell extends TreeTableCell<Entry, Entry> {
|
||||
public AddressCell() {
|
||||
super();
|
||||
setAlignment(Pos.CENTER_LEFT);
|
||||
setContentDisplay(ContentDisplay.RIGHT);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(Entry entry, boolean empty) {
|
||||
super.updateItem(entry, empty);
|
||||
|
||||
EntryCell.applyRowStyles(this, entry);
|
||||
getStyleClass().add("address-cell");
|
||||
|
||||
if (empty) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
} else {
|
||||
if(entry instanceof UtxoEntry) {
|
||||
UtxoEntry utxoEntry = (UtxoEntry)entry;
|
||||
Address address = utxoEntry.getAddress();
|
||||
setText(address.toString());
|
||||
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), null));
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setText(getTooltipText(utxoEntry));
|
||||
setTooltip(tooltip);
|
||||
|
||||
if(utxoEntry.isDuplicateAddress()) {
|
||||
setGraphic(getDuplicateGlyph());
|
||||
} else {
|
||||
setGraphic(null);
|
||||
}
|
||||
|
||||
utxoEntry.duplicateAddressProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(newValue) {
|
||||
setGraphic(getDuplicateGlyph());
|
||||
Tooltip tt = new Tooltip();
|
||||
tt.setText(getTooltipText(utxoEntry));
|
||||
setTooltip(tt);
|
||||
} else {
|
||||
setGraphic(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String getTooltipText(UtxoEntry utxoEntry) {
|
||||
return utxoEntry.getNode().getDerivationPath().replace("m", "..") + (utxoEntry.isDuplicateAddress() ? " (Duplicate address)" : "");
|
||||
}
|
||||
|
||||
public static Glyph getDuplicateGlyph() {
|
||||
Glyph duplicateGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
|
||||
duplicateGlyph.getStyleClass().add("duplicate-warning");
|
||||
duplicateGlyph.setFontSize(12);
|
||||
return duplicateGlyph;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.ReceiveActionEvent;
|
||||
import com.sparrowwallet.sparrow.event.ReceiveToEvent;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.NodeEntry;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.MouseButton;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
public class AddressTreeTable extends CoinTreeTable {
|
||||
public void initialize(NodeEntry rootEntry) {
|
||||
getStyleClass().add("address-treetable");
|
||||
setBitcoinUnit(rootEntry.getWallet());
|
||||
|
||||
String address = rootEntry.getAddress().toString();
|
||||
updateAll(rootEntry);
|
||||
setShowRoot(false);
|
||||
|
||||
TreeTableColumn<Entry, Entry> addressCol = new TreeTableColumn<>("Address / Outpoints");
|
||||
addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
|
||||
});
|
||||
addressCol.setCellFactory(p -> new EntryCell());
|
||||
addressCol.setSortable(false);
|
||||
getColumns().add(addressCol);
|
||||
|
||||
if(address != null) {
|
||||
addressCol.setMinWidth(TextUtils.computeTextWidth(AppServices.getMonospaceFont(), address, 0.0));
|
||||
}
|
||||
|
||||
TreeTableColumn<Entry, String> labelCol = new TreeTableColumn<>("Label");
|
||||
labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return param.getValue().getValue().labelProperty();
|
||||
});
|
||||
labelCol.setCellFactory(p -> new LabelCell());
|
||||
labelCol.setSortable(false);
|
||||
getColumns().add(labelCol);
|
||||
|
||||
TreeTableColumn<Entry, Number> amountCol = new TreeTableColumn<>("Value");
|
||||
amountCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
|
||||
});
|
||||
amountCol.setCellFactory(p -> new CoinCell());
|
||||
amountCol.setSortable(false);
|
||||
getColumns().add(amountCol);
|
||||
|
||||
setEditable(true);
|
||||
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
|
||||
addressCol.setSortType(TreeTableColumn.SortType.ASCENDING);
|
||||
getSortOrder().add(addressCol);
|
||||
|
||||
Integer highestUsedIndex = rootEntry.getNode().getHighestUsedIndex();
|
||||
if(highestUsedIndex != null) {
|
||||
OptionalInt tableIndex = rootEntry.getChildren().stream().filter(childEntry -> ((NodeEntry)childEntry).getNode().getIndex() == highestUsedIndex + 1).mapToInt(childEntry -> rootEntry.getChildren().indexOf(childEntry)).findFirst();
|
||||
if(tableIndex.isPresent() && tableIndex.getAsInt() > 5) {
|
||||
scrollTo(tableIndex.getAsInt());
|
||||
}
|
||||
}
|
||||
|
||||
setOnMouseClicked(mouseEvent -> {
|
||||
if(mouseEvent.getButton().equals(MouseButton.PRIMARY)){
|
||||
if(mouseEvent.getClickCount() == 2) {
|
||||
TreeItem<Entry> treeItem = getSelectionModel().getSelectedItem();
|
||||
if(treeItem != null && treeItem.getChildren().isEmpty()) {
|
||||
Entry entry = getSelectionModel().getSelectedItem().getValue();
|
||||
if(entry instanceof NodeEntry) {
|
||||
NodeEntry nodeEntry = (NodeEntry)entry;
|
||||
EventManager.get().post(new ReceiveActionEvent(nodeEntry));
|
||||
Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void updateAll(NodeEntry rootEntry) {
|
||||
setBitcoinUnit(rootEntry.getWallet());
|
||||
|
||||
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
|
||||
setRoot(rootItem);
|
||||
rootItem.setExpanded(true);
|
||||
|
||||
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
|
||||
TreeTableColumn<Entry, ?> addressCol = getColumns().get(0);
|
||||
getSortOrder().add(addressCol);
|
||||
addressCol.setSortType(TreeTableColumn.SortType.ASCENDING);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateHistory(List<WalletNode> updatedNodes) {
|
||||
//We only ever add or replace child nodes - never remove in order to keep a full sequence
|
||||
NodeEntry rootEntry = (NodeEntry)getRoot().getValue();
|
||||
|
||||
for(WalletNode updatedNode : updatedNodes) {
|
||||
NodeEntry nodeEntry = new NodeEntry(rootEntry.getWallet(), updatedNode);
|
||||
|
||||
Optional<Entry> optEntry = rootEntry.getChildren().stream().filter(childEntry -> ((NodeEntry)childEntry).getNode().equals(updatedNode)).findFirst();
|
||||
if(optEntry.isPresent()) {
|
||||
int index = rootEntry.getChildren().indexOf(optEntry.get());
|
||||
rootEntry.getChildren().set(index, nodeEntry);
|
||||
} else {
|
||||
rootEntry.getChildren().add(nodeEntry);
|
||||
}
|
||||
}
|
||||
|
||||
sort();
|
||||
}
|
||||
|
||||
public void updateLabel(Entry entry) {
|
||||
Entry rootEntry = getRoot().getValue();
|
||||
rootEntry.updateLabel(entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.chart.*;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class BalanceChart extends LineChart<Number, Number> {
|
||||
private XYChart.Series<Number, Number> balanceSeries;
|
||||
|
||||
private TransactionEntry selectedEntry;
|
||||
|
||||
public BalanceChart(@NamedArg("xAxis") Axis<Number> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
|
||||
super(xAxis, yAxis);
|
||||
}
|
||||
|
||||
public void initialize(WalletTransactionsEntry walletTransactionsEntry) {
|
||||
managedProperty().bind(visibleProperty());
|
||||
balanceSeries = new XYChart.Series<>();
|
||||
getData().add(balanceSeries);
|
||||
update(walletTransactionsEntry);
|
||||
|
||||
BitcoinUnit unit = Config.get().getBitcoinUnit();
|
||||
setBitcoinUnit(walletTransactionsEntry.getWallet(), unit);
|
||||
}
|
||||
|
||||
public void update(WalletTransactionsEntry walletTransactionsEntry) {
|
||||
setVisible(!walletTransactionsEntry.getChildren().isEmpty());
|
||||
balanceSeries.getData().clear();
|
||||
|
||||
List<Data<Number, Number>> balanceDataList = walletTransactionsEntry.getChildren().stream()
|
||||
.map(entry -> (TransactionEntry)entry)
|
||||
.filter(txEntry -> txEntry.getBlockTransaction().getHeight() > 0)
|
||||
.map(txEntry -> new XYChart.Data<>((Number)txEntry.getBlockTransaction().getDate().getTime(), (Number)txEntry.getBalance(), txEntry))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int size = balanceDataList.size() * 2;
|
||||
for(int i = 0; i < size; i+= 2) {
|
||||
Data<Number, Number> data = balanceDataList.get(i);
|
||||
|
||||
if(i + 1 < balanceDataList.size()) {
|
||||
Data<Number, Number> nextData = balanceDataList.get(i + 1);
|
||||
Data<Number, Number> interstitialData = new Data<>(nextData.getXValue(), data.getYValue(), null);
|
||||
balanceDataList.add(i + 1, interstitialData);
|
||||
} else {
|
||||
Date now = new Date();
|
||||
Data<Number, Number> interstitialData = new Data<>(now.getTime(), data.getYValue(), null);
|
||||
balanceDataList.add(interstitialData);
|
||||
}
|
||||
}
|
||||
|
||||
if(!balanceDataList.isEmpty()) {
|
||||
long min = balanceDataList.stream().map(data -> data.getXValue().longValue()).min(Long::compare).get();
|
||||
long max = balanceDataList.stream().map(data -> data.getXValue().longValue()).max(Long::compare).get();
|
||||
|
||||
DateAxisFormatter dateAxisFormatter = new DateAxisFormatter(max - min);
|
||||
NumberAxis xAxis = (NumberAxis)getXAxis();
|
||||
xAxis.setTickLabelFormatter(dateAxisFormatter);
|
||||
}
|
||||
|
||||
balanceSeries.getData().addAll(balanceDataList);
|
||||
|
||||
if(selectedEntry != null) {
|
||||
select(selectedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
public void select(TransactionEntry transactionEntry) {
|
||||
Set<Node> selectedSymbols = lookupAll(".chart-line-symbol.selected");
|
||||
for(Node selectedSymbol : selectedSymbols) {
|
||||
selectedSymbol.getStyleClass().remove("selected");
|
||||
}
|
||||
|
||||
for(int i = 0; i < balanceSeries.getData().size(); i++) {
|
||||
XYChart.Data<Number, Number> data = balanceSeries.getData().get(i);
|
||||
if(transactionEntry.getBlockTransaction().getDate() != null && data.getXValue().equals(transactionEntry.getBlockTransaction().getDate().getTime()) && data.getExtraValue() != null) {
|
||||
Node symbol = lookup(".chart-line-symbol.data" + i);
|
||||
if(symbol != null) {
|
||||
symbol.getStyleClass().add("selected");
|
||||
selectedEntry = transactionEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setBitcoinUnit(Wallet wallet, BitcoinUnit unit) {
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
unit = wallet.getAutoUnit();
|
||||
}
|
||||
|
||||
NumberAxis yaxis = (NumberAxis)getYAxis();
|
||||
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, unit));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.chart.Axis;
|
||||
import javafx.scene.chart.LineChart;
|
||||
import javafx.scene.chart.XYChart;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
public class BlockTargetFeeRatesChart extends LineChart<String, Number> {
|
||||
private XYChart.Series<String, Number> feeRateSeries;
|
||||
private Integer selectedTargetBlocks;
|
||||
|
||||
public BlockTargetFeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
|
||||
super(xAxis, yAxis);
|
||||
}
|
||||
|
||||
public void initialize() {
|
||||
feeRateSeries = new XYChart.Series<>();
|
||||
getData().add(feeRateSeries);
|
||||
}
|
||||
|
||||
public void update(Map<Integer, Double> targetBlocksFeeRates) {
|
||||
feeRateSeries.getData().clear();
|
||||
|
||||
for(Iterator<Integer> targetBlocksIter = targetBlocksFeeRates.keySet().iterator(); targetBlocksIter.hasNext(); ) {
|
||||
Integer targetBlocks = targetBlocksIter.next();
|
||||
String category = targetBlocks + (targetBlocksIter.hasNext() ? "" : "+");
|
||||
XYChart.Data<String, Number> data = new XYChart.Data<>(category, targetBlocksFeeRates.get(targetBlocks));
|
||||
feeRateSeries.getData().add(data);
|
||||
}
|
||||
|
||||
if(selectedTargetBlocks != null) {
|
||||
select(selectedTargetBlocks);
|
||||
}
|
||||
}
|
||||
|
||||
public void select(Integer targetBlocks) {
|
||||
Node selectedSymbol = lookup(".chart-line-symbol.selected");
|
||||
if(selectedSymbol != null) {
|
||||
selectedSymbol.getStyleClass().remove("selected");
|
||||
}
|
||||
|
||||
for(int i = 0; i < feeRateSeries.getData().size(); i++) {
|
||||
XYChart.Data<String, Number> data = feeRateSeries.getData().get(i);
|
||||
Node symbol = lookup(".chart-line-symbol.data" + i);
|
||||
if(symbol != null) {
|
||||
if(data.getXValue().replace("+", "").equals(targetBlocks.toString())) {
|
||||
symbol.getStyleClass().add("selected");
|
||||
selectedTargetBlocks = targetBlocks;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import javafx.scene.chart.NumberAxis;
|
||||
import javafx.util.StringConverter;
|
||||
|
||||
import java.text.ParseException;
|
||||
|
||||
final class CoinAxisFormatter extends StringConverter<Number> {
|
||||
private final BitcoinUnit bitcoinUnit;
|
||||
|
||||
public CoinAxisFormatter(NumberAxis axis, BitcoinUnit unit) {
|
||||
this.bitcoinUnit = unit;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(Number object) {
|
||||
Double value = bitcoinUnit.getValue(object.longValue());
|
||||
return CoinTextFormatter.COIN_FORMAT.format(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Number fromString(String string) {
|
||||
try {
|
||||
Number number = CoinTextFormatter.COIN_FORMAT.parse(string);
|
||||
return bitcoinUnit.getSatsValue(number.doubleValue());
|
||||
} catch (ParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
|
||||
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
||||
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
|
||||
import javafx.scene.control.ContentDisplay;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.control.TreeTableCell;
|
||||
import javafx.scene.layout.Region;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
class CoinCell extends TreeTableCell<Entry, Number> {
|
||||
private final Tooltip tooltip;
|
||||
|
||||
public CoinCell() {
|
||||
super();
|
||||
tooltip = new Tooltip();
|
||||
getStyleClass().add("coin-cell");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(Number amount, boolean empty) {
|
||||
super.updateItem(amount, empty);
|
||||
|
||||
if(empty || amount == null) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
} else {
|
||||
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
|
||||
EntryCell.applyRowStyles(this, entry);
|
||||
|
||||
CoinTreeTable coinTreeTable = (CoinTreeTable)getTreeTableView();
|
||||
BitcoinUnit unit = coinTreeTable.getBitcoinUnit();
|
||||
|
||||
String satsValue = String.format(Locale.ENGLISH, "%,d", amount.longValue());
|
||||
final String btcValue = CoinLabel.getBTCFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
|
||||
|
||||
if(unit.equals(BitcoinUnit.BTC)) {
|
||||
tooltip.setText(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
|
||||
setText(btcValue);
|
||||
} else {
|
||||
tooltip.setText(btcValue + " " + BitcoinUnit.BTC.getLabel());
|
||||
setText(satsValue);
|
||||
}
|
||||
setTooltip(tooltip);
|
||||
String tooltipValue = tooltip.getText();
|
||||
|
||||
if(entry instanceof TransactionEntry) {
|
||||
TransactionEntry transactionEntry = (TransactionEntry)entry;
|
||||
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
|
||||
|
||||
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
|
||||
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
|
||||
});
|
||||
|
||||
if(transactionEntry.isConfirming()) {
|
||||
ConfirmationProgressIndicator arc = new ConfirmationProgressIndicator(transactionEntry.getConfirmations());
|
||||
arc.confirmationsProperty().bind(transactionEntry.confirmationsProperty());
|
||||
setGraphic(arc);
|
||||
setContentDisplay(ContentDisplay.LEFT);
|
||||
} else {
|
||||
setGraphic(null);
|
||||
}
|
||||
} else if(entry instanceof UtxoEntry) {
|
||||
setGraphic(null);
|
||||
} else if(entry instanceof HashIndexEntry) {
|
||||
Region node = new Region();
|
||||
node.setPrefWidth(10);
|
||||
setGraphic(node);
|
||||
setContentDisplay(ContentDisplay.RIGHT);
|
||||
|
||||
if(((HashIndexEntry) entry).getType() == HashIndexEntry.Type.INPUT) {
|
||||
satsValue = "-" + satsValue;
|
||||
}
|
||||
} else {
|
||||
setGraphic(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import javafx.beans.property.LongProperty;
|
||||
import javafx.beans.property.SimpleLongProperty;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
@@ -14,13 +16,11 @@ import java.text.DecimalFormatSymbols;
|
||||
import java.util.Locale;
|
||||
|
||||
public class CoinLabel extends CopyableLabel {
|
||||
public static final int MAX_SATS_SHOWN = 1000000;
|
||||
public static final DecimalFormat BTC_FORMAT = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
|
||||
private DecimalFormat btcFormat = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
|
||||
private final LongProperty value = new SimpleLongProperty();
|
||||
private Tooltip tooltip;
|
||||
private CoinContextMenu contextMenu;
|
||||
private final LongProperty valueProperty = new SimpleLongProperty(-1);
|
||||
private final Tooltip tooltip;
|
||||
private final CoinContextMenu contextMenu;
|
||||
|
||||
public CoinLabel() {
|
||||
this("Unknown");
|
||||
@@ -28,31 +28,45 @@ public class CoinLabel extends CopyableLabel {
|
||||
|
||||
public CoinLabel(String text) {
|
||||
super(text);
|
||||
btcFormat.setMaximumFractionDigits(8);
|
||||
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue));
|
||||
BTC_FORMAT.setMaximumFractionDigits(8);
|
||||
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getBitcoinUnit()));
|
||||
tooltip = new Tooltip();
|
||||
contextMenu = new CoinContextMenu();
|
||||
}
|
||||
|
||||
public final LongProperty valueProperty() {
|
||||
return value;
|
||||
return valueProperty;
|
||||
}
|
||||
|
||||
public final long getValue() {
|
||||
return value.get();
|
||||
return valueProperty.get();
|
||||
}
|
||||
|
||||
public final void setValue(long value) {
|
||||
this.value.set(value);
|
||||
this.valueProperty.set(value);
|
||||
}
|
||||
|
||||
private void setValueAsText(Long value) {
|
||||
public void refresh() {
|
||||
refresh(Config.get().getBitcoinUnit());
|
||||
}
|
||||
|
||||
public void refresh(BitcoinUnit bitcoinUnit) {
|
||||
setValueAsText(getValue(), bitcoinUnit);
|
||||
}
|
||||
|
||||
private void setValueAsText(Long value, BitcoinUnit bitcoinUnit) {
|
||||
setTooltip(tooltip);
|
||||
setContextMenu(contextMenu);
|
||||
|
||||
String satsValue = String.format(Locale.ENGLISH, "%,d",value) + " sats";
|
||||
String btcValue = btcFormat.format(value.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC";
|
||||
if(value > MAX_SATS_SHOWN) {
|
||||
String satsValue = String.format(Locale.ENGLISH, "%,d", value) + " sats";
|
||||
String btcValue = BTC_FORMAT.format(value.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC";
|
||||
|
||||
BitcoinUnit unit = bitcoinUnit;
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
unit = (value >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS);
|
||||
}
|
||||
|
||||
if(unit.equals(BitcoinUnit.BTC)) {
|
||||
tooltip.setText(satsValue);
|
||||
setText(btcValue);
|
||||
} else {
|
||||
@@ -62,11 +76,8 @@ public class CoinLabel extends CopyableLabel {
|
||||
}
|
||||
|
||||
private class CoinContextMenu extends ContextMenu {
|
||||
private MenuItem copySatsValue;
|
||||
private MenuItem copyBtcValue;
|
||||
|
||||
public CoinContextMenu() {
|
||||
copySatsValue = new MenuItem("Copy Value in Satoshis");
|
||||
MenuItem copySatsValue = new MenuItem("Copy Value in Satoshis");
|
||||
copySatsValue.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
@@ -74,15 +85,20 @@ public class CoinLabel extends CopyableLabel {
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
copyBtcValue = new MenuItem("Copy Value in BTC");
|
||||
MenuItem copyBtcValue = new MenuItem("Copy Value in BTC");
|
||||
copyBtcValue.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(btcFormat.format((double)getValue() / Transaction.SATOSHIS_PER_BITCOIN));
|
||||
content.putString(BTC_FORMAT.format((double)getValue() / Transaction.SATOSHIS_PER_BITCOIN));
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
getItems().addAll(copySatsValue, copyBtcValue);
|
||||
}
|
||||
}
|
||||
|
||||
public static DecimalFormat getBTCFormat() {
|
||||
BTC_FORMAT.setMaximumFractionDigits(8);
|
||||
return BTC_FORMAT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.scene.control.TextFormatter;
|
||||
import javafx.scene.control.TextInputControl;
|
||||
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.text.ParseException;
|
||||
import java.util.Locale;
|
||||
import java.util.function.UnaryOperator;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class CoinTextFormatter extends TextFormatter<String> {
|
||||
private static final Pattern COIN_VALIDATION = Pattern.compile("[\\d,]*(\\.\\d{0,8})?");
|
||||
public static final DecimalFormat COIN_FORMAT = new DecimalFormat("###,###.########", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
|
||||
public CoinTextFormatter() {
|
||||
super(new CoinFilter());
|
||||
}
|
||||
|
||||
private static class CoinFilter implements UnaryOperator<Change> {
|
||||
@Override
|
||||
public Change apply(Change change) {
|
||||
String oldText = change.getControlText();
|
||||
String newText = change.getControlNewText();
|
||||
String deleted = null;
|
||||
if(change.isDeleted()) {
|
||||
deleted = oldText.substring(change.getRangeStart(), change.getRangeEnd());
|
||||
}
|
||||
|
||||
String noFractionCommaText = newText;
|
||||
int commasRemoved = 0;
|
||||
int dotIndex = newText.indexOf(".");
|
||||
if(dotIndex > -1) {
|
||||
noFractionCommaText = newText.substring(0, dotIndex) + newText.substring(dotIndex).replaceAll(",", "");
|
||||
commasRemoved = newText.length() - noFractionCommaText.length();
|
||||
}
|
||||
|
||||
if(!COIN_VALIDATION.matcher(noFractionCommaText).matches()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if(",".equals(change.getText())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if("".equals(newText)) {
|
||||
return change;
|
||||
}
|
||||
|
||||
if(change.isDeleted() && ",".equals(deleted) && change.getRangeStart() > 0) {
|
||||
noFractionCommaText = noFractionCommaText.substring(0, change.getRangeStart() - 1) + noFractionCommaText.substring(change.getRangeEnd() - 1);
|
||||
}
|
||||
|
||||
try {
|
||||
Number value = COIN_FORMAT.parse(noFractionCommaText);
|
||||
String correct = COIN_FORMAT.format(value.doubleValue());
|
||||
|
||||
String compare = newText;
|
||||
if(compare.contains(".") && compare.endsWith("0")) {
|
||||
compare = compare.replaceAll("0*$", "");
|
||||
}
|
||||
|
||||
if(compare.endsWith(".")) {
|
||||
compare = compare.substring(0, compare.length() - 1);
|
||||
}
|
||||
|
||||
if(correct.equals(compare)) {
|
||||
return change;
|
||||
}
|
||||
|
||||
if(value.doubleValue() == 0.0 && "0".equals(correct)) {
|
||||
return change;
|
||||
}
|
||||
|
||||
TextInputControl control = (TextInputControl)change.getControl();
|
||||
change.setText(correct);
|
||||
change.setRange(0, control.getLength());
|
||||
|
||||
if(correct.length() != newText.length()) {
|
||||
String postCorrect = correct.substring(Math.min(change.getCaretPosition(), correct.length()));
|
||||
int commasAfter = postCorrect.length() - postCorrect.replace(",", "").length();
|
||||
int caretShift = change.isDeleted() && ".".equals(deleted) ? commasAfter : 0;
|
||||
|
||||
int caret = change.getCaretPosition() + (correct.length() - newText.length() - caretShift) + commasRemoved;
|
||||
if(caret >= 0) {
|
||||
change.setCaretPosition(caret);
|
||||
change.setAnchor(caret);
|
||||
}
|
||||
}
|
||||
|
||||
return change;
|
||||
} catch (ParseException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletSettingsChangedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.net.ServerType;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Hyperlink;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TreeTableView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Optional;
|
||||
|
||||
public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
private BitcoinUnit bitcoinUnit;
|
||||
|
||||
public BitcoinUnit getBitcoinUnit() {
|
||||
return bitcoinUnit;
|
||||
}
|
||||
|
||||
public void setBitcoinUnit(BitcoinUnit bitcoinUnit) {
|
||||
this.bitcoinUnit = bitcoinUnit;
|
||||
}
|
||||
|
||||
public void setBitcoinUnit(Wallet wallet) {
|
||||
setBitcoinUnit(wallet, Config.get().getBitcoinUnit());
|
||||
}
|
||||
|
||||
public void setBitcoinUnit(Wallet wallet, BitcoinUnit unit) {
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
unit = wallet.getAutoUnit();
|
||||
}
|
||||
|
||||
boolean changed = (bitcoinUnit != unit);
|
||||
this.bitcoinUnit = unit;
|
||||
|
||||
if(changed && !getChildren().isEmpty()) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateHistoryStatus(WalletHistoryStatusEvent event) {
|
||||
if(getRoot() != null) {
|
||||
Entry entry = getRoot().getValue();
|
||||
if(entry != null && event.getWallet() != null && entry.getWallet() == event.getWallet()) {
|
||||
Platform.runLater(() -> {
|
||||
if(event.getErrorMessage() != null) {
|
||||
setPlaceholder(new Label("Error loading transactions: " + event.getErrorMessage()));
|
||||
} else if(event.isLoading()) {
|
||||
if(event.getStatusMessage() != null) {
|
||||
setPlaceholder(new Label(event.getStatusMessage() + "..."));
|
||||
} else {
|
||||
setPlaceholder(new Label("Loading transactions..."));
|
||||
}
|
||||
} else {
|
||||
setPlaceholder(getDefaultPlaceholder(event.getWallet()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected Node getDefaultPlaceholder(Wallet wallet) {
|
||||
StackPane stackPane = new StackPane();
|
||||
stackPane.getChildren().add(AppServices.isConnecting() ? new Label("Loading transactions...") : new Label("No transactions"));
|
||||
|
||||
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && !AppServices.isConnecting()) {
|
||||
Hyperlink hyperlink = new Hyperlink();
|
||||
hyperlink.setTranslateY(30);
|
||||
hyperlink.setOnAction(event -> {
|
||||
WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate());
|
||||
Optional<Date> optDate = dlg.showAndWait();
|
||||
if(optDate.isPresent()) {
|
||||
wallet.setBirthDate(optDate.get());
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
if(storage != null) {
|
||||
//Trigger background save of birthdate
|
||||
EventManager.get().post(new WalletDataChangedEvent(wallet));
|
||||
//Trigger full wallet rescan
|
||||
wallet.clearHistory();
|
||||
EventManager.get().post(new WalletSettingsChangedEvent(wallet, storage.getWalletFile()));
|
||||
}
|
||||
}
|
||||
});
|
||||
if(wallet.getBirthDate() == null) {
|
||||
hyperlink.setText("Scan for previous transactions?");
|
||||
} else {
|
||||
DateFormat dateFormat = new SimpleDateFormat(DateStringConverter.FORMAT_PATTERN);
|
||||
hyperlink.setText("Scan for transactions earlier than " + dateFormat.format(wallet.getBirthDate()) + "?");
|
||||
}
|
||||
|
||||
stackPane.getChildren().add(hyperlink);
|
||||
}
|
||||
|
||||
stackPane.setAlignment(Pos.CENTER);
|
||||
return stackPane;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
|
||||
import javafx.animation.*;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.Arc;
|
||||
import javafx.scene.shape.ArcType;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.shape.Line;
|
||||
import javafx.util.Duration;
|
||||
|
||||
public class ConfirmationProgressIndicator extends StackPane {
|
||||
private final Group confirmationGroup;
|
||||
private final Arc arc;
|
||||
private final Line downTickLine;
|
||||
private final Line upTickLine;
|
||||
|
||||
public ConfirmationProgressIndicator(int confirmations) {
|
||||
Circle circle = new Circle(7, 7, 7);
|
||||
circle.setFill(Color.TRANSPARENT);
|
||||
circle.getStyleClass().add("confirmation-progress-circle");
|
||||
|
||||
arc = new Arc(7, 7, 7, 7, 90, getDegrees(confirmations));
|
||||
arc.setType(ArcType.ROUND);
|
||||
arc.getStyleClass().add("confirmation-progress-arc");
|
||||
|
||||
downTickLine = new Line(4, 8, 4, 8);
|
||||
downTickLine.setStrokeWidth(1.0);
|
||||
downTickLine.setOpacity(0);
|
||||
downTickLine.getStyleClass().add("confirmation-progress-tick");
|
||||
upTickLine = new Line(6, 10, 6, 10);
|
||||
upTickLine.setStrokeWidth(1.0);
|
||||
upTickLine.setOpacity(0);
|
||||
upTickLine.getStyleClass().add("confirmation-progress-tick");
|
||||
|
||||
confirmationGroup = new Group(circle, arc, downTickLine, upTickLine);
|
||||
getStyleClass().add("confirmation-progress");
|
||||
|
||||
setAlignment(Pos.CENTER);
|
||||
getChildren().addAll(confirmationGroup);
|
||||
|
||||
confirmationsProperty().set(confirmations);
|
||||
confirmationsProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(!oldValue.equals(newValue)) {
|
||||
SequentialTransition sequence = new SequentialTransition();
|
||||
|
||||
Timeline arcLengthTimeline = new Timeline();
|
||||
KeyValue arcLengthValue = new KeyValue(arc.lengthProperty(), getDegrees(newValue.intValue()));
|
||||
KeyFrame arcLengthFrame = new KeyFrame(Duration.millis(3000), arcLengthValue);
|
||||
arcLengthTimeline.getKeyFrames().add(arcLengthFrame);
|
||||
sequence.getChildren().add(arcLengthTimeline);
|
||||
|
||||
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
|
||||
Timeline arcRadiusTimeline = new Timeline();
|
||||
KeyValue arcRadiusXValue = new KeyValue(arc.radiusXProperty(), 0.0);
|
||||
KeyValue arcRadiusYValue = new KeyValue(arc.radiusYProperty(), 0.0);
|
||||
KeyFrame arcRadiusFrame = new KeyFrame(Duration.millis(500), arcRadiusXValue, arcRadiusYValue);
|
||||
arcRadiusTimeline.getKeyFrames().add(arcRadiusFrame);
|
||||
sequence.getChildren().add(arcRadiusTimeline);
|
||||
|
||||
FadeTransition downTickFadeIn = new FadeTransition(Duration.millis(50), downTickLine);
|
||||
downTickFadeIn.setFromValue(0);
|
||||
downTickFadeIn.setToValue(1);
|
||||
sequence.getChildren().add(downTickFadeIn);
|
||||
|
||||
Timeline downTickLineTimeline = new Timeline();
|
||||
KeyValue downTickLineX = new KeyValue(downTickLine.endXProperty(), 6);
|
||||
KeyValue downTickLineY = new KeyValue(downTickLine.endYProperty(), 10);
|
||||
KeyFrame downTickLineFrame = new KeyFrame(Duration.millis(125), downTickLineX, downTickLineY);
|
||||
downTickLineTimeline.getKeyFrames().add(downTickLineFrame);
|
||||
sequence.getChildren().add(downTickLineTimeline);
|
||||
|
||||
FadeTransition upTickFadeIn = new FadeTransition(Duration.millis(50), upTickLine);
|
||||
upTickFadeIn.setFromValue(0);
|
||||
upTickFadeIn.setToValue(1);
|
||||
sequence.getChildren().add(upTickFadeIn);
|
||||
|
||||
Timeline upTickLineTimeline = new Timeline();
|
||||
KeyValue upTickLineX = new KeyValue(upTickLine.endXProperty(), 10);
|
||||
KeyValue upTickLineY = new KeyValue(upTickLine.endYProperty(), 4);
|
||||
KeyFrame upTickLineFrame = new KeyFrame(Duration.millis(250), upTickLineX, upTickLineY);
|
||||
upTickLineTimeline.getKeyFrames().add(upTickLineFrame);
|
||||
sequence.getChildren().add(upTickLineTimeline);
|
||||
|
||||
FadeTransition groupFadeOut = new FadeTransition(Duration.minutes(10), confirmationGroup);
|
||||
groupFadeOut.setFromValue(1);
|
||||
groupFadeOut.setToValue(0);
|
||||
sequence.getChildren().add(groupFadeOut);
|
||||
}
|
||||
|
||||
sequence.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static double getDegrees(int confirmations) {
|
||||
int requiredConfirmations = BlockTransactionHash.BLOCKS_TO_CONFIRM;
|
||||
return ((double)Math.min(confirmations, requiredConfirmations)/ requiredConfirmations) * -360d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the number of confirmations
|
||||
*/
|
||||
private IntegerProperty confirmations;
|
||||
|
||||
public final void setConfirmations(int value) {
|
||||
if(confirmations != null || value != 0) {
|
||||
confirmationsProperty().set(value);
|
||||
}
|
||||
}
|
||||
|
||||
public final int getConfirmations() {
|
||||
return confirmations == null ? 0 : confirmations.get();
|
||||
}
|
||||
|
||||
public final IntegerProperty confirmationsProperty() {
|
||||
if(confirmations == null) {
|
||||
confirmations = new IntegerPropertyBase(0) {
|
||||
|
||||
@Override
|
||||
public Object getBean() {
|
||||
return ConfirmationProgressIndicator.this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "confirmations";
|
||||
}
|
||||
};
|
||||
}
|
||||
return confirmations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.animation.FadeTransition;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.Observable;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.util.Duration;
|
||||
import org.controlsfx.control.textfield.CustomTextField;
|
||||
|
||||
public class CopyableTextField extends CustomTextField {
|
||||
private static final Duration FADE_DURATION = Duration.millis(350);
|
||||
|
||||
public CopyableTextField() {
|
||||
super();
|
||||
getStyleClass().add("copyable-text-field");
|
||||
setupCopyButtonField(super.rightProperty());
|
||||
}
|
||||
|
||||
private void setupCopyButtonField(ObjectProperty<Node> rightProperty) {
|
||||
Region copyButton = new Region();
|
||||
copyButton.getStyleClass().addAll("graphic"); //$NON-NLS-1$
|
||||
StackPane copyButtonPane = new StackPane(copyButton);
|
||||
copyButtonPane.getStyleClass().addAll("copy-button"); //$NON-NLS-1$
|
||||
copyButtonPane.setOpacity(0.0);
|
||||
copyButtonPane.setCursor(Cursor.DEFAULT);
|
||||
copyButtonPane.setOnMouseReleased(e -> {
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(getText());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
rightProperty.set(copyButtonPane);
|
||||
|
||||
final FadeTransition fader = new FadeTransition(FADE_DURATION, copyButtonPane);
|
||||
fader.setCycleCount(1);
|
||||
|
||||
textProperty().addListener(new InvalidationListener() {
|
||||
@Override
|
||||
public void invalidated(Observable arg0) {
|
||||
String text = getText();
|
||||
boolean isTextEmpty = text == null || text.isEmpty();
|
||||
boolean isButtonVisible = fader.getNode().getOpacity() > 0;
|
||||
|
||||
if (isTextEmpty && isButtonVisible) {
|
||||
setButtonVisible(false);
|
||||
} else if (!isTextEmpty && !isButtonVisible) {
|
||||
setButtonVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void setButtonVisible( boolean visible ) {
|
||||
fader.setFromValue(visible? 0.0: 1.0);
|
||||
fader.setToValue(visible? 1.0: 0.0);
|
||||
fader.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.util.StringConverter;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
public class DateAxisFormatter extends StringConverter<Number> {
|
||||
private static final DateFormat HOUR_FORMAT = new SimpleDateFormat("HH:mm");
|
||||
private static final DateFormat DAY_FORMAT = new SimpleDateFormat("d MMM");
|
||||
private static final DateFormat MONTH_FORMAT = new SimpleDateFormat("MMM yy");
|
||||
|
||||
private final DateFormat dateFormat;
|
||||
private int oddCounter;
|
||||
|
||||
public DateAxisFormatter(long duration) {
|
||||
if(duration < (24 * 60 * 60 * 1000L)) {
|
||||
dateFormat = HOUR_FORMAT;
|
||||
} else if(duration < (365 * 24 * 60 * 60 * 1000L)) {
|
||||
dateFormat = DAY_FORMAT;
|
||||
} else {
|
||||
dateFormat = MONTH_FORMAT;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(Number object) {
|
||||
oddCounter++;
|
||||
return oddCounter % 3 == 0 ? dateFormat.format(new Date(object.longValue())) : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Number fromString(String string) {
|
||||
try {
|
||||
Date date = dateFormat.parse(string);
|
||||
return date.getTime();
|
||||
} catch (ParseException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
|
||||
public class DateCell extends TreeTableCell<Entry, Entry> {
|
||||
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
|
||||
|
||||
public DateCell() {
|
||||
super();
|
||||
setAlignment(Pos.CENTER_LEFT);
|
||||
setContentDisplay(ContentDisplay.RIGHT);
|
||||
getStyleClass().add("date-cell");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(Entry entry, boolean empty) {
|
||||
super.updateItem(entry, empty);
|
||||
|
||||
EntryCell.applyRowStyles(this, entry);
|
||||
|
||||
if (empty) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
} else {
|
||||
if(entry instanceof UtxoEntry) {
|
||||
UtxoEntry utxoEntry = (UtxoEntry)entry;
|
||||
if(utxoEntry.getHashIndex().getHeight() <= 0) {
|
||||
setText("Unconfirmed " + (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)"));
|
||||
} else {
|
||||
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
|
||||
setText(date);
|
||||
setContextMenu(new DateContextMenu(date, utxoEntry.getHashIndex()));
|
||||
}
|
||||
|
||||
Tooltip tooltip = new Tooltip();
|
||||
int height = utxoEntry.getHashIndex().getHeight();
|
||||
tooltip.setText(height > 0 ? Integer.toString(height) : "Mempool");
|
||||
setTooltip(tooltip);
|
||||
}
|
||||
setGraphic(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static class DateContextMenu extends ContextMenu {
|
||||
public DateContextMenu(String date, BlockTransactionHashIndex reference) {
|
||||
MenuItem copyDate = new MenuItem("Copy Date");
|
||||
copyDate.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(date);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
MenuItem copyHeight = new MenuItem("Copy Block Height");
|
||||
copyHeight.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(reference.getHeight() > 0 ? Integer.toString(reference.getHeight()) : "Mempool");
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
getItems().addAll(copyDate, copyHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
public class DateLabel extends CopyableLabel {
|
||||
private static final DateFormat LAST_24_HR = new SimpleDateFormat("HH:mm");
|
||||
private static final DateFormat LAST_YEAR = new SimpleDateFormat("d MMM");
|
||||
private static final DateFormat ALL_TIME = new SimpleDateFormat("yyyy/MM/dd");
|
||||
|
||||
private Date date;
|
||||
|
||||
public DateLabel(Date date) {
|
||||
super(getShortDateFormat(date));
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
public static String getShortDateFormat(Date date) {
|
||||
Date now = new Date();
|
||||
long elapsed = (now.getTime() - date.getTime()) / 1000;
|
||||
|
||||
if(elapsed < 24 * 60 * 60) {
|
||||
return LAST_24_HR.format(date);
|
||||
} else if(elapsed < 365 * 24 * 60 * 60) {
|
||||
return LAST_YEAR.format(date);
|
||||
} else {
|
||||
return ALL_TIME.format(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.util.StringConverter;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public class DateStringConverter extends StringConverter<LocalDate> {
|
||||
public static final String FORMAT_PATTERN = "yyyy/MM/dd";
|
||||
public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(FORMAT_PATTERN);
|
||||
|
||||
@Override
|
||||
public String toString(LocalDate date) {
|
||||
if (date != null) {
|
||||
return DATE_FORMATTER.format(date);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalDate fromString(String string) {
|
||||
if (string != null && !string.isEmpty()) {
|
||||
return LocalDate.parse(string, DATE_FORMATTER);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import org.fxmisc.richtext.CodeArea;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static com.sparrowwallet.drongo.policy.PolicyType.MULTI;
|
||||
import static com.sparrowwallet.drongo.policy.PolicyType.SINGLE;
|
||||
import static com.sparrowwallet.drongo.protocol.ScriptType.MULTISIG;
|
||||
|
||||
public class DescriptorArea extends CodeArea {
|
||||
private Wallet wallet;
|
||||
|
||||
public void setWallet(Wallet wallet) {
|
||||
clear();
|
||||
this.wallet = wallet;
|
||||
|
||||
DescriptorContextMenu contextMenu = new DescriptorContextMenu(wallet, this);
|
||||
setContextMenu(contextMenu);
|
||||
|
||||
PolicyType policyType = wallet.getPolicyType();
|
||||
ScriptType scriptType = wallet.getScriptType();
|
||||
List<Keystore> keystores = wallet.getKeystores();
|
||||
int threshold = wallet.getDefaultPolicy().getNumSignaturesRequired();
|
||||
|
||||
if(SINGLE.equals(policyType)) {
|
||||
append(scriptType.getDescriptor(), "descriptor-text");
|
||||
replace(getLength(), getLength(), keystores.get(0).getScriptName(), List.of(keystores.get(0).isValid() ? "descriptor-text" : "descriptor-error", keystores.get(0).getScriptName()));
|
||||
append(scriptType.getCloseDescriptor(), "descriptor-text");
|
||||
}
|
||||
|
||||
if(MULTI.equals(policyType)) {
|
||||
append(scriptType.getDescriptor(), "descriptor-text");
|
||||
append(MULTISIG.getDescriptor(), "descriptor-text");
|
||||
append(Integer.toString(threshold), "descriptor-text");
|
||||
|
||||
for(Keystore keystore : keystores) {
|
||||
append(",", "descriptor-text");
|
||||
replace(getLength(), getLength(), keystore.getScriptName(), List.of(keystore.isValid() ? "descriptor-text" : "descriptor-error", keystore.getScriptName()));
|
||||
}
|
||||
|
||||
append(MULTISIG.getCloseDescriptor(), "descriptor-text");
|
||||
append(scriptType.getCloseDescriptor(), "descriptor-text");
|
||||
}
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
super.clear();
|
||||
this.wallet = null;
|
||||
setDisable(false);
|
||||
setContextMenu(null);
|
||||
}
|
||||
|
||||
private static class DescriptorContextMenu extends ContextMenu {
|
||||
public DescriptorContextMenu(Wallet wallet, DescriptorArea descriptorArea) {
|
||||
MenuItem copyvalue = new MenuItem("Copy Value");
|
||||
copyvalue.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(descriptorArea.getText());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
getItems().add(copyvalue);
|
||||
|
||||
MenuItem copyOutputDescriptor = new MenuItem("Copy Output Descriptor");
|
||||
copyOutputDescriptor.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(OutputDescriptor.getOutputDescriptor(wallet).toString(true));
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
getItems().add(copyOutputDescriptor);
|
||||
this.setStyle("-fx-background-color: -fx-color; -fx-font-family: System; -fx-font-size: 1em;");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.AddressDisplayedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class DeviceAddressDialog extends DeviceDialog<String> {
|
||||
private final Wallet wallet;
|
||||
private final KeyDerivation keyDerivation;
|
||||
|
||||
public DeviceAddressDialog(List<String> operationFingerprints, Wallet wallet, KeyDerivation keyDerivation) {
|
||||
super(operationFingerprints);
|
||||
this.wallet = wallet;
|
||||
this.keyDerivation = keyDerivation;
|
||||
|
||||
EventManager.get().register(this);
|
||||
setOnCloseRequest(event -> {
|
||||
EventManager.get().unregister(this);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected DevicePane getDevicePane(Device device) {
|
||||
return new DevicePane(wallet, keyDerivation, device);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void addressDisplayed(AddressDisplayedEvent event) {
|
||||
setResult(event.getAddress());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.UsbDeviceEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
import com.sparrowwallet.sparrow.io.Hwi;
|
||||
import javafx.application.Platform;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public abstract class DeviceDialog<R> extends Dialog<R> {
|
||||
private final List<String> operationFingerprints;
|
||||
private final Accordion deviceAccordion;
|
||||
private final VBox scanBox;
|
||||
private final Label scanLabel;
|
||||
|
||||
public DeviceDialog() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
public DeviceDialog(List<String> operationFingerprints) {
|
||||
this.operationFingerprints = operationFingerprints;
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
dialogPane.setContent(stackPane);
|
||||
|
||||
AnchorPane anchorPane = new AnchorPane();
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setPrefHeight(280);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
anchorPane.getChildren().add(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
AnchorPane.setLeftAnchor(scrollPane, 0.0);
|
||||
AnchorPane.setRightAnchor(scrollPane, 0.0);
|
||||
|
||||
deviceAccordion = new Accordion();
|
||||
scrollPane.setContent(deviceAccordion);
|
||||
|
||||
scanBox = new VBox();
|
||||
scanBox.setSpacing(30);
|
||||
scanBox.setAlignment(Pos.CENTER);
|
||||
Glyph usb = new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
|
||||
usb.setFontSize(50);
|
||||
scanLabel = new Label("Connect Hardware Wallet");
|
||||
Button button = new Button("Scan...");
|
||||
button.setPrefSize(120, 60);
|
||||
button.setOnAction(event -> {
|
||||
scan();
|
||||
});
|
||||
scanBox.getChildren().addAll(usb, scanLabel, button);
|
||||
scanBox.managedProperty().bind(scanBox.visibleProperty());
|
||||
|
||||
stackPane.getChildren().addAll(anchorPane, scanBox);
|
||||
|
||||
List<Device> devices = AppServices.getDevices();
|
||||
if(devices == null || devices.isEmpty()) {
|
||||
scanBox.setVisible(true);
|
||||
} else {
|
||||
Platform.runLater(() -> setDevices(devices));
|
||||
scanBox.setVisible(false);
|
||||
}
|
||||
|
||||
final ButtonType rescanButtonType = new javafx.scene.control.ButtonType("Rescan", ButtonBar.ButtonData.NO);
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(rescanButtonType, cancelButtonType);
|
||||
|
||||
Button rescanButton = (Button) dialogPane.lookupButton(rescanButtonType);
|
||||
rescanButton.managedProperty().bind(rescanButton.visibleProperty());
|
||||
rescanButton.visibleProperty().bind(scanBox.visibleProperty().not());
|
||||
rescanButton.addEventFilter(ActionEvent.ACTION, event -> {
|
||||
scan();
|
||||
event.consume();
|
||||
});
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(360);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton == cancelButtonType ? null : getResult());
|
||||
}
|
||||
|
||||
private void scan() {
|
||||
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
|
||||
enumerateService.setOnSucceeded(workerStateEvent -> {
|
||||
List<Device> devices = enumerateService.getValue();
|
||||
setDevices(devices);
|
||||
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
|
||||
});
|
||||
enumerateService.setOnFailed(workerStateEvent -> {
|
||||
scanBox.setVisible(true);
|
||||
scanLabel.setText(workerStateEvent.getSource().getException().getMessage());
|
||||
});
|
||||
enumerateService.start();
|
||||
}
|
||||
|
||||
protected void setDevices(List<Device> devices) {
|
||||
List<Device> dialogDevices = new ArrayList<>(devices);
|
||||
dialogDevices.removeIf(Objects::isNull);
|
||||
|
||||
if(operationFingerprints != null) {
|
||||
dialogDevices.removeIf(device -> {
|
||||
return device.getFingerprint() != null && !operationFingerprints.contains(device.getFingerprint()) && !(device.getNeedsPinSent() || device.getNeedsPassphraseSent());
|
||||
});
|
||||
}
|
||||
|
||||
deviceAccordion.getPanes().clear();
|
||||
|
||||
if(dialogDevices.isEmpty()) {
|
||||
scanBox.setVisible(true);
|
||||
scanLabel.setText("No matching devices found");
|
||||
} else {
|
||||
scanBox.setVisible(false);
|
||||
for(Device device : dialogDevices) {
|
||||
DevicePane devicePane = getDevicePane(device);
|
||||
deviceAccordion.getPanes().add(devicePane);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract DevicePane getDevicePane(Device device);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.AddressDisplayedEvent;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
import com.sparrowwallet.sparrow.event.MessageSignedEvent;
|
||||
import com.sparrowwallet.sparrow.event.PSBTSignedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
import com.sparrowwallet.sparrow.io.Hwi;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.*;
|
||||
import org.controlsfx.control.textfield.CustomPasswordField;
|
||||
import org.controlsfx.control.textfield.CustomTextField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class DevicePane extends TitledDescriptionPane {
|
||||
private static final Logger log = LoggerFactory.getLogger(DevicePane.class);
|
||||
|
||||
private final DeviceOperation deviceOperation;
|
||||
private final Wallet wallet;
|
||||
private final PSBT psbt;
|
||||
private final KeyDerivation keyDerivation;
|
||||
private final String message;
|
||||
private final Device device;
|
||||
|
||||
private CustomPasswordField pinField;
|
||||
private Button unlockButton;
|
||||
private Button enterPinButton;
|
||||
private Button setPassphraseButton;
|
||||
private SplitMenuButton importButton;
|
||||
private Button signButton;
|
||||
private Button displayAddressButton;
|
||||
private Button signMessageButton;
|
||||
|
||||
private final SimpleStringProperty passphrase = new SimpleStringProperty("");
|
||||
|
||||
public DevicePane(Wallet wallet, Device device) {
|
||||
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
|
||||
this.deviceOperation = DeviceOperation.IMPORT;
|
||||
this.wallet = wallet;
|
||||
this.psbt = null;
|
||||
this.keyDerivation = null;
|
||||
this.message = null;
|
||||
this.device = device;
|
||||
|
||||
setDefaultStatus();
|
||||
showHideLink.setVisible(false);
|
||||
|
||||
createSetPassphraseButton();
|
||||
createImportButton();
|
||||
|
||||
initialise(device);
|
||||
|
||||
buttonBox.getChildren().addAll(setPassphraseButton, importButton);
|
||||
}
|
||||
|
||||
public DevicePane(PSBT psbt, Device device) {
|
||||
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
|
||||
this.deviceOperation = DeviceOperation.SIGN;
|
||||
this.wallet = null;
|
||||
this.psbt = psbt;
|
||||
this.keyDerivation = null;
|
||||
this.message = null;
|
||||
this.device = device;
|
||||
|
||||
setDefaultStatus();
|
||||
showHideLink.setVisible(false);
|
||||
|
||||
createSetPassphraseButton();
|
||||
createSignButton();
|
||||
|
||||
initialise(device);
|
||||
|
||||
buttonBox.getChildren().addAll(setPassphraseButton, signButton);
|
||||
}
|
||||
|
||||
public DevicePane(Wallet wallet, KeyDerivation keyDerivation, Device device) {
|
||||
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
|
||||
this.deviceOperation = DeviceOperation.DISPLAY_ADDRESS;
|
||||
this.wallet = wallet;
|
||||
this.psbt = null;
|
||||
this.keyDerivation = keyDerivation;
|
||||
this.message = null;
|
||||
this.device = device;
|
||||
|
||||
setDefaultStatus();
|
||||
showHideLink.setVisible(false);
|
||||
|
||||
createSetPassphraseButton();
|
||||
createDisplayAddressButton();
|
||||
|
||||
initialise(device);
|
||||
|
||||
buttonBox.getChildren().addAll(setPassphraseButton, displayAddressButton);
|
||||
}
|
||||
|
||||
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device) {
|
||||
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
|
||||
this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
|
||||
this.wallet = wallet;
|
||||
this.psbt = null;
|
||||
this.keyDerivation = keyDerivation;
|
||||
this.message = message;
|
||||
this.device = device;
|
||||
|
||||
setDefaultStatus();
|
||||
showHideLink.setVisible(false);
|
||||
|
||||
createSetPassphraseButton();
|
||||
createSignMessageButton();
|
||||
|
||||
initialise(device);
|
||||
|
||||
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
|
||||
}
|
||||
|
||||
private void initialise(Device device) {
|
||||
if(device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
|
||||
unlockButton.setVisible(true);
|
||||
} else if(device.getNeedsPassphraseSent() != null && device.getNeedsPassphraseSent()) {
|
||||
setPassphraseButton.setVisible(true);
|
||||
} else if(device.getError() != null) {
|
||||
setError("Error", device.getError());
|
||||
Platform.runLater(() -> {
|
||||
setExpanded(true);
|
||||
});
|
||||
} else {
|
||||
showOperationButton();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Control createButton() {
|
||||
createUnlockButton();
|
||||
return unlockButton;
|
||||
}
|
||||
|
||||
private void setDefaultStatus() {
|
||||
setDescription(device.getNeedsPinSent() ? "Locked" : device.getNeedsPassphraseSent() ? "Passphrase Required" : "Unlocked");
|
||||
}
|
||||
|
||||
private void createUnlockButton() {
|
||||
unlockButton = new Button("Unlock");
|
||||
unlockButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
unlockButton.setOnAction(event -> {
|
||||
unlockButton.setDisable(true);
|
||||
unlock(device);
|
||||
});
|
||||
unlockButton.managedProperty().bind(unlockButton.visibleProperty());
|
||||
unlockButton.setVisible(false);
|
||||
}
|
||||
|
||||
private void createSetPassphraseButton() {
|
||||
setPassphraseButton = new Button("Set Passphrase");
|
||||
setPassphraseButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
setPassphraseButton.setOnAction(event -> {
|
||||
setPassphraseButton.setDisable(true);
|
||||
setContent(getPassphraseEntry());
|
||||
setExpanded(true);
|
||||
});
|
||||
setPassphraseButton.managedProperty().bind(setPassphraseButton.visibleProperty());
|
||||
setPassphraseButton.setVisible(false);
|
||||
}
|
||||
|
||||
private void createImportButton() {
|
||||
importButton = new SplitMenuButton();
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setText("Import Keystore");
|
||||
importButton.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(wallet.getScriptType().getDefaultDerivation());
|
||||
});
|
||||
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
|
||||
item.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(derivation);
|
||||
});
|
||||
importButton.getItems().add(item);
|
||||
}
|
||||
importButton.managedProperty().bind(importButton.visibleProperty());
|
||||
importButton.setVisible(false);
|
||||
}
|
||||
|
||||
private void createSignButton() {
|
||||
signButton = new Button("Sign");
|
||||
signButton.setDefaultButton(true);
|
||||
signButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
signButton.setMinWidth(44);
|
||||
signButton.setOnAction(event -> {
|
||||
signButton.setDisable(true);
|
||||
sign();
|
||||
});
|
||||
signButton.managedProperty().bind(signButton.visibleProperty());
|
||||
signButton.setVisible(false);
|
||||
}
|
||||
|
||||
private void createDisplayAddressButton() {
|
||||
displayAddressButton = new Button("Display Address");
|
||||
displayAddressButton.setDefaultButton(true);
|
||||
displayAddressButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
displayAddressButton.setOnAction(event -> {
|
||||
displayAddressButton.setDisable(true);
|
||||
displayAddress();
|
||||
});
|
||||
displayAddressButton.managedProperty().bind(displayAddressButton.visibleProperty());
|
||||
displayAddressButton.setVisible(false);
|
||||
|
||||
if(device.getFingerprint() != null && !device.getFingerprint().equals(keyDerivation.getMasterFingerprint())) {
|
||||
displayAddressButton.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void createSignMessageButton() {
|
||||
signMessageButton = new Button("Sign Message");
|
||||
signMessageButton.setDefaultButton(true);
|
||||
signMessageButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
signMessageButton.setOnAction(event -> {
|
||||
signMessageButton.setDisable(true);
|
||||
signMessage();
|
||||
});
|
||||
signMessageButton.managedProperty().bind(signMessageButton.visibleProperty());
|
||||
signMessageButton.setVisible(false);
|
||||
|
||||
if(device.getFingerprint() != null && !device.getFingerprint().equals(keyDerivation.getMasterFingerprint())) {
|
||||
signMessageButton.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void unlock(Device device) {
|
||||
if(device.getModel().requiresPinPrompt()) {
|
||||
promptPin();
|
||||
}
|
||||
}
|
||||
|
||||
private Node getPinEntry() {
|
||||
VBox vBox = new VBox();
|
||||
vBox.setMaxHeight(120);
|
||||
vBox.setSpacing(42);
|
||||
pinField = (CustomPasswordField)TextFields.createClearablePasswordField();
|
||||
enterPinButton = new Button("Enter PIN");
|
||||
enterPinButton.setOnAction(event -> {
|
||||
enterPinButton.setDisable(true);
|
||||
sendPin(pinField.getText());
|
||||
});
|
||||
vBox.getChildren().addAll(pinField, enterPinButton);
|
||||
|
||||
TilePane tilePane = new TilePane();
|
||||
tilePane.setPrefColumns(3);
|
||||
tilePane.setHgap(10);
|
||||
tilePane.setVgap(10);
|
||||
tilePane.setMaxWidth(150);
|
||||
tilePane.setMaxHeight(120);
|
||||
|
||||
int[] digits = new int[] {7, 8, 9, 4, 5, 6, 1, 2, 3};
|
||||
for(int i = 0; i < digits.length; i++) {
|
||||
Button pinButton = new Button();
|
||||
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, "CIRCLE");
|
||||
pinButton.setGraphic(circle);
|
||||
pinButton.setUserData(digits[i]);
|
||||
tilePane.getChildren().add(pinButton);
|
||||
pinButton.setOnAction(event -> {
|
||||
pinField.setText(pinField.getText() + pinButton.getUserData());
|
||||
});
|
||||
}
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setSpacing(50);
|
||||
contentBox.getChildren().add(tilePane);
|
||||
contentBox.getChildren().add(vBox);
|
||||
contentBox.setPadding(new Insets(10, 0, 10, 0));
|
||||
contentBox.setAlignment(Pos.TOP_CENTER);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private Node getPassphraseEntry() {
|
||||
CustomTextField passphraseField = (CustomTextField)TextFields.createClearableTextField();
|
||||
passphrase.bind(passphraseField.textProperty());
|
||||
HBox.setHgrow(passphraseField, Priority.ALWAYS);
|
||||
|
||||
Button sendPassphraseButton = new Button("Send Passphrase");
|
||||
sendPassphraseButton.setOnAction(event -> {
|
||||
setExpanded(false);
|
||||
sendPassphrase(passphrase.get());
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setAlignment(Pos.TOP_RIGHT);
|
||||
contentBox.setSpacing(20);
|
||||
contentBox.getChildren().add(passphraseField);
|
||||
contentBox.getChildren().add(sendPassphraseButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
|
||||
Platform.runLater(passphraseField::requestFocus);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private void promptPin() {
|
||||
Hwi.PromptPinService promptPinService = new Hwi.PromptPinService(device);
|
||||
promptPinService.setOnSucceeded(workerStateEvent -> {
|
||||
Boolean result = promptPinService.getValue();
|
||||
if(result) {
|
||||
setContent(getPinEntry());
|
||||
setExpanded(true);
|
||||
} else {
|
||||
setError("Could not request PIN", null);
|
||||
unlockButton.setDisable(false);
|
||||
}
|
||||
});
|
||||
promptPinService.setOnFailed(workerStateEvent -> {
|
||||
setError(promptPinService.getException().getMessage(), null);
|
||||
unlockButton.setDisable(false);
|
||||
});
|
||||
promptPinService.start();
|
||||
}
|
||||
|
||||
private void sendPin(String pin) {
|
||||
Hwi.SendPinService sendPinService = new Hwi.SendPinService(device, pin);
|
||||
sendPinService.setOnSucceeded(workerStateEvent -> {
|
||||
Boolean result = sendPinService.getValue();
|
||||
if(result) {
|
||||
device.setNeedsPinSent(false);
|
||||
setDefaultStatus();
|
||||
setExpanded(false);
|
||||
unlockButton.setVisible(false);
|
||||
|
||||
if(device.getNeedsPassphraseSent()) {
|
||||
setPassphraseButton.setVisible(true);
|
||||
setPassphraseButton.setDisable(true);
|
||||
setContent(getPassphraseEntry());
|
||||
setExpanded(true);
|
||||
} else {
|
||||
showOperationButton();
|
||||
}
|
||||
} else {
|
||||
setError("Incorrect PIN", null);
|
||||
unlockButton.setDisable(false);
|
||||
if(pinField != null) {
|
||||
pinField.setText("");
|
||||
}
|
||||
}
|
||||
});
|
||||
sendPinService.setOnFailed(workerStateEvent -> {
|
||||
setError(sendPinService.getException().getMessage(), null);
|
||||
enterPinButton.setDisable(false);
|
||||
});
|
||||
setDescription("Unlocking...");
|
||||
showHideLink.setVisible(false);
|
||||
sendPinService.start();
|
||||
}
|
||||
|
||||
private void sendPassphrase(String passphrase) {
|
||||
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(passphrase);
|
||||
enumerateService.setOnSucceeded(workerStateEvent -> {
|
||||
List<Device> devices = enumerateService.getValue();
|
||||
for (Device freshDevice : devices) {
|
||||
if (device.getPath().equals(freshDevice.getPath()) && device.getModel().equals(freshDevice.getModel())) {
|
||||
device.setFingerprint(freshDevice.getFingerprint());
|
||||
}
|
||||
}
|
||||
|
||||
if(device.getFingerprint() != null) {
|
||||
setPassphraseButton.setVisible(false);
|
||||
setDescription("Passphrase sent");
|
||||
showOperationButton();
|
||||
} else {
|
||||
setError("Passphrase send failed", null);
|
||||
setPassphraseButton.setDisable(false);
|
||||
setPassphraseButton.setVisible(true);
|
||||
}
|
||||
});
|
||||
enumerateService.setOnFailed(workerStateEvent -> {
|
||||
setError(enumerateService.getException().getMessage(), null);
|
||||
setPassphraseButton.setDisable(false);
|
||||
setPassphraseButton.setVisible(true);
|
||||
});
|
||||
enumerateService.start();
|
||||
}
|
||||
|
||||
private void importKeystore(List<ChildNumber> derivation) {
|
||||
if(device.getFingerprint() == null) {
|
||||
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(passphrase.get());
|
||||
enumerateService.setOnSucceeded(workerStateEvent -> {
|
||||
List<Device> devices = enumerateService.getValue();
|
||||
for (Device freshDevice : devices) {
|
||||
if (device.getPath().equals(freshDevice.getPath()) && device.getModel().equals(freshDevice.getModel())) {
|
||||
device.setFingerprint(freshDevice.getFingerprint());
|
||||
}
|
||||
}
|
||||
|
||||
importXpub(derivation);
|
||||
});
|
||||
enumerateService.setOnFailed(workerStateEvent -> {
|
||||
setError(enumerateService.getException().getMessage(), null);
|
||||
importButton.setDisable(false);
|
||||
});
|
||||
enumerateService.start();
|
||||
} else {
|
||||
importXpub(derivation);
|
||||
}
|
||||
}
|
||||
|
||||
private void importXpub(List<ChildNumber> derivation) {
|
||||
String derivationPath = KeyDerivation.writePath(derivation);
|
||||
|
||||
Hwi.GetXpubService getXpubService = new Hwi.GetXpubService(device, passphrase.get(), derivationPath);
|
||||
getXpubService.setOnSucceeded(workerStateEvent -> {
|
||||
String xpub = getXpubService.getValue();
|
||||
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel(device.getModel().toDisplayString());
|
||||
keystore.setSource(KeystoreSource.HW_USB);
|
||||
keystore.setWalletModel(device.getModel());
|
||||
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath));
|
||||
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
|
||||
|
||||
EventManager.get().post(new KeystoreImportEvent(keystore));
|
||||
});
|
||||
getXpubService.setOnFailed(workerStateEvent -> {
|
||||
setError("Could not retrieve xpub", getXpubService.getException().getMessage());
|
||||
importButton.setDisable(false);
|
||||
});
|
||||
setDescription("Importing...");
|
||||
showHideLink.setVisible(false);
|
||||
getXpubService.start();
|
||||
}
|
||||
|
||||
private void sign() {
|
||||
Hwi.SignPSBTService signPSBTService = new Hwi.SignPSBTService(device, passphrase.get(), psbt);
|
||||
signPSBTService.setOnSucceeded(workerStateEvent -> {
|
||||
PSBT signedPsbt = signPSBTService.getValue();
|
||||
EventManager.get().post(new PSBTSignedEvent(psbt, signedPsbt));
|
||||
});
|
||||
signPSBTService.setOnFailed(workerStateEvent -> {
|
||||
setError("Signing Error", signPSBTService.getException().getMessage());
|
||||
log.error("Signing Error: " + signPSBTService.getException().getMessage());
|
||||
signButton.setDisable(false);
|
||||
});
|
||||
setDescription("Signing...");
|
||||
showHideLink.setVisible(false);
|
||||
signPSBTService.start();
|
||||
}
|
||||
|
||||
private void displayAddress() {
|
||||
Hwi.DisplayAddressService displayAddressService = new Hwi.DisplayAddressService(device, passphrase.get(), wallet.getScriptType(), keyDerivation.getDerivationPath());
|
||||
displayAddressService.setOnSucceeded(successEvent -> {
|
||||
String address = displayAddressService.getValue();
|
||||
EventManager.get().post(new AddressDisplayedEvent(address));
|
||||
});
|
||||
displayAddressService.setOnFailed(failedEvent -> {
|
||||
setError("Could not display address", displayAddressService.getException().getMessage());
|
||||
displayAddressButton.setDisable(false);
|
||||
});
|
||||
setDescription("Check device for address");
|
||||
displayAddressService.start();
|
||||
}
|
||||
|
||||
private void signMessage() {
|
||||
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath());
|
||||
signMessageService.setOnSucceeded(successEvent -> {
|
||||
String signature = signMessageService.getValue();
|
||||
EventManager.get().post(new MessageSignedEvent(wallet, signature));
|
||||
});
|
||||
signMessageService.setOnFailed(failedEvent -> {
|
||||
setError("Could not sign message", signMessageService.getException().getMessage());
|
||||
signMessageButton.setDisable(false);
|
||||
});
|
||||
setDescription("Signing message...");
|
||||
signMessageService.start();
|
||||
}
|
||||
|
||||
private void showOperationButton() {
|
||||
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
|
||||
importButton.setVisible(true);
|
||||
showHideLink.setText("Show derivation...");
|
||||
showHideLink.setVisible(true);
|
||||
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
|
||||
} else if(deviceOperation.equals(DeviceOperation.SIGN)) {
|
||||
signButton.setVisible(true);
|
||||
showHideLink.setVisible(false);
|
||||
} else if(deviceOperation.equals(DeviceOperation.DISPLAY_ADDRESS)) {
|
||||
displayAddressButton.setVisible(true);
|
||||
showHideLink.setVisible(false);
|
||||
} else if(deviceOperation.equals(DeviceOperation.SIGN_MESSAGE)) {
|
||||
signMessageButton.setVisible(true);
|
||||
showHideLink.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Node getDerivationEntry(List<ChildNumber> derivation) {
|
||||
TextField derivationField = new TextField();
|
||||
derivationField.setPromptText("Derivation path");
|
||||
derivationField.setText(KeyDerivation.writePath(derivation));
|
||||
HBox.setHgrow(derivationField, Priority.ALWAYS);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
validationSupport.registerValidator(derivationField, Validator.combine(
|
||||
Validator.createEmptyValidator("Derivation is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
|
||||
));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
|
||||
Button importDerivationButton = new Button("Import");
|
||||
importDerivationButton.setOnAction(event -> {
|
||||
showHideLink.setVisible(true);
|
||||
setExpanded(false);
|
||||
List<ChildNumber> importDerivation = KeyDerivation.parsePath(derivationField.getText());
|
||||
importXpub(importDerivation);
|
||||
});
|
||||
|
||||
derivationField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue));
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setAlignment(Pos.TOP_RIGHT);
|
||||
contentBox.setSpacing(20);
|
||||
contentBox.getChildren().add(derivationField);
|
||||
contentBox.getChildren().add(importDerivationButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
contentBox.setPrefHeight(60);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
public enum DeviceOperation {
|
||||
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.PSBTSignedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class DeviceSignDialog extends DeviceDialog<PSBT> {
|
||||
private final PSBT psbt;
|
||||
|
||||
public DeviceSignDialog(List<String> operationFingerprints, PSBT psbt) {
|
||||
super(operationFingerprints);
|
||||
this.psbt = psbt;
|
||||
EventManager.get().register(this);
|
||||
setOnCloseRequest(event -> {
|
||||
EventManager.get().unregister(this);
|
||||
});
|
||||
setResultConverter(dialogButton -> dialogButton.getButtonData().isCancelButton() ? null : psbt);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected DevicePane getDevicePane(Device device) {
|
||||
return new DevicePane(psbt, device);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void psbtSigned(PSBTSignedEvent event) {
|
||||
if(psbt == event.getPsbt()) {
|
||||
setResult(event.getSignedPsbt());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.MessageSignedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class DeviceSignMessageDialog extends DeviceDialog<String> {
|
||||
private final Wallet wallet;
|
||||
private final String message;
|
||||
private final KeyDerivation keyDerivation;
|
||||
|
||||
public DeviceSignMessageDialog(List<String> operationFingerprints, Wallet wallet, String message, KeyDerivation keyDerivation) {
|
||||
super(operationFingerprints);
|
||||
this.wallet = wallet;
|
||||
this.message = message;
|
||||
this.keyDerivation = keyDerivation;
|
||||
EventManager.get().register(this);
|
||||
setOnCloseRequest(event -> {
|
||||
EventManager.get().unregister(this);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected DevicePane getDevicePane(Device device) {
|
||||
return new DevicePane(wallet, message, keyDerivation, device);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void messageSigned(MessageSignedEvent event) {
|
||||
if(wallet == event.getWallet()) {
|
||||
setResult(event.getSignature());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import tornadofx.control.Form;
|
||||
|
||||
public class DynamicForm extends Form {
|
||||
private DynamicUpdate dynamicUpdate;
|
||||
|
||||
public DynamicForm() {
|
||||
super();
|
||||
}
|
||||
|
||||
public void setDynamicUpdate(DynamicUpdate dynamicUpdate) {
|
||||
this.dynamicUpdate = dynamicUpdate;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
if(dynamicUpdate != null) {
|
||||
dynamicUpdate.update();
|
||||
}
|
||||
|
||||
super.layoutChildren();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
public interface DynamicUpdate {
|
||||
void update();
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionOutput;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.wallet.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.controlsfx.glyphfont.FontAwesome;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
|
||||
|
||||
public EntryCell() {
|
||||
super();
|
||||
setAlignment(Pos.CENTER_LEFT);
|
||||
setContentDisplay(ContentDisplay.RIGHT);
|
||||
getStyleClass().add("entry-cell");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(Entry entry, boolean empty) {
|
||||
super.updateItem(entry, empty);
|
||||
|
||||
applyRowStyles(this, entry);
|
||||
|
||||
if(empty) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
} else {
|
||||
if(entry instanceof TransactionEntry) {
|
||||
TransactionEntry transactionEntry = (TransactionEntry)entry;
|
||||
if(transactionEntry.getBlockTransaction().getHeight() == -1) {
|
||||
setText("Unconfirmed Parent");
|
||||
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry));
|
||||
} else if(transactionEntry.getBlockTransaction().getHeight() == 0) {
|
||||
setText("Unconfirmed");
|
||||
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry));
|
||||
} else {
|
||||
String date = DATE_FORMAT.format(transactionEntry.getBlockTransaction().getDate());
|
||||
setText(date);
|
||||
setContextMenu(new TransactionContextMenu(date, transactionEntry.getBlockTransaction()));
|
||||
}
|
||||
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setText(transactionEntry.getBlockTransaction().getHash().toString());
|
||||
setTooltip(tooltip);
|
||||
|
||||
HBox actionBox = new HBox();
|
||||
Button viewTransactionButton = new Button("");
|
||||
Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH);
|
||||
searchGlyph.setFontSize(12);
|
||||
viewTransactionButton.setGraphic(searchGlyph);
|
||||
viewTransactionButton.setOnAction(event -> {
|
||||
EventManager.get().post(new ViewTransactionEvent(this.getScene().getWindow(), transactionEntry.getBlockTransaction()));
|
||||
});
|
||||
actionBox.getChildren().add(viewTransactionButton);
|
||||
|
||||
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
|
||||
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
Button increaseFeeButton = new Button("");
|
||||
Glyph increaseFeeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING_MEDICAL);
|
||||
increaseFeeGlyph.setFontSize(12);
|
||||
increaseFeeButton.setGraphic(increaseFeeGlyph);
|
||||
increaseFeeButton.setOnAction(event -> {
|
||||
increaseFee(transactionEntry);
|
||||
});
|
||||
actionBox.getChildren().add(increaseFeeButton);
|
||||
}
|
||||
|
||||
setGraphic(actionBox);
|
||||
} else if(entry instanceof NodeEntry) {
|
||||
NodeEntry nodeEntry = (NodeEntry)entry;
|
||||
Address address = nodeEntry.getAddress();
|
||||
setText(address.toString());
|
||||
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), null));
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setText(nodeEntry.getNode().getDerivationPath().replace("m", ".."));
|
||||
setTooltip(tooltip);
|
||||
getStyleClass().add("address-cell");
|
||||
|
||||
HBox actionBox = new HBox();
|
||||
Button receiveButton = new Button("");
|
||||
Glyph receiveGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.ARROW_DOWN);
|
||||
receiveGlyph.setFontSize(12);
|
||||
receiveButton.setGraphic(receiveGlyph);
|
||||
receiveButton.setOnAction(event -> {
|
||||
EventManager.get().post(new ReceiveActionEvent(nodeEntry));
|
||||
Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry)));
|
||||
});
|
||||
actionBox.getChildren().add(receiveButton);
|
||||
|
||||
if(nodeEntry.getWallet().getKeystores().size() == 1 &&
|
||||
(nodeEntry.getWallet().getKeystores().get(0).hasSeed() || nodeEntry.getWallet().getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
|
||||
Button signMessageButton = new Button("");
|
||||
Glyph signMessageGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
|
||||
signMessageGlyph.setFontSize(12);
|
||||
signMessageButton.setGraphic(signMessageGlyph);
|
||||
signMessageButton.setOnAction(event -> {
|
||||
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
|
||||
messageSignDialog.showAndWait();
|
||||
});
|
||||
actionBox.getChildren().add(signMessageButton);
|
||||
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry));
|
||||
}
|
||||
|
||||
setGraphic(actionBox);
|
||||
} else if(entry instanceof HashIndexEntry) {
|
||||
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
|
||||
setText(hashIndexEntry.getDescription());
|
||||
setContextMenu(new HashIndexEntryContextMenu(getTreeTableView(), hashIndexEntry));
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setText(hashIndexEntry.getHashIndex().toString());
|
||||
setTooltip(tooltip);
|
||||
|
||||
HBox actionBox = new HBox();
|
||||
Button viewTransactionButton = new Button("");
|
||||
Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH);
|
||||
searchGlyph.setFontSize(12);
|
||||
viewTransactionButton.setGraphic(searchGlyph);
|
||||
viewTransactionButton.setOnAction(event -> {
|
||||
EventManager.get().post(new ViewTransactionEvent(this.getScene().getWindow(), hashIndexEntry.getBlockTransaction(), hashIndexEntry));
|
||||
});
|
||||
actionBox.getChildren().add(viewTransactionButton);
|
||||
|
||||
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && hashIndexEntry.isSpendable() && !hashIndexEntry.getHashIndex().isSpent()) {
|
||||
Button spendUtxoButton = new Button("");
|
||||
Glyph sendGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
|
||||
sendGlyph.setFontSize(12);
|
||||
spendUtxoButton.setGraphic(sendGlyph);
|
||||
spendUtxoButton.setOnAction(event -> {
|
||||
sendSelectedUtxos(getTreeTableView(), hashIndexEntry);
|
||||
});
|
||||
actionBox.getChildren().add(spendUtxoButton);
|
||||
}
|
||||
|
||||
setGraphic(actionBox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void increaseFee(TransactionEntry transactionEntry) {
|
||||
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
|
||||
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
|
||||
List<BlockTransactionHashIndex> utxos = transactionEntry.getChildren().stream()
|
||||
.filter(e -> e instanceof HashIndexEntry)
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
|
||||
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
|
||||
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<TransactionOutput> ourOutputs = transactionEntry.getChildren().stream()
|
||||
.filter(e -> e instanceof HashIndexEntry)
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
|
||||
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
|
||||
Transaction tx = blockTransaction.getTransaction();
|
||||
int vSize = tx.getVirtualSize();
|
||||
int inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
|
||||
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(transactionEntry.getWallet().getWalletUtxos().keySet());
|
||||
Collections.shuffle(walletUtxos);
|
||||
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty()) {
|
||||
//If there is insufficent change output, include another random UTXO so the fee can be increased
|
||||
BlockTransactionHashIndex utxo = walletUtxos.remove(0);
|
||||
utxos.add(utxo);
|
||||
changeTotal += utxo.getValue();
|
||||
vSize += inputSize;
|
||||
}
|
||||
|
||||
List<TransactionOutput> externalOutputs = new ArrayList<>(blockTransaction.getTransaction().getOutputs());
|
||||
externalOutputs.removeAll(ourOutputs);
|
||||
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
|
||||
try {
|
||||
return new Payment(txOutput.getScript().getToAddresses()[0], transactionEntry.getLabel(), txOutput.getValue(), false);
|
||||
} catch(Exception e) {
|
||||
return null;
|
||||
}
|
||||
}).filter(Objects::nonNull).collect(Collectors.toList());
|
||||
|
||||
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, blockTransaction.getFee(), true)));
|
||||
}
|
||||
|
||||
private static Double getMaxFeeRate() {
|
||||
if(AppServices.getTargetBlockFeeRates().isEmpty()) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
return AppServices.getTargetBlockFeeRates().values().iterator().next();
|
||||
}
|
||||
|
||||
private static void sendSelectedUtxos(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
|
||||
List<HashIndexEntry> utxoEntries = treeTableView.getSelectionModel().getSelectedCells().stream()
|
||||
.map(tp -> tp.getTreeItem().getValue())
|
||||
.filter(e -> e instanceof HashIndexEntry)
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.isSpendable())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if(!utxoEntries.contains(hashIndexEntry)) {
|
||||
utxoEntries = List.of(hashIndexEntry);
|
||||
}
|
||||
|
||||
final List<BlockTransactionHashIndex> spendingUtxos = utxoEntries.stream().map(HashIndexEntry::getHashIndex).collect(Collectors.toList());
|
||||
EventManager.get().post(new SendActionEvent(hashIndexEntry.getWallet(), spendingUtxos));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(hashIndexEntry.getWallet(), spendingUtxos)));
|
||||
}
|
||||
|
||||
private static void freezeUtxo(HashIndexEntry hashIndexEntry) {
|
||||
hashIndexEntry.getHashIndex().setStatus(Status.FROZEN);
|
||||
EventManager.get().post(new WalletUtxoStatusChangedEvent(hashIndexEntry.getWallet(), hashIndexEntry.getHashIndex()));
|
||||
}
|
||||
|
||||
private static void unfreezeUtxo(HashIndexEntry hashIndexEntry) {
|
||||
hashIndexEntry.getHashIndex().setStatus(null);
|
||||
EventManager.get().post(new WalletUtxoStatusChangedEvent(hashIndexEntry.getWallet(), hashIndexEntry.getHashIndex()));
|
||||
}
|
||||
|
||||
private static class UnconfirmedTransactionContextMenu extends ContextMenu {
|
||||
public UnconfirmedTransactionContextMenu(TransactionEntry transactionEntry) {
|
||||
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
|
||||
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
|
||||
copyTxid.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(blockTransaction.getHashAsString());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
getItems().add(copyTxid);
|
||||
|
||||
if(blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
MenuItem increaseFee = new MenuItem("Increase Fee");
|
||||
increaseFee.setOnAction(AE -> {
|
||||
hide();
|
||||
increaseFee(transactionEntry);
|
||||
});
|
||||
|
||||
getItems().add(increaseFee);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class TransactionContextMenu extends ContextMenu {
|
||||
public TransactionContextMenu(String date, BlockTransaction blockTransaction) {
|
||||
MenuItem copyDate = new MenuItem("Copy Date");
|
||||
copyDate.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(date);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
|
||||
copyTxid.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(blockTransaction.getHashAsString());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
MenuItem copyHeight = new MenuItem("Copy Block Height");
|
||||
copyHeight.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(blockTransaction.getHeight() > 0 ? Integer.toString(blockTransaction.getHeight()) : "Mempool");
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
getItems().addAll(copyDate, copyTxid, copyHeight);
|
||||
}
|
||||
}
|
||||
|
||||
public static class AddressContextMenu extends ContextMenu {
|
||||
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry) {
|
||||
MenuItem copyAddress = new MenuItem("Copy Address");
|
||||
copyAddress.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(address.toString());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
MenuItem copyHex = new MenuItem("Copy Script Output Bytes");
|
||||
copyHex.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(Utils.bytesToHex(address.getOutputScriptData()));
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
MenuItem copyOutputDescriptor = new MenuItem("Copy Output Descriptor");
|
||||
copyOutputDescriptor.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(outputDescriptor);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
getItems().addAll(copyAddress, copyHex, copyOutputDescriptor);
|
||||
|
||||
if(nodeEntry != null) {
|
||||
MenuItem signVerifyMessage = new MenuItem("Sign/Verify Message");
|
||||
signVerifyMessage.setOnAction(AE -> {
|
||||
hide();
|
||||
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
|
||||
messageSignDialog.showAndWait();
|
||||
});
|
||||
|
||||
getItems().add(signVerifyMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class HashIndexEntryContextMenu extends ContextMenu {
|
||||
public HashIndexEntryContextMenu(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
|
||||
String label = "Copy " + (hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) ? "Transaction Output" : "Transaction Input");
|
||||
MenuItem copyHashIndex = new MenuItem(label);
|
||||
copyHashIndex.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(hashIndexEntry.getHashIndex().toString());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
getItems().add(copyHashIndex);
|
||||
|
||||
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && hashIndexEntry.isSpendable() && !hashIndexEntry.getHashIndex().isSpent()) {
|
||||
MenuItem sendSelected = new MenuItem("Send Selected");
|
||||
sendSelected.setOnAction(AE -> {
|
||||
hide();
|
||||
sendSelectedUtxos(treeTableView, hashIndexEntry);
|
||||
});
|
||||
getItems().add(sendSelected);
|
||||
}
|
||||
|
||||
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && !hashIndexEntry.getHashIndex().isSpent()) {
|
||||
if(hashIndexEntry.getHashIndex().getStatus() == null || hashIndexEntry.getHashIndex().getStatus() != Status.FROZEN) {
|
||||
MenuItem freezeUtxo = new MenuItem("Freeze UTXO");
|
||||
freezeUtxo.setOnAction(AE -> {
|
||||
hide();
|
||||
freezeUtxo(hashIndexEntry);
|
||||
});
|
||||
getItems().add(freezeUtxo);
|
||||
} else {
|
||||
MenuItem unfreezeUtxo = new MenuItem("Unfreeze UTXO");
|
||||
unfreezeUtxo.setOnAction(AE -> {
|
||||
hide();
|
||||
unfreezeUtxo(hashIndexEntry);
|
||||
});
|
||||
getItems().add(unfreezeUtxo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void applyRowStyles(TreeTableCell<?, ?> cell, Entry entry) {
|
||||
cell.getStyleClass().remove("transaction-row");
|
||||
cell.getStyleClass().remove("node-row");
|
||||
cell.getStyleClass().remove("utxo-row");
|
||||
cell.getStyleClass().remove("address-cell");
|
||||
cell.getStyleClass().remove("hashindex-row");
|
||||
cell.getStyleClass().remove("confirming");
|
||||
cell.getStyleClass().remove("spent");
|
||||
cell.getStyleClass().remove("unspendable");
|
||||
|
||||
if(entry != null) {
|
||||
if(entry instanceof TransactionEntry) {
|
||||
cell.getStyleClass().add("transaction-row");
|
||||
TransactionEntry transactionEntry = (TransactionEntry)entry;
|
||||
if(transactionEntry.isConfirming()) {
|
||||
cell.getStyleClass().add("confirming");
|
||||
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(!transactionEntry.isConfirming()) {
|
||||
cell.getStyleClass().remove("confirming");
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if(entry instanceof NodeEntry) {
|
||||
cell.getStyleClass().add("node-row");
|
||||
} else if(entry instanceof UtxoEntry) {
|
||||
cell.getStyleClass().add("utxo-row");
|
||||
UtxoEntry utxoEntry = (UtxoEntry)entry;
|
||||
if(!utxoEntry.isSpendable()) {
|
||||
cell.getStyleClass().add("unspendable");
|
||||
}
|
||||
} else if(entry instanceof HashIndexEntry) {
|
||||
cell.getStyleClass().add("hashindex-row");
|
||||
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
|
||||
if(hashIndexEntry.isSpent()) {
|
||||
cell.getStyleClass().add("spent");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.sparrow.CurrencyRate;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.util.Currency;
|
||||
import java.util.Locale;
|
||||
|
||||
public class FiatLabel extends CopyableLabel {
|
||||
private static final DecimalFormat CURRENCY_FORMAT = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
|
||||
private final LongProperty valueProperty = new SimpleLongProperty(-1);
|
||||
private final DoubleProperty btcRateProperty = new SimpleDoubleProperty(0.0);
|
||||
private final ObjectProperty<Currency> currencyProperty = new SimpleObjectProperty<>(null);
|
||||
private final Tooltip tooltip;
|
||||
private final FiatContextMenu contextMenu;
|
||||
|
||||
public FiatLabel() {
|
||||
this("");
|
||||
}
|
||||
|
||||
public FiatLabel(String text) {
|
||||
super(text);
|
||||
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue));
|
||||
btcRateProperty().addListener((observable, oldValue, newValue) -> setValueAsText(getValue()));
|
||||
currencyProperty().addListener((observable, oldValue, newValue) -> setValueAsText(getValue()));
|
||||
tooltip = new Tooltip();
|
||||
contextMenu = new FiatContextMenu();
|
||||
}
|
||||
|
||||
public final LongProperty valueProperty() {
|
||||
return valueProperty;
|
||||
}
|
||||
|
||||
public final long getValue() {
|
||||
return valueProperty.get();
|
||||
}
|
||||
|
||||
public final void setValue(long value) {
|
||||
this.valueProperty.set(value);
|
||||
}
|
||||
|
||||
public final DoubleProperty btcRateProperty() {
|
||||
return btcRateProperty;
|
||||
}
|
||||
|
||||
public final double getBtcRate() {
|
||||
return btcRateProperty.get();
|
||||
}
|
||||
|
||||
public final void setBtcRate(double btcRate) {
|
||||
this.btcRateProperty.set(btcRate);
|
||||
}
|
||||
|
||||
public final ObjectProperty<Currency> currencyProperty() {
|
||||
return currencyProperty;
|
||||
}
|
||||
|
||||
public final Currency getCurrency() {
|
||||
return currencyProperty.get();
|
||||
}
|
||||
|
||||
public final void setCurrency(Currency currency) {
|
||||
this.currencyProperty.set(currency);
|
||||
}
|
||||
|
||||
public final void set(CurrencyRate currencyRate, long value) {
|
||||
set(currencyRate.getCurrency(), currencyRate.getBtcRate(), value);
|
||||
}
|
||||
|
||||
public final void set(Currency currency, double btcRate, long value) {
|
||||
setValue(value);
|
||||
setBtcRate(btcRate);
|
||||
setCurrency(currency);
|
||||
}
|
||||
|
||||
private void setValueAsText(long balance) {
|
||||
if(getCurrency() != null && getBtcRate() > 0.0) {
|
||||
BigDecimal satsBalance = BigDecimal.valueOf(balance);
|
||||
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
|
||||
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(getBtcRate()));
|
||||
|
||||
String label = getCurrency().getSymbol() + " " + CURRENCY_FORMAT.format(fiatBalance.doubleValue());
|
||||
tooltip.setText("1 BTC = " + getCurrency().getSymbol() + " " + CURRENCY_FORMAT.format(getBtcRate()));
|
||||
|
||||
setText(label);
|
||||
setTooltip(tooltip);
|
||||
setContextMenu(contextMenu);
|
||||
} else {
|
||||
setText("");
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
}
|
||||
}
|
||||
|
||||
private class FiatContextMenu extends ContextMenu {
|
||||
public FiatContextMenu() {
|
||||
MenuItem copyValue = new MenuItem("Copy Value");
|
||||
copyValue.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(getText());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
MenuItem copyRate = new MenuItem("Copy Rate");
|
||||
copyRate.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(getTooltip().getText());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
getItems().addAll(copyValue, copyRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.FileImport;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonBase;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.ToggleButton;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
import org.controlsfx.control.SegmentedButton;
|
||||
import org.controlsfx.control.textfield.CustomPasswordField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.tools.Platform;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
private static final Logger log = LoggerFactory.getLogger(FileImportPane.class);
|
||||
|
||||
private final FileImport importer;
|
||||
protected ButtonBase importButton;
|
||||
private final SimpleStringProperty password = new SimpleStringProperty("");
|
||||
private final boolean scannable;
|
||||
protected List<Wallet> wallets;
|
||||
|
||||
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable) {
|
||||
super(title, description, content, imageUrl);
|
||||
this.importer = importer;
|
||||
this.scannable = scannable;
|
||||
|
||||
buttonBox.getChildren().clear();
|
||||
buttonBox.getChildren().add(createButton());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Control createButton() {
|
||||
if(scannable) {
|
||||
ToggleButton scanButton = new ToggleButton("Scan...");
|
||||
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
|
||||
cameraGlyph.setFontSize(12);
|
||||
scanButton.setGraphic(cameraGlyph);
|
||||
scanButton.setOnAction(event -> {
|
||||
scanButton.setSelected(false);
|
||||
importQR();
|
||||
});
|
||||
|
||||
ToggleButton fileButton = new ToggleButton("Import File...");
|
||||
fileButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
fileButton.setOnAction(event -> {
|
||||
fileButton.setSelected(false);
|
||||
importFile();
|
||||
});
|
||||
importButton = fileButton;
|
||||
|
||||
SegmentedButton segmentedButton = new SegmentedButton();
|
||||
segmentedButton.getButtons().addAll(scanButton, fileButton);
|
||||
return segmentedButton;
|
||||
} else {
|
||||
importButton = new Button("Import File...");
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setOnAction(event -> {
|
||||
importFile();
|
||||
});
|
||||
return importButton;
|
||||
}
|
||||
}
|
||||
|
||||
private void importFile() {
|
||||
Stage window = new Stage();
|
||||
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Open " + importer.getWalletModel().toDisplayString() + " File");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("All Files", Platform.getCurrent().equals(Platform.UNIX) ? "*" : "*.*"),
|
||||
new FileChooser.ExtensionFilter("JSON", "*.json"),
|
||||
new FileChooser.ExtensionFilter("TXT", "*.txt")
|
||||
);
|
||||
|
||||
File file = fileChooser.showOpenDialog(window);
|
||||
if(file != null) {
|
||||
importFile(file, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void importFile(File file, String password) {
|
||||
if(file.exists()) {
|
||||
try {
|
||||
if(importer.isEncrypted(file) && password == null) {
|
||||
setDescription("Password Required");
|
||||
showHideLink.setVisible(false);
|
||||
setContent(getPasswordEntry(file));
|
||||
importButton.setDisable(true);
|
||||
setExpanded(true);
|
||||
} else {
|
||||
InputStream inputStream = new BufferedInputStream(new FileInputStream(file));
|
||||
importFile(file.getName(), inputStream, password);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error importing file", e);
|
||||
String errorMessage = e.getMessage();
|
||||
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
if(e instanceof InvalidPasswordException || e.getCause() instanceof InvalidPasswordException) {
|
||||
errorMessage = "Invalid wallet password";
|
||||
}
|
||||
if(e instanceof JsonParseException || e.getCause() instanceof JsonParseException) {
|
||||
errorMessage = "File was not in JSON format";
|
||||
}
|
||||
setError("Import Error", errorMessage);
|
||||
importButton.setDisable(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void importQR() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.wallets != null) {
|
||||
wallets = result.wallets;
|
||||
try {
|
||||
importFile(importer.getName(), null, null);
|
||||
} catch(ImportException e) {
|
||||
setError("Import Error", e.getMessage());
|
||||
}
|
||||
} else if(result.payload != null) {
|
||||
try {
|
||||
importFile(importer.getName(), new ByteArrayInputStream(result.payload.getBytes(StandardCharsets.UTF_8)), null);
|
||||
} catch(Exception e) {
|
||||
log.error("Error importing QR", e);
|
||||
String errorMessage = e.getMessage();
|
||||
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
if(e instanceof JsonParseException || e.getCause() instanceof JsonParseException) {
|
||||
errorMessage = "QR contents were not in JSON format";
|
||||
}
|
||||
setError("Import Error", errorMessage);
|
||||
}
|
||||
} else if(result.exception != null) {
|
||||
log.error("Error importing QR", result.exception);
|
||||
setError("Import Error", result.exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected Keystore getScannedKeystore(ScriptType scriptType) throws ImportException {
|
||||
if(wallets != null) {
|
||||
for(Wallet wallet : wallets) {
|
||||
if(scriptType.equals(wallet.getScriptType()) && !wallet.getKeystores().isEmpty()) {
|
||||
return wallet.getKeystores().get(0);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImportException("Script type " + scriptType + " is not supported");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected abstract void importFile(String fileName, InputStream inputStream, String password) throws ImportException;
|
||||
|
||||
private Node getPasswordEntry(File file) {
|
||||
CustomPasswordField passwordField = (CustomPasswordField) TextFields.createClearablePasswordField();
|
||||
passwordField.setPromptText("Wallet password");
|
||||
password.bind(passwordField.textProperty());
|
||||
HBox.setHgrow(passwordField, Priority.ALWAYS);
|
||||
|
||||
Button importEncryptedButton = new Button("Import");
|
||||
importEncryptedButton.setDefaultButton(true);
|
||||
importEncryptedButton.setOnAction(event -> {
|
||||
showHideLink.setVisible(true);
|
||||
setExpanded(false);
|
||||
importFile(file, password.get());
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setAlignment(Pos.TOP_RIGHT);
|
||||
contentBox.setSpacing(20);
|
||||
contentBox.getChildren().add(passwordField);
|
||||
contentBox.getChildren().add(importEncryptedButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
contentBox.setPrefHeight(60);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import com.sparrowwallet.sparrow.io.KeystoreFileImport;
|
||||
|
||||
import java.io.*;
|
||||
|
||||
public class FileKeystoreImportPane extends FileImportPane {
|
||||
protected final Wallet wallet;
|
||||
private final KeystoreFileImport importer;
|
||||
|
||||
public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer) {
|
||||
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable());
|
||||
this.wallet = wallet;
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
|
||||
Keystore keystore = getScannedKeystore(wallet.getScriptType());
|
||||
if(keystore == null) {
|
||||
keystore = importer.getKeystore(wallet.getScriptType(), inputStream, password);
|
||||
}
|
||||
|
||||
EventManager.get().post(new KeystoreImportEvent(keystore));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.StorageEvent;
|
||||
import com.sparrowwallet.sparrow.event.TimedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletExportEvent;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.io.WalletExport;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
private final Wallet wallet;
|
||||
private final WalletExport exporter;
|
||||
|
||||
public FileWalletExportPane(Wallet wallet, WalletExport exporter) {
|
||||
super(exporter.getName(), "Wallet file export", exporter.getWalletExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png");
|
||||
this.wallet = wallet;
|
||||
this.exporter = exporter;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Control createButton() {
|
||||
Button exportButton = new Button("Export Wallet...");
|
||||
exportButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
exportButton.setOnAction(event -> {
|
||||
exportWallet();
|
||||
});
|
||||
return exportButton;
|
||||
}
|
||||
|
||||
private void exportWallet() {
|
||||
Stage window = new Stage();
|
||||
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
|
||||
fileChooser.setInitialFileName(wallet.getName() + "-" + exporter.getWalletModel().toDisplayString().toLowerCase() + "." + exporter.getExportFileExtension());
|
||||
|
||||
File file = fileChooser.showSaveDialog(window);
|
||||
if(file != null) {
|
||||
exportWallet(file);
|
||||
}
|
||||
}
|
||||
|
||||
private void exportWallet(File file) {
|
||||
Wallet copy = wallet.copy();
|
||||
|
||||
if(copy.isEncrypted()) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
|
||||
decryptWalletService.setOnSucceeded(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(file, TimedEvent.Action.END, "Done"));
|
||||
Wallet decryptedWallet = decryptWalletService.getValue();
|
||||
exportWallet(file, decryptedWallet);
|
||||
});
|
||||
decryptWalletService.setOnFailed(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(file, TimedEvent.Action.END, "Failed"));
|
||||
setError("Export Error", decryptWalletService.getException().getMessage());
|
||||
});
|
||||
EventManager.get().post(new StorageEvent(file, TimedEvent.Action.START, "Decrypting wallet..."));
|
||||
decryptWalletService.start();
|
||||
}
|
||||
} else {
|
||||
exportWallet(file, copy);
|
||||
}
|
||||
}
|
||||
|
||||
private void exportWallet(File file, Wallet decryptedWallet) {
|
||||
try {
|
||||
OutputStream outputStream = new FileOutputStream(file);
|
||||
exporter.exportWallet(decryptedWallet, outputStream);
|
||||
EventManager.get().post(new WalletExportEvent(decryptedWallet));
|
||||
} catch(Exception e) {
|
||||
String errorMessage = e.getMessage();
|
||||
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
setError("Export Error", errorMessage);
|
||||
} finally {
|
||||
decryptedWallet.clearPrivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletImportEvent;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import com.sparrowwallet.sparrow.io.WalletImport;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public class FileWalletImportPane extends FileImportPane {
|
||||
private final WalletImport importer;
|
||||
|
||||
public FileWalletImportPane(WalletImport importer) {
|
||||
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable());
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
|
||||
Wallet wallet = importer.importWallet(inputStream, password);
|
||||
if(wallet.getName() == null) {
|
||||
wallet.setName(fileName);
|
||||
}
|
||||
EventManager.get().post(new WalletImportEvent(wallet));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.io.ByteStreams;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletImportEvent;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import com.sparrowwallet.sparrow.io.KeystoreFileImport;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
public class FileWalletKeystoreImportPane extends FileImportPane {
|
||||
private static final Logger log = LoggerFactory.getLogger(FileWalletKeystoreImportPane.class);
|
||||
|
||||
private final KeystoreFileImport importer;
|
||||
private String fileName;
|
||||
private byte[] fileBytes;
|
||||
|
||||
public FileWalletKeystoreImportPane(KeystoreFileImport importer) {
|
||||
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable());
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
|
||||
this.fileName = fileName;
|
||||
try {
|
||||
fileBytes = ByteStreams.toByteArray(inputStream);
|
||||
} catch(IOException e) {
|
||||
throw new ImportException("Could not read file", e);
|
||||
}
|
||||
|
||||
setContent(getScriptTypeEntry());
|
||||
setExpanded(true);
|
||||
importButton.setDisable(true);
|
||||
}
|
||||
|
||||
private void importWallet(ScriptType scriptType) throws ImportException {
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
|
||||
Keystore keystore = importer.getKeystore(scriptType, bais, "");
|
||||
|
||||
Wallet wallet = new Wallet();
|
||||
wallet.setName(fileName);
|
||||
wallet.setPolicyType(PolicyType.SINGLE);
|
||||
wallet.setScriptType(scriptType);
|
||||
wallet.getKeystores().add(keystore);
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
|
||||
|
||||
EventManager.get().post(new WalletImportEvent(wallet));
|
||||
}
|
||||
|
||||
private Node getScriptTypeEntry() {
|
||||
Label label = new Label("Script Type:");
|
||||
ComboBox<ScriptType> scriptTypeComboBox = new ComboBox<>(FXCollections.observableArrayList(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE)));
|
||||
scriptTypeComboBox.setValue(ScriptType.P2WPKH);
|
||||
|
||||
Region region = new Region();
|
||||
HBox.setHgrow(region, Priority.SOMETIMES);
|
||||
|
||||
Button importFileButton = new Button("Import");
|
||||
importFileButton.setOnAction(event -> {
|
||||
showHideLink.setVisible(true);
|
||||
setExpanded(false);
|
||||
try {
|
||||
importWallet(scriptTypeComboBox.getValue());
|
||||
} catch(ImportException e) {
|
||||
log.error("Error importing file", e);
|
||||
String errorMessage = e.getMessage();
|
||||
if(e.getCause() instanceof JsonParseException) {
|
||||
errorMessage = "File was not in JSON format";
|
||||
} else if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
setError("Import Error", errorMessage);
|
||||
importButton.setDisable(false);
|
||||
}
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
contentBox.setSpacing(20);
|
||||
contentBox.getChildren().addAll(label, scriptTypeComboBox, region, importFileButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
contentBox.setPrefHeight(60);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
public class HelpLabel extends Label {
|
||||
private final Tooltip tooltip;
|
||||
|
||||
public HelpLabel() {
|
||||
super("", getHelpGlyph());
|
||||
tooltip = new Tooltip();
|
||||
tooltip.textProperty().bind(helpTextProperty());
|
||||
setTooltip(tooltip);
|
||||
getStyleClass().add("help-label");
|
||||
}
|
||||
|
||||
private static Glyph getHelpGlyph() {
|
||||
Glyph lockGlyph = new Glyph("Font Awesome 5 Free Solid", FontAwesome5.Glyph.QUESTION_CIRCLE);
|
||||
lockGlyph.getStyleClass().add("help-icon");
|
||||
lockGlyph.setFontSize(12);
|
||||
return lockGlyph;
|
||||
}
|
||||
|
||||
public final StringProperty helpTextProperty() {
|
||||
if(helpText == null) {
|
||||
helpText = new SimpleStringProperty(this, "helpText", "");
|
||||
}
|
||||
|
||||
return helpText;
|
||||
}
|
||||
|
||||
private StringProperty helpText;
|
||||
|
||||
public final void setHelpText(String value) {
|
||||
helpTextProperty().setValue(value.replace("\\n", "\n"));
|
||||
}
|
||||
|
||||
public final String getHelpText() {
|
||||
return helpText == null ? "" : helpText.getValue();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.scene.text.Font;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
|
||||
public class IdLabel extends CopyableLabel {
|
||||
public IdLabel() {
|
||||
@@ -9,6 +9,6 @@ public class IdLabel extends CopyableLabel {
|
||||
|
||||
public IdLabel(String text) {
|
||||
super(text);
|
||||
setFont(Font.font("Courier"));
|
||||
setFont(AppServices.getMonospaceFont());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.DialogPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.control.textfield.CustomPasswordField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
public class KeystorePassphraseDialog extends Dialog<String> {
|
||||
private final CustomPasswordField passphrase;
|
||||
|
||||
public KeystorePassphraseDialog(Keystore keystore) {
|
||||
this(null, keystore);
|
||||
}
|
||||
|
||||
public KeystorePassphraseDialog(String walletName, Keystore keystore) {
|
||||
this.passphrase = (CustomPasswordField) TextFields.createClearablePasswordField();
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
setTitle("Keystore Passphrase" + (walletName != null ? " - " + walletName : ""));
|
||||
dialogPane.setHeaderText("Please enter the passphrase for keystore: \n" + keystore.getLabel());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK);
|
||||
dialogPane.setPrefWidth(380);
|
||||
dialogPane.setPrefHeight(200);
|
||||
|
||||
Glyph key = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.KEY);
|
||||
key.setFontSize(50);
|
||||
dialogPane.setGraphic(key);
|
||||
|
||||
final VBox content = new VBox(10);
|
||||
content.setPrefHeight(50);
|
||||
content.getChildren().add(passphrase);
|
||||
|
||||
dialogPane.setContent(content);
|
||||
Platform.runLater(passphrase::requestFocus);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton == ButtonType.OK ? passphrase.getText() : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import javafx.event.Event;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.control.cell.TextFieldTreeTableCell;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import javafx.util.converter.DefaultStringConverter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
class LabelCell extends TextFieldTreeTableCell<Entry, String> {
|
||||
private static final Logger log = LoggerFactory.getLogger(LabelCell.class);
|
||||
|
||||
public LabelCell() {
|
||||
super(new DefaultStringConverter());
|
||||
getStyleClass().add("label-cell");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateItem(String label, boolean empty) {
|
||||
super.updateItem(label, empty);
|
||||
|
||||
if(empty) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
} else {
|
||||
EntryCell.applyRowStyles(this, getTreeTableView().getTreeItem(getIndex()).getValue());
|
||||
|
||||
setText(label);
|
||||
setContextMenu(new LabelContextMenu(label));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commitEdit(String label) {
|
||||
if(label != null) {
|
||||
label = label.trim();
|
||||
}
|
||||
|
||||
// This block is necessary to support commit on losing focus, because
|
||||
// the baked-in mechanism sets our editing state to false before we can
|
||||
// intercept the loss of focus. The default commitEdit(...) method
|
||||
// simply bails if we are not editing...
|
||||
if (!isEditing() && !label.equals(getItem())) {
|
||||
TreeTableView<Entry> treeTable = getTreeTableView();
|
||||
if(treeTable != null) {
|
||||
TreeTableColumn<Entry, String> column = getTableColumn();
|
||||
TreeTableColumn.CellEditEvent<Entry, String> event = new TreeTableColumn.CellEditEvent<>(
|
||||
treeTable, new TreeTablePosition<>(treeTable, getIndex(), column),
|
||||
TreeTableColumn.editCommitEvent(), label
|
||||
);
|
||||
Event.fireEvent(column, event);
|
||||
}
|
||||
}
|
||||
|
||||
super.commitEdit(label);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startEdit() {
|
||||
super.startEdit();
|
||||
|
||||
try {
|
||||
Field f = getClass().getSuperclass().getDeclaredField("textField");
|
||||
f.setAccessible(true);
|
||||
TextField textField = (TextField)f.get(this);
|
||||
textField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> {
|
||||
if (!isNowFocused) {
|
||||
commitEdit(getConverter().fromString(textField.getText()));
|
||||
setText(getConverter().fromString(textField.getText()));
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("Error starting edit", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static class LabelContextMenu extends ContextMenu {
|
||||
public LabelContextMenu(String label) {
|
||||
MenuItem copyLabel = new MenuItem("Copy Label");
|
||||
copyLabel.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(label);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
getItems().add(copyLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.net.MempoolRateSize;
|
||||
import com.sparrowwallet.sparrow.wallet.SendController;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.geometry.Point2D;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.chart.*;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.util.Duration;
|
||||
import javafx.util.StringConverter;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
|
||||
private static final DateFormat dateFormatter = new SimpleDateFormat("HH:mm");
|
||||
public static final int MAX_PERIOD_HOURS = 2;
|
||||
|
||||
private Tooltip tooltip;
|
||||
|
||||
public MempoolSizeFeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
|
||||
super(xAxis, yAxis);
|
||||
}
|
||||
|
||||
public void initialize() {
|
||||
setCreateSymbols(false);
|
||||
setCursor(Cursor.CROSSHAIR);
|
||||
setVerticalGridLinesVisible(false);
|
||||
tooltip = new Tooltip();
|
||||
tooltip.setShowDelay(Duration.ZERO);
|
||||
tooltip.setHideDelay(Duration.ZERO);
|
||||
tooltip.setShowDuration(Duration.INDEFINITE);
|
||||
Platform.runLater(() -> {
|
||||
Node node = this.lookup(".plot-content");
|
||||
Tooltip.install(node, tooltip);
|
||||
});
|
||||
}
|
||||
|
||||
public void update(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) {
|
||||
getData().clear();
|
||||
if(tooltip.isShowing()) {
|
||||
tooltip.hide();
|
||||
}
|
||||
|
||||
Map<Date, Set<MempoolRateSize>> periodRateSizes = getPeriodRateSizes(mempoolRateSizes);
|
||||
Map<Date, String> categories = getCategories(periodRateSizes);
|
||||
|
||||
CategoryAxis categoryAxis = (CategoryAxis)getXAxis();
|
||||
categoryAxis.setTickMarkVisible(false);
|
||||
categoryAxis.setTickLabelGap(10);
|
||||
categoryAxis.setAutoRanging(false);
|
||||
categoryAxis.setCategories(FXCollections.observableArrayList(categories.values()));
|
||||
categoryAxis.invalidateRange(new ArrayList<>(categories.values()));
|
||||
categoryAxis.setGapStartAndEnd(false);
|
||||
categoryAxis.setTickLabelRotation(0);
|
||||
|
||||
NumberAxis numberAxis = (NumberAxis)getYAxis();
|
||||
numberAxis.setTickLabelFormatter(new StringConverter<Number>() {
|
||||
@Override
|
||||
public String toString(Number object) {
|
||||
long vSizeBytes = object.longValue();
|
||||
return (vSizeBytes / (1000 * 1000)) + " MvB";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Number fromString(String string) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
this.setOnMouseMoved(mouseEvent -> {
|
||||
Point2D sceneCoords = this.localToScene(mouseEvent.getX(), mouseEvent.getY());
|
||||
String category = categoryAxis.getValueForDisplay(categoryAxis.sceneToLocal(sceneCoords).getX());
|
||||
if(category != null) {
|
||||
Optional<String> time = categories.entrySet().stream().filter(entry -> entry.getValue().equals(category)).map(entry -> dateFormatter.format(entry.getKey())).findFirst();
|
||||
time.ifPresent(s -> tooltip.setGraphic(new ChartTooltip(category, s, getData())));
|
||||
}
|
||||
});
|
||||
|
||||
long previousFeeRate = 0;
|
||||
for(Long feeRate : SendController.FEE_RATES_RANGE) {
|
||||
XYChart.Series<String, Number> series = new XYChart.Series<>();
|
||||
series.setName(feeRate + "+ vB");
|
||||
long seriesTotalVSize = 0;
|
||||
|
||||
for(Date date : periodRateSizes.keySet()) {
|
||||
Set<MempoolRateSize> rateSizes = periodRateSizes.get(date);
|
||||
long totalVSize = 0;
|
||||
for(MempoolRateSize rateSize : rateSizes) {
|
||||
if(rateSize.getFee() > previousFeeRate && rateSize.getFee() <= feeRate) {
|
||||
totalVSize += rateSize.getVSize();
|
||||
}
|
||||
}
|
||||
|
||||
series.getData().add(new XYChart.Data<>(categories.get(date), totalVSize));
|
||||
seriesTotalVSize += totalVSize;
|
||||
}
|
||||
|
||||
if(seriesTotalVSize > 0) {
|
||||
getData().add(series);
|
||||
}
|
||||
|
||||
previousFeeRate = feeRate;
|
||||
}
|
||||
|
||||
if(categories.keySet().iterator().hasNext()) {
|
||||
String time = categories.values().iterator().next();
|
||||
tooltip.setGraphic(new ChartTooltip(time, time, getData()));
|
||||
numberAxis.setTickLabelsVisible(true);
|
||||
numberAxis.setOpacity(1);
|
||||
} else {
|
||||
numberAxis.setTickLabelsVisible(false);
|
||||
numberAxis.setOpacity(0);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Date, Set<MempoolRateSize>> getPeriodRateSizes(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) {
|
||||
if(mempoolRateSizes.size() == 1) {
|
||||
return mempoolRateSizes;
|
||||
}
|
||||
|
||||
LocalDateTime period = LocalDateTime.now().minusHours(MAX_PERIOD_HOURS);
|
||||
return mempoolRateSizes.entrySet().stream().filter(entry -> {
|
||||
LocalDateTime dateTime = entry.getKey().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
|
||||
return dateTime.isAfter(period);
|
||||
}).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue,
|
||||
(u, v) -> { throw new IllegalStateException("Duplicate dates"); },
|
||||
TreeMap::new));
|
||||
}
|
||||
|
||||
private Map<Date, String> getCategories(Map<Date, Set<MempoolRateSize>> mempoolHistogram) {
|
||||
Map<Date, String> categories = new LinkedHashMap<>();
|
||||
|
||||
String invisible = "" + '\ufeff';
|
||||
for(Iterator<Date> iter = mempoolHistogram.keySet().iterator(); iter.hasNext(); ) {
|
||||
Date date = iter.next();
|
||||
String label = dateFormatter.format(date);
|
||||
if(!categories.isEmpty() && iter.hasNext()) {
|
||||
label = invisible;
|
||||
invisible += '\ufeff';
|
||||
}
|
||||
|
||||
categories.put(date, label);
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
private static class ChartTooltip extends VBox {
|
||||
public ChartTooltip(String category, String time, List<Series<String, Number>> seriesList) {
|
||||
Label title = new Label("At " + time);
|
||||
HBox titleBox = new HBox(title);
|
||||
title.setStyle("-fx-alignment: center; -fx-font-size: 12px; -fx-padding: 0 0 5 0;");
|
||||
getChildren().add(titleBox);
|
||||
|
||||
for(int i = seriesList.size() - 1; i >= 0; i--) {
|
||||
Series<String, Number> series = seriesList.get(i);
|
||||
for(XYChart.Data<String, Number> data : series.getData()) {
|
||||
if(data.getXValue().equals(category)) {
|
||||
double mvb = data.getYValue().doubleValue() / (1000 * 1000);
|
||||
if(mvb >= 0.01) {
|
||||
Label label = new Label(series.getName() + ": " + String.format("%.2f", mvb) + " MvB");
|
||||
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CIRCLE);
|
||||
if(i < 8) {
|
||||
circle.setStyle("-fx-text-fill: CHART_COLOR_" + (i+1));
|
||||
}
|
||||
label.setGraphic(circle);
|
||||
getChildren().add(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
|
||||
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
|
||||
import com.sparrowwallet.sparrow.event.StorageEvent;
|
||||
import com.sparrowwallet.sparrow.event.TimedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import tornadofx.control.Field;
|
||||
import tornadofx.control.Fieldset;
|
||||
import tornadofx.control.Form;
|
||||
|
||||
import java.security.SignatureException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
private static final Logger log = LoggerFactory.getLogger(MessageSignDialog.class);
|
||||
|
||||
private final TextField address;
|
||||
private final TextArea message;
|
||||
private final TextArea signature;
|
||||
private final Wallet wallet;
|
||||
private WalletNode walletNode;
|
||||
|
||||
/**
|
||||
* Verification only constructor
|
||||
*/
|
||||
public MessageSignDialog() {
|
||||
this(null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign and verify with user entered address
|
||||
*
|
||||
* @param wallet Wallet to sign with
|
||||
*/
|
||||
public MessageSignDialog(Wallet wallet) {
|
||||
this(wallet, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign and verify with preset address
|
||||
*
|
||||
* @param wallet Wallet to sign with
|
||||
* @param walletNode Wallet node to derive address from
|
||||
*/
|
||||
public MessageSignDialog(Wallet wallet, WalletNode walletNode) {
|
||||
if(wallet != null) {
|
||||
if(wallet.getKeystores().size() != 1) {
|
||||
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
|
||||
}
|
||||
if(!wallet.getKeystores().get(0).hasSeed() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
|
||||
throw new IllegalArgumentException("Cannot sign messages using a wallet without a seed or USB keystore");
|
||||
}
|
||||
}
|
||||
|
||||
this.wallet = wallet;
|
||||
this.walletNode = walletNode;
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.setHeaderText(wallet == null ? "Verify Message" : "Sign/Verify Message");
|
||||
|
||||
Image image = new Image("image/seed.png", 50, 50, false, false);
|
||||
if (!image.isError()) {
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setSmooth(false);
|
||||
imageView.setImage(image);
|
||||
dialogPane.setGraphic(imageView);
|
||||
}
|
||||
|
||||
VBox vBox = new VBox();
|
||||
vBox.setSpacing(20);
|
||||
|
||||
Form form = new Form();
|
||||
Fieldset fieldset = new Fieldset();
|
||||
fieldset.setText("");
|
||||
|
||||
Field addressField = new Field();
|
||||
addressField.setText("Address:");
|
||||
address = new TextField();
|
||||
address.getStyleClass().add("id");
|
||||
address.setEditable(walletNode == null);
|
||||
addressField.getInputs().add(address);
|
||||
|
||||
if(walletNode != null) {
|
||||
address.setText(wallet.getAddress(walletNode).toString());
|
||||
}
|
||||
|
||||
Field messageField = new Field();
|
||||
messageField.setText("Message:");
|
||||
message = new TextArea();
|
||||
message.setWrapText(true);
|
||||
message.setPrefRowCount(10);
|
||||
message.setStyle("-fx-pref-height: 180px");
|
||||
messageField.getInputs().add(message);
|
||||
|
||||
Field signatureField = new Field();
|
||||
signatureField.setText("Signature:");
|
||||
signature = new TextArea();
|
||||
signature.getStyleClass().add("id");
|
||||
signature.setPrefRowCount(2);
|
||||
signature.setStyle("-fx-pref-height: 60px");
|
||||
signature.setWrapText(true);
|
||||
signatureField.getInputs().add(signature);
|
||||
|
||||
fieldset.getChildren().addAll(addressField, messageField, signatureField);
|
||||
form.getChildren().add(fieldset);
|
||||
dialogPane.setContent(form);
|
||||
|
||||
ButtonType signButtonType = new javafx.scene.control.ButtonType("Sign", ButtonBar.ButtonData.BACK_PREVIOUS);
|
||||
ButtonType verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD);
|
||||
ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
|
||||
|
||||
Button signButton = (Button)dialogPane.lookupButton(signButtonType);
|
||||
signButton.setDisable(wallet == null);
|
||||
signButton.setOnAction(event -> {
|
||||
signMessage();
|
||||
});
|
||||
|
||||
Button verifyButton = (Button)dialogPane.lookupButton(verifyButtonType);
|
||||
verifyButton.setDefaultButton(false);
|
||||
verifyButton.setOnAction(event -> {
|
||||
verifyMessage();
|
||||
});
|
||||
|
||||
boolean validAddress = isValidAddress();
|
||||
signButton.setDisable(!validAddress || (wallet == null));
|
||||
verifyButton.setDisable(!validAddress);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
Platform.runLater(() -> {
|
||||
validationSupport.registerValidator(address, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid address", !isValidAddress()));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
});
|
||||
|
||||
address.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
boolean valid = isValidAddress();
|
||||
signButton.setDisable(!valid || (wallet == null));
|
||||
verifyButton.setDisable(!valid);
|
||||
|
||||
if(valid && wallet != null) {
|
||||
try {
|
||||
Address address = getAddress();
|
||||
setWalletNodeFromAddress(wallet, address);
|
||||
} catch(InvalidAddressException e) {
|
||||
//can't happen
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
EventManager.get().register(this);
|
||||
setOnCloseRequest(event -> {
|
||||
if(ButtonBar.ButtonData.APPLY.equals(getResult())) {
|
||||
event.consume();
|
||||
return;
|
||||
}
|
||||
|
||||
EventManager.get().unregister(this);
|
||||
});
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : ButtonBar.ButtonData.OK_DONE);
|
||||
}
|
||||
|
||||
private Address getAddress()throws InvalidAddressException {
|
||||
return Address.fromString(address.getText());
|
||||
}
|
||||
|
||||
private boolean isValidAddress() {
|
||||
try {
|
||||
getAddress();
|
||||
return true;
|
||||
} catch (InvalidAddressException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void setWalletNodeFromAddress(Wallet wallet, Address address) {
|
||||
walletNode = wallet.getWalletAddresses().get(address);
|
||||
}
|
||||
|
||||
private void signMessage() {
|
||||
if(walletNode == null) {
|
||||
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
|
||||
return;
|
||||
}
|
||||
|
||||
if(wallet.containsSeeds()) {
|
||||
if(wallet.isEncrypted()) {
|
||||
EventManager.get().post(new RequestOpenWalletsEvent());
|
||||
} else {
|
||||
signUnencryptedKeystore(wallet);
|
||||
}
|
||||
} else if(wallet.containsSource(KeystoreSource.HW_USB)) {
|
||||
signUsbKeystore(wallet);
|
||||
}
|
||||
}
|
||||
|
||||
private void signUnencryptedKeystore(Wallet decryptedWallet) {
|
||||
try {
|
||||
//Note we can expect a single keystore due to the check above
|
||||
Keystore keystore = decryptedWallet.getKeystores().get(0);
|
||||
ECKey privKey = keystore.getKey(walletNode);
|
||||
String signatureText = privKey.signMessage(message.getText().trim(), decryptedWallet.getScriptType(), null);
|
||||
signature.clear();
|
||||
signature.appendText(signatureText);
|
||||
} catch(Exception e) {
|
||||
log.error("Could not sign message", e);
|
||||
AppServices.showErrorDialog("Could not sign message", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void signUsbKeystore(Wallet usbWallet) {
|
||||
List<String> fingerprints = List.of(usbWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
|
||||
KeyDerivation fullDerivation = usbWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
|
||||
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, usbWallet, message.getText().trim(), fullDerivation);
|
||||
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
|
||||
if(optSignature.isPresent()) {
|
||||
signature.clear();
|
||||
signature.appendText(optSignature.get());
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyMessage() {
|
||||
try {
|
||||
//Find ECKey from message and signature
|
||||
//http://www.secg.org/download/aid-780/sec1-v2.pdf section 4.1.6
|
||||
boolean verified = false;
|
||||
try {
|
||||
ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText().trim(), signature.getText().trim(), false);
|
||||
verified = verifyMessage(signedMessageKey);
|
||||
} catch(SignatureException e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
if(!verified) {
|
||||
try {
|
||||
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), true);
|
||||
verified = verifyMessage(electrumSignedMessageKey);
|
||||
} catch(SignatureException e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
if(verified) {
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||
AppServices.setStageIcon(alert.getDialogPane().getScene().getWindow());
|
||||
alert.setTitle("Verification Succeeded");
|
||||
alert.setHeaderText("Verification Succeeded");
|
||||
alert.setContentText("The signature verified against the message.");
|
||||
alert.showAndWait();
|
||||
} else {
|
||||
AppServices.showErrorDialog("Verification failed", "The provided signature did not match the message for this address.");
|
||||
}
|
||||
} catch(IllegalArgumentException e) {
|
||||
AppServices.showErrorDialog("Could not verify message", e.getMessage());
|
||||
} catch(Exception e) {
|
||||
log.error("Could not verify message", e);
|
||||
AppServices.showErrorDialog("Could not verify message", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifyMessage(ECKey signedMessageKey) throws InvalidAddressException, SignatureException {
|
||||
Address providedAddress = getAddress();
|
||||
ScriptType scriptType = providedAddress.getScriptType();
|
||||
if(scriptType == ScriptType.P2SH) {
|
||||
scriptType = ScriptType.P2SH_P2WPKH;
|
||||
}
|
||||
if(!ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE).contains(scriptType)) {
|
||||
throw new IllegalArgumentException("Only single signature P2PKH, P2SH-P2WPKH or P2WPKH addresses can verify messages.");
|
||||
}
|
||||
|
||||
Address signedMessageAddress = scriptType.getAddress(signedMessageKey);
|
||||
return providedAddress.equals(signedMessageAddress);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void openWallets(OpenWalletsEvent event) {
|
||||
Storage storage = event.getStorage(wallet);
|
||||
if(storage == null) {
|
||||
//Another window, ignore
|
||||
return;
|
||||
}
|
||||
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
|
||||
decryptWalletService.setOnSucceeded(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
|
||||
Wallet decryptedWallet = decryptWalletService.getValue();
|
||||
signUnencryptedKeystore(decryptedWallet);
|
||||
});
|
||||
decryptWalletService.setOnFailed(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
|
||||
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
|
||||
});
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
|
||||
decryptWalletService.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.util.Callback;
|
||||
import org.controlsfx.control.textfield.AutoCompletionBinding;
|
||||
import org.controlsfx.control.textfield.CustomTextField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.tools.Borders;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
protected final Wallet wallet;
|
||||
private final KeystoreMnemonicImport importer;
|
||||
|
||||
private SplitMenuButton enterMnemonicButton;
|
||||
private SplitMenuButton importButton;
|
||||
|
||||
private TilePane wordsPane;
|
||||
private Button generateButton;
|
||||
private Label validLabel;
|
||||
private Label invalidLabel;
|
||||
private Button calculateButton;
|
||||
private Button backButton;
|
||||
private Button nextButton;
|
||||
private Button confirmButton;
|
||||
private List<String> generatedMnemonicCode;
|
||||
|
||||
private SimpleListProperty<String> wordEntriesProperty;
|
||||
private final SimpleStringProperty passphraseProperty = new SimpleStringProperty();
|
||||
|
||||
public MnemonicKeystoreImportPane(Wallet wallet, KeystoreMnemonicImport importer) {
|
||||
super(importer.getName(), "Seed import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
|
||||
this.wallet = wallet;
|
||||
this.importer = importer;
|
||||
|
||||
createImportButton();
|
||||
buttonBox.getChildren().add(importButton);
|
||||
}
|
||||
|
||||
public MnemonicKeystoreImportPane(Keystore keystore) {
|
||||
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() ? "Passphrase enabled" : "Passphrase disabled", "", "image/" + WalletModel.SEED.getType() + ".png");
|
||||
this.wallet = null;
|
||||
this.importer = null;
|
||||
showHideLink.setVisible(false);
|
||||
buttonBox.getChildren().clear();
|
||||
|
||||
showWordList(keystore.getSeed());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Control createButton() {
|
||||
createEnterMnemonicButton();
|
||||
return enterMnemonicButton;
|
||||
}
|
||||
|
||||
private void createEnterMnemonicButton() {
|
||||
enterMnemonicButton = new SplitMenuButton();
|
||||
enterMnemonicButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
enterMnemonicButton.setText("Set Words Size");
|
||||
enterMnemonicButton.setOnAction(event -> {
|
||||
enterMnemonic(24);
|
||||
});
|
||||
int[] numberWords = new int[] {24, 21, 18, 15, 12};
|
||||
for(int i = 0; i < numberWords.length; i++) {
|
||||
MenuItem item = new MenuItem(numberWords[i] + " words");
|
||||
final int words = numberWords[i];
|
||||
item.setOnAction(event -> {
|
||||
enterMnemonic(words);
|
||||
});
|
||||
enterMnemonicButton.getItems().add(item);
|
||||
}
|
||||
enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty());
|
||||
}
|
||||
|
||||
private void createImportButton() {
|
||||
importButton = new SplitMenuButton();
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setText("Import Keystore");
|
||||
importButton.getStyleClass().add("default-button");
|
||||
importButton.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(wallet.getScriptType().getDefaultDerivation(), false);
|
||||
});
|
||||
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
|
||||
item.setOnAction(event -> {
|
||||
importButton.setDisable(true);
|
||||
importKeystore(derivation, false);
|
||||
});
|
||||
importButton.getItems().add(item);
|
||||
}
|
||||
|
||||
importButton.managedProperty().bind(importButton.visibleProperty());
|
||||
importButton.setVisible(false);
|
||||
}
|
||||
|
||||
private void enterMnemonic(int numWords) {
|
||||
generatedMnemonicCode = null;
|
||||
setDescription("Enter mnemonic word list");
|
||||
showHideLink.setVisible(false);
|
||||
setContent(getMnemonicWordsEntry(numWords, false));
|
||||
setExpanded(true);
|
||||
}
|
||||
|
||||
private Node getMnemonicWordsEntry(int numWords, boolean displayWordsOnly) {
|
||||
VBox vBox = new VBox();
|
||||
vBox.setSpacing(10);
|
||||
|
||||
wordsPane = new TilePane();
|
||||
wordsPane.setPrefRows(numWords/3);
|
||||
wordsPane.setHgap(10);
|
||||
wordsPane.setVgap(10);
|
||||
wordsPane.setOrientation(Orientation.VERTICAL);
|
||||
|
||||
List<String> words = new ArrayList<>();
|
||||
for(int i = 0; i < numWords; i++) {
|
||||
words.add("");
|
||||
}
|
||||
|
||||
ObservableList<String> wordEntryList = FXCollections.observableArrayList(words);
|
||||
wordEntriesProperty = new SimpleListProperty<>(wordEntryList);
|
||||
for(int i = 0; i < numWords; i++) {
|
||||
WordEntry wordEntry = new WordEntry(i, wordEntryList);
|
||||
wordsPane.getChildren().add(wordEntry);
|
||||
}
|
||||
|
||||
vBox.getChildren().add(wordsPane);
|
||||
|
||||
if(!displayWordsOnly) {
|
||||
PassphraseEntry passphraseEntry = new PassphraseEntry();
|
||||
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
|
||||
vBox.getChildren().add(passphraseEntry);
|
||||
|
||||
AnchorPane buttonPane = new AnchorPane();
|
||||
buttonPane.setPadding(new Insets(0, 26, 0, 10));
|
||||
|
||||
generateButton = new Button("Generate New");
|
||||
generateButton.setOnAction(event -> {
|
||||
generateNew();
|
||||
});
|
||||
generateButton.managedProperty().bind(generateButton.visibleProperty());
|
||||
generateButton.setTooltip(new Tooltip("Generate a unique set of words that provide the seed for your wallet"));
|
||||
buttonPane.getChildren().add(generateButton);
|
||||
AnchorPane.setLeftAnchor(generateButton, 0.0);
|
||||
|
||||
validLabel = new Label("Valid checksum", getValidGlyph());
|
||||
validLabel.setContentDisplay(ContentDisplay.LEFT);
|
||||
validLabel.setGraphicTextGap(5.0);
|
||||
validLabel.managedProperty().bind(validLabel.visibleProperty());
|
||||
validLabel.setVisible(false);
|
||||
buttonPane.getChildren().add(validLabel);
|
||||
AnchorPane.setTopAnchor(validLabel, 5.0);
|
||||
AnchorPane.setLeftAnchor(validLabel, 0.0);
|
||||
|
||||
invalidLabel = new Label("Invalid checksum", getInvalidGlyph());
|
||||
invalidLabel.setContentDisplay(ContentDisplay.LEFT);
|
||||
invalidLabel.setGraphicTextGap(5.0);
|
||||
invalidLabel.managedProperty().bind(invalidLabel.visibleProperty());
|
||||
invalidLabel.setVisible(false);
|
||||
buttonPane.getChildren().add(invalidLabel);
|
||||
AnchorPane.setTopAnchor(invalidLabel, 5.0);
|
||||
AnchorPane.setLeftAnchor(invalidLabel, 0.0);
|
||||
|
||||
confirmButton = new Button("Re-enter Words...");
|
||||
confirmButton.setOnAction(event -> {
|
||||
confirmBackup();
|
||||
});
|
||||
confirmButton.managedProperty().bind(confirmButton.visibleProperty());
|
||||
confirmButton.setVisible(false);
|
||||
confirmButton.setDefaultButton(true);
|
||||
confirmButton.setTooltip(new Tooltip("Re-enter the generated word list to confirm your backup is correct"));
|
||||
|
||||
calculateButton = new Button("Calculate Seed");
|
||||
calculateButton.setDisable(true);
|
||||
calculateButton.setDefaultButton(true);
|
||||
calculateButton.setOnAction(event -> {
|
||||
prepareImport();
|
||||
});
|
||||
calculateButton.managedProperty().bind(calculateButton.visibleProperty());
|
||||
calculateButton.setTooltip(new Tooltip("Calculate the seed from the provided word list"));
|
||||
|
||||
backButton = new Button("Back");
|
||||
backButton.setOnAction(event -> {
|
||||
displayMnemonicCode();
|
||||
});
|
||||
backButton.managedProperty().bind(backButton.visibleProperty());
|
||||
backButton.setTooltip(new Tooltip("Go back to the generated word list"));
|
||||
backButton.setVisible(false);
|
||||
|
||||
nextButton = new Button("Confirm Backup...");
|
||||
nextButton.setOnAction(event -> {
|
||||
confirmRecord();
|
||||
});
|
||||
nextButton.managedProperty().bind(nextButton.visibleProperty());
|
||||
nextButton.setTooltip(new Tooltip("Confirm you have recorded the generated word list"));
|
||||
nextButton.setVisible(false);
|
||||
nextButton.setDefaultButton(true);
|
||||
|
||||
wordEntriesProperty.addListener((ListChangeListener<String>) c -> {
|
||||
boolean empty = true;
|
||||
boolean validWords = true;
|
||||
boolean validChecksum = false;
|
||||
for(String word : wordEntryList) {
|
||||
if(!word.isEmpty()) {
|
||||
empty = false;
|
||||
}
|
||||
|
||||
if(!WordEntry.isValid(word)) {
|
||||
validWords = false;
|
||||
}
|
||||
}
|
||||
|
||||
if(!empty && validWords) {
|
||||
try {
|
||||
importer.getKeystore(wallet.getScriptType().getDefaultDerivation(), wordEntriesProperty.get(), passphraseProperty.get());
|
||||
validChecksum = true;
|
||||
} catch(ImportException e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
generateButton.setVisible(empty && generatedMnemonicCode == null);
|
||||
calculateButton.setDisable(!validChecksum);
|
||||
validLabel.setVisible(validChecksum);
|
||||
invalidLabel.setVisible(!validChecksum && !empty);
|
||||
});
|
||||
|
||||
HBox rightBox = new HBox();
|
||||
rightBox.setSpacing(10);
|
||||
rightBox.getChildren().addAll(backButton, nextButton, confirmButton, calculateButton);
|
||||
|
||||
buttonPane.getChildren().add(rightBox);
|
||||
AnchorPane.setRightAnchor(rightBox, 0.0);
|
||||
|
||||
vBox.getChildren().add(buttonPane);
|
||||
}
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
stackPane.getChildren().add(vBox);
|
||||
return stackPane;
|
||||
}
|
||||
|
||||
private void generateNew() {
|
||||
int mnemonicSeedLength = wordEntriesProperty.get().size() * 11;
|
||||
int entropyLength = mnemonicSeedLength - (mnemonicSeedLength/33);
|
||||
|
||||
DeterministicSeed deterministicSeed = new DeterministicSeed(new SecureRandom(), entropyLength, "");
|
||||
generatedMnemonicCode = deterministicSeed.getMnemonicCode();
|
||||
|
||||
displayMnemonicCode();
|
||||
}
|
||||
|
||||
private void displayMnemonicCode() {
|
||||
setDescription("Write down words before re-entering");
|
||||
showHideLink.setVisible(false);
|
||||
|
||||
calculateButton.setVisible(false);
|
||||
nextButton.setVisible(true);
|
||||
backButton.setVisible(false);
|
||||
|
||||
if(generatedMnemonicCode.size() != wordsPane.getChildren().size()) {
|
||||
throw new IllegalStateException("Generated mnemonic words list not same size as displayed words list");
|
||||
}
|
||||
|
||||
for (int i = 0; i < wordsPane.getChildren().size(); i++) {
|
||||
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
|
||||
wordEntry.getEditor().setText(generatedMnemonicCode.get(i));
|
||||
wordEntry.getEditor().setEditable(false);
|
||||
}
|
||||
|
||||
StackPane wordsStackPane = (StackPane)getContent();
|
||||
if(wordsStackPane.getChildren().size() > 1) {
|
||||
wordsStackPane.getChildren().remove(1);
|
||||
confirmButton.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void confirmRecord() {
|
||||
setDescription("Confirm words have been recorded");
|
||||
showHideLink.setVisible(false);
|
||||
|
||||
StackPane wordsPane = (StackPane)getContent();
|
||||
StackPane confirmPane = new StackPane();
|
||||
confirmPane.setMaxWidth(300);
|
||||
confirmPane.setMaxHeight(100);
|
||||
Region region = new Region();
|
||||
region.setMinWidth(confirmPane.getMaxWidth());
|
||||
region.setMinHeight(confirmPane.getMaxHeight());
|
||||
confirmPane.getStyleClass().add("box-overlay");
|
||||
Node wrappedRegion = Borders.wrap(region).lineBorder().innerPadding(0).outerPadding(0).buildAll();
|
||||
Label label = new Label("Have these " + wordEntriesProperty.get().size() + " words been written down?\nIn the next step, you will need to re-enter them.");
|
||||
confirmPane.getChildren().addAll(wrappedRegion, label);
|
||||
wordsPane.getChildren().add(confirmPane);
|
||||
|
||||
setExpanded(true);
|
||||
backButton.setVisible(true);
|
||||
nextButton.setVisible(false);
|
||||
confirmButton.setVisible(true);
|
||||
generateButton.setVisible(false);
|
||||
}
|
||||
|
||||
private void confirmBackup() {
|
||||
setDescription("Confirm backup by re-entering words");
|
||||
showHideLink.setVisible(false);
|
||||
setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size(), false));
|
||||
setExpanded(true);
|
||||
backButton.setVisible(true);
|
||||
generateButton.setVisible(false);
|
||||
}
|
||||
|
||||
private void prepareImport() {
|
||||
if(generatedMnemonicCode != null && !generatedMnemonicCode.equals(wordEntriesProperty.get())) {
|
||||
setError("Import Error", "Confirmation words did not match generated mnemonic");
|
||||
return;
|
||||
}
|
||||
|
||||
if(importKeystore(wallet.getScriptType().getDefaultDerivation(), true)) {
|
||||
setExpanded(true);
|
||||
enterMnemonicButton.setVisible(false);
|
||||
importButton.setVisible(true);
|
||||
importButton.setDisable(false);
|
||||
setDescription("Ready to import");
|
||||
showHideLink.setText("Show Derivation...");
|
||||
showHideLink.setVisible(true);
|
||||
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean importKeystore(List<ChildNumber> derivation, boolean dryrun) {
|
||||
importButton.setDisable(true);
|
||||
try {
|
||||
Keystore keystore = importer.getKeystore(derivation, wordEntriesProperty.get(), passphraseProperty.get());
|
||||
if(!dryrun) {
|
||||
EventManager.get().post(new KeystoreImportEvent(keystore));
|
||||
}
|
||||
return true;
|
||||
} catch (ImportException e) {
|
||||
String errorMessage = e.getMessage();
|
||||
if(e.getCause() instanceof MnemonicException.MnemonicChecksumException) {
|
||||
errorMessage = "Invalid word list - checksum incorrect";
|
||||
} else if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
setError("Import Error", errorMessage);
|
||||
importButton.setDisable(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static class WordEntry extends HBox {
|
||||
private static List<String> wordList;
|
||||
private final TextField wordField;
|
||||
|
||||
public WordEntry(int wordNumber, ObservableList<String> wordEntryList) {
|
||||
super();
|
||||
setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
setSpacing(10);
|
||||
Label label = new Label((wordNumber+1) + ".");
|
||||
label.setPrefWidth(22);
|
||||
label.setAlignment(Pos.CENTER_RIGHT);
|
||||
wordField = new TextField();
|
||||
wordField.setMaxWidth(100);
|
||||
|
||||
wordList = Bip39MnemonicCode.INSTANCE.getWordList();
|
||||
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
|
||||
autoCompletionBinding.setDelay(50);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
validationSupport.registerValidator(wordField, Validator.combine(
|
||||
Validator.createEmptyValidator("Word is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", !wordList.contains(newValue))
|
||||
));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
|
||||
wordField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
wordEntryList.set(wordNumber, newValue);
|
||||
});
|
||||
|
||||
this.getChildren().addAll(label, wordField);
|
||||
}
|
||||
|
||||
public TextField getEditor() {
|
||||
return wordField;
|
||||
}
|
||||
|
||||
public static boolean isValid(String word) {
|
||||
return wordList.contains(word);
|
||||
}
|
||||
}
|
||||
|
||||
private static class WordlistSuggestionProvider implements Callback<AutoCompletionBinding.ISuggestionRequest, Collection<String>> {
|
||||
private final List<String> wordList;
|
||||
|
||||
public WordlistSuggestionProvider(List<String> wordList) {
|
||||
this.wordList = wordList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> call(AutoCompletionBinding.ISuggestionRequest request) {
|
||||
List<String> suggestions = new ArrayList<>();
|
||||
if(!request.getUserText().isEmpty()) {
|
||||
for(String word : wordList) {
|
||||
if(word.equals(request.getUserText())) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if(word.startsWith(request.getUserText())) {
|
||||
suggestions.add(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
private class PassphraseEntry extends HBox {
|
||||
public PassphraseEntry() {
|
||||
super();
|
||||
|
||||
setAlignment(Pos.CENTER_LEFT);
|
||||
setSpacing(10);
|
||||
Label passphraseLabel = new Label("Passphrase:");
|
||||
CustomTextField passphraseField = (CustomTextField) TextFields.createClearableTextField();
|
||||
passphraseProperty.bind(passphraseField.textProperty());
|
||||
passphraseField.setPromptText("Leave blank for none");
|
||||
|
||||
HelpLabel helpLabel = new HelpLabel();
|
||||
helpLabel.setStyle("-fx-padding: 0 0 0 0");
|
||||
helpLabel.setHelpText("A passphrase provides optional added security - it is not stored so it must be remembered!");
|
||||
|
||||
getChildren().addAll(passphraseLabel, passphraseField, helpLabel);
|
||||
}
|
||||
}
|
||||
|
||||
private Node getDerivationEntry(List<ChildNumber> derivation) {
|
||||
TextField derivationField = new TextField();
|
||||
derivationField.setPromptText("Derivation path");
|
||||
derivationField.setText(KeyDerivation.writePath(derivation));
|
||||
HBox.setHgrow(derivationField, Priority.ALWAYS);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
validationSupport.registerValidator(derivationField, Validator.combine(
|
||||
Validator.createEmptyValidator("Derivation is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
|
||||
));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
|
||||
Button importDerivationButton = new Button("Import");
|
||||
importDerivationButton.setOnAction(event -> {
|
||||
showHideLink.setVisible(true);
|
||||
setExpanded(false);
|
||||
List<ChildNumber> importDerivation = KeyDerivation.parsePath(derivationField.getText());
|
||||
importKeystore(importDerivation, false);
|
||||
});
|
||||
|
||||
derivationField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue));
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setAlignment(Pos.TOP_RIGHT);
|
||||
contentBox.setSpacing(20);
|
||||
contentBox.getChildren().add(derivationField);
|
||||
contentBox.getChildren().add(importDerivationButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
contentBox.setPrefHeight(60);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private void showWordList(DeterministicSeed seed) {
|
||||
List<String> words = seed.getMnemonicCode();
|
||||
setContent(getMnemonicWordsEntry(words.size(), true));
|
||||
setExpanded(true);
|
||||
|
||||
for (int i = 0; i < wordsPane.getChildren().size(); i++) {
|
||||
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
|
||||
wordEntry.getEditor().setText(words.get(i));
|
||||
wordEntry.getEditor().setEditable(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static Glyph getValidGlyph() {
|
||||
Glyph validGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CHECK_CIRCLE);
|
||||
validGlyph.getStyleClass().add("valid-checksum");
|
||||
validGlyph.setFontSize(12);
|
||||
return validGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getInvalidGlyph() {
|
||||
Glyph invalidGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
|
||||
invalidGlyph.getStyleClass().add("invalid-checksum");
|
||||
invalidGlyph.setFontSize(12);
|
||||
return invalidGlyph;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.client.j2se.MatrixToImageConfig;
|
||||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
import com.sparrowwallet.hummingbird.LegacyUREncoder;
|
||||
import com.sparrowwallet.hummingbird.registry.RegistryType;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import com.sparrowwallet.hummingbird.UR;
|
||||
import com.sparrowwallet.hummingbird.UREncoder;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.util.Duration;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.tools.Borders;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public class QRDisplayDialog extends Dialog<UR> {
|
||||
private static final Logger log = LoggerFactory.getLogger(QRDisplayDialog.class);
|
||||
|
||||
private static final int MIN_FRAGMENT_LENGTH = 10;
|
||||
private static final int MAX_FRAGMENT_LENGTH = 100;
|
||||
|
||||
private static final int ANIMATION_PERIOD_MILLIS = 400;
|
||||
|
||||
private final UR ur;
|
||||
private final UREncoder encoder;
|
||||
|
||||
private final ImageView qrImageView;
|
||||
|
||||
private AnimateQRService animateQRService;
|
||||
private String currentPart;
|
||||
|
||||
private boolean useLegacyEncoding;
|
||||
private String[] legacyParts;
|
||||
private int legacyPartIndex;
|
||||
|
||||
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
|
||||
this(UR.fromBytes(type, data), addLegacyEncodingOption);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur) {
|
||||
this(ur, false);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption) {
|
||||
this.ur = ur;
|
||||
this.encoder = new UREncoder(ur, MAX_FRAGMENT_LENGTH, MIN_FRAGMENT_LENGTH, 0);
|
||||
|
||||
final DialogPane dialogPane = new QRDisplayDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
qrImageView = new ImageView();
|
||||
stackPane.getChildren().add(qrImageView);
|
||||
|
||||
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
|
||||
|
||||
nextPart();
|
||||
if(encoder.isSinglePart()) {
|
||||
qrImageView.setImage(getQrCode(currentPart));
|
||||
} else {
|
||||
animateQRService = new AnimateQRService();
|
||||
animateQRService.setPeriod(Duration.millis(ANIMATION_PERIOD_MILLIS));
|
||||
animateQRService.start();
|
||||
setOnCloseRequest(event -> {
|
||||
animateQRService.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().add(cancelButtonType);
|
||||
|
||||
if(addLegacyEncodingOption) {
|
||||
final ButtonType legacyEncodingButtonType = new javafx.scene.control.ButtonType("Use Legacy Encoding (Cobo Vault)", ButtonBar.ButtonData.LEFT);
|
||||
dialogPane.getButtonTypes().add(legacyEncodingButtonType);
|
||||
}
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(550);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(String data) {
|
||||
this.ur = null;
|
||||
this.encoder = null;
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
qrImageView = new ImageView();
|
||||
stackPane.getChildren().add(qrImageView);
|
||||
|
||||
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
|
||||
qrImageView.setImage(getQrCode(data));
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(550);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
|
||||
}
|
||||
|
||||
private void nextPart() {
|
||||
if(!useLegacyEncoding) {
|
||||
String fragment = encoder.nextPart();
|
||||
currentPart = fragment.toUpperCase();
|
||||
} else {
|
||||
currentPart = legacyParts[legacyPartIndex];
|
||||
legacyPartIndex++;
|
||||
if(legacyPartIndex > legacyParts.length - 1) {
|
||||
legacyPartIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Image getQrCode(String fragment) {
|
||||
try {
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, 480, 480);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
|
||||
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
|
||||
return new Image(bais);
|
||||
} catch(Exception e) {
|
||||
log.error("Error generating QR", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void setUseLegacyEncoding(boolean useLegacyEncoding) {
|
||||
if(useLegacyEncoding) {
|
||||
try {
|
||||
//Force to be bytes type for legacy encoding
|
||||
LegacyUREncoder legacyEncoder = new LegacyUREncoder(new UR(RegistryType.BYTES.toString(), ur.getCborBytes()));
|
||||
this.legacyParts = legacyEncoder.encode();
|
||||
this.useLegacyEncoding = true;
|
||||
|
||||
if(legacyParts.length == 1) {
|
||||
if(animateQRService != null) {
|
||||
animateQRService.cancel();
|
||||
}
|
||||
|
||||
nextPart();
|
||||
qrImageView.setImage(getQrCode(currentPart));
|
||||
} else if(!animateQRService.isRunning()) {
|
||||
animateQRService.reset();
|
||||
animateQRService.start();
|
||||
}
|
||||
} catch(UR.InvalidTypeException e) {
|
||||
//Can't happen
|
||||
}
|
||||
} else {
|
||||
this.useLegacyEncoding = false;
|
||||
|
||||
if(encoder.isSinglePart()) {
|
||||
if(animateQRService != null) {
|
||||
animateQRService.cancel();
|
||||
}
|
||||
|
||||
qrImageView.setImage(getQrCode(currentPart));
|
||||
} else if(!animateQRService.isRunning()) {
|
||||
animateQRService.reset();
|
||||
animateQRService.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AnimateQRService extends ScheduledService<Boolean> {
|
||||
@Override
|
||||
protected Task<Boolean> createTask() {
|
||||
return new Task<>() {
|
||||
protected Boolean call() throws ImportException {
|
||||
Image qrImage = getQrCode(currentPart);
|
||||
qrImageView.setImage(qrImage);
|
||||
nextPart();
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private class QRDisplayDialogPane extends DialogPane {
|
||||
@Override
|
||||
protected Node createButton(ButtonType buttonType) {
|
||||
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
|
||||
ToggleButton legacy = new ToggleButton(buttonType.getText());
|
||||
legacy.setGraphicTextGap(5);
|
||||
setLegacyGraphic(legacy, false);
|
||||
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(legacy, buttonData);
|
||||
legacy.selectedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
setUseLegacyEncoding(newValue);
|
||||
setLegacyGraphic(legacy, newValue);
|
||||
});
|
||||
|
||||
return legacy;
|
||||
}
|
||||
|
||||
return super.createButton(buttonType);
|
||||
}
|
||||
|
||||
private void setLegacyGraphic(ToggleButton legacy, boolean useLegacyEncoding) {
|
||||
if(useLegacyEncoding) {
|
||||
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE));
|
||||
} else {
|
||||
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE));
|
||||
}
|
||||
}
|
||||
|
||||
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
|
||||
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
|
||||
glyph.setFontSize(11);
|
||||
return glyph;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.github.sarxos.webcam.WebcamEvent;
|
||||
import com.github.sarxos.webcam.WebcamListener;
|
||||
import com.github.sarxos.webcam.WebcamResolution;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.P2PKHAddress;
|
||||
import com.sparrowwallet.drongo.address.P2SHAddress;
|
||||
import com.sparrowwallet.drongo.address.P2WPKHAddress;
|
||||
import com.sparrowwallet.drongo.crypto.*;
|
||||
import com.sparrowwallet.drongo.protocol.Base43;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.hummingbird.LegacyURDecoder;
|
||||
import com.sparrowwallet.hummingbird.registry.*;
|
||||
import com.sparrowwallet.hummingbird.ResultType;
|
||||
import com.sparrowwallet.hummingbird.UR;
|
||||
import com.sparrowwallet.hummingbird.URDecoder;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.tools.Borders;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.CharBuffer;
|
||||
import java.nio.charset.CharsetDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class);
|
||||
|
||||
private final URDecoder decoder;
|
||||
private final LegacyURDecoder legacyDecoder;
|
||||
private final WebcamService webcamService;
|
||||
private List<String> parts;
|
||||
|
||||
private boolean isUr;
|
||||
private QRScanDialog.Result result;
|
||||
|
||||
private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)");
|
||||
|
||||
private final ObjectProperty<WebcamResolution> webcamResolutionProperty = new SimpleObjectProperty<>(WebcamResolution.VGA);
|
||||
|
||||
public QRScanDialog() {
|
||||
this.decoder = new URDecoder();
|
||||
this.legacyDecoder = new LegacyURDecoder();
|
||||
|
||||
if(Config.get().isHdCapture()) {
|
||||
webcamResolutionProperty.set(WebcamResolution.HD);
|
||||
}
|
||||
|
||||
this.webcamService = new WebcamService(webcamResolutionProperty.get(), new QRScanListener());
|
||||
WebcamView webcamView = new WebcamView(webcamService);
|
||||
|
||||
final DialogPane dialogPane = new QRScanDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
stackPane.getChildren().add(webcamView.getView());
|
||||
|
||||
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
|
||||
|
||||
webcamService.resultProperty().addListener(new QRResultListener());
|
||||
webcamService.setOnFailed(failedEvent -> {
|
||||
Platform.runLater(() -> setResult(new Result(failedEvent.getSource().getException())));
|
||||
});
|
||||
webcamService.start();
|
||||
webcamResolutionProperty.addListener((observable, oldValue, newResolution) -> {
|
||||
if(newResolution != null) {
|
||||
setHeight(newResolution == WebcamResolution.HD ? (getHeight() - 100) : (getHeight() + 100));
|
||||
}
|
||||
webcamService.cancel();
|
||||
});
|
||||
|
||||
setOnCloseRequest(event -> {
|
||||
boolean isHdCapture = (webcamResolutionProperty.get() == WebcamResolution.HD);
|
||||
if(Config.get().isHdCapture() != isHdCapture) {
|
||||
Config.get().setHdCapture(isHdCapture);
|
||||
}
|
||||
|
||||
Platform.runLater(() -> webcamResolutionProperty.set(null));
|
||||
});
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
final ButtonType hdButtonType = new javafx.scene.control.ButtonType("Use HD Capture", ButtonBar.ButtonData.LEFT);
|
||||
dialogPane.getButtonTypes().addAll(hdButtonType, cancelButtonType);
|
||||
dialogPane.setPrefWidth(646);
|
||||
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 450 : 550);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null);
|
||||
}
|
||||
|
||||
private class QRResultListener implements ChangeListener<com.google.zxing.Result> {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends com.google.zxing.Result> observable, com.google.zxing.Result oldValue, com.google.zxing.Result qrResult) {
|
||||
if(result != null) {
|
||||
Platform.runLater(() -> setResult(result));
|
||||
}
|
||||
|
||||
//Try text first
|
||||
String qrtext = qrResult.getText();
|
||||
Matcher partMatcher = PART_PATTERN.matcher(qrtext);
|
||||
|
||||
if(isUr || qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) {
|
||||
isUr = true;
|
||||
|
||||
if(LegacyURDecoder.isLegacyURFragment(qrtext)) {
|
||||
legacyDecoder.receivePart(qrtext.toLowerCase());
|
||||
|
||||
if(legacyDecoder.isComplete()) {
|
||||
try {
|
||||
UR ur = legacyDecoder.decode();
|
||||
result = extractResultFromUR(ur);
|
||||
} catch(Exception e) {
|
||||
result = new Result(new URException(e.getMessage()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
decoder.receivePart(qrtext);
|
||||
|
||||
if(decoder.getResult() != null) {
|
||||
URDecoder.Result urResult = decoder.getResult();
|
||||
if(urResult.type == ResultType.SUCCESS) {
|
||||
result = extractResultFromUR(urResult.ur);
|
||||
} else {
|
||||
result = new Result(new URException(urResult.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if(partMatcher.matches()) {
|
||||
int m = Integer.parseInt(partMatcher.group(1));
|
||||
int n = Integer.parseInt(partMatcher.group(2));
|
||||
String payload = partMatcher.group(3);
|
||||
|
||||
if(parts == null) {
|
||||
parts = new ArrayList<>(n);
|
||||
IntStream.range(0, n).forEach(i -> parts.add(null));
|
||||
}
|
||||
parts.set(m - 1, payload);
|
||||
|
||||
if(parts.stream().filter(Objects::nonNull).count() == n) {
|
||||
String complete = String.join("", parts);
|
||||
try {
|
||||
PSBT psbt = PSBT.fromString(complete);
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as PSBT
|
||||
}
|
||||
|
||||
try {
|
||||
Transaction transaction = new Transaction(Utils.hexToBytes(complete));
|
||||
result = new Result(transaction);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as tx
|
||||
}
|
||||
|
||||
result = new Result(new ScanException("Parsed QR parts were not a PSBT or transaction"));
|
||||
}
|
||||
} else {
|
||||
PSBT psbt;
|
||||
Transaction transaction;
|
||||
BitcoinURI bitcoinURI;
|
||||
Address address;
|
||||
ExtendedKey extendedKey;
|
||||
try {
|
||||
extendedKey = ExtendedKey.fromDescriptor(qrtext);
|
||||
result = new Result(extendedKey);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not a valid xpub
|
||||
}
|
||||
|
||||
try {
|
||||
bitcoinURI = new BitcoinURI(qrtext);
|
||||
result = new Result(bitcoinURI);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not an BIP 21 URI
|
||||
}
|
||||
|
||||
try {
|
||||
address = Address.fromString(qrtext);
|
||||
result = new Result(address);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not an address
|
||||
}
|
||||
|
||||
try {
|
||||
psbt = PSBT.fromString(qrtext);
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not parseable as Base64 or hex
|
||||
}
|
||||
|
||||
try {
|
||||
psbt = new PSBT(qrResult.getRawBytes());
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not parseable as raw bytes
|
||||
}
|
||||
|
||||
try {
|
||||
transaction = new Transaction(Utils.hexToBytes(qrtext));
|
||||
result = new Result(transaction);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not parseable as hex
|
||||
}
|
||||
|
||||
try {
|
||||
transaction = new Transaction(qrResult.getRawBytes());
|
||||
result = new Result(transaction);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not parseable as raw bytes
|
||||
}
|
||||
|
||||
//Try Base43 used by Electrum
|
||||
try {
|
||||
psbt = new PSBT(Base43.decode(qrtext));
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not parseable as base43 decoded bytes
|
||||
}
|
||||
|
||||
try {
|
||||
transaction = new Transaction(Base43.decode(qrtext));
|
||||
result = new Result(transaction);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not parseable as base43 decoded bytes
|
||||
}
|
||||
|
||||
result = new Result(qrtext);
|
||||
}
|
||||
}
|
||||
|
||||
private Result extractResultFromUR(UR ur) {
|
||||
try {
|
||||
RegistryType urRegistryType = ur.getRegistryType();
|
||||
|
||||
if(urRegistryType.equals(RegistryType.BYTES)) {
|
||||
byte[] urBytes = (byte[])ur.decodeFromRegistry();
|
||||
try {
|
||||
PSBT psbt = new PSBT(urBytes);
|
||||
return new Result(psbt);
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as PSBT
|
||||
}
|
||||
|
||||
try {
|
||||
Transaction transaction = new Transaction(urBytes);
|
||||
return new Result(transaction);
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as tx
|
||||
}
|
||||
|
||||
try {
|
||||
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
|
||||
ByteBuffer buf = ByteBuffer.wrap(urBytes);
|
||||
CharBuffer charBuffer = decoder.decode(buf);
|
||||
return new Result(charBuffer.toString());
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as utf-8
|
||||
}
|
||||
|
||||
result = new Result(new URException("Parsed UR of type " + urRegistryType + " was not a PSBT, transaction or UTF-8 text"));
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_PSBT)) {
|
||||
CryptoPSBT cryptoPSBT = (CryptoPSBT)ur.decodeFromRegistry();
|
||||
try {
|
||||
PSBT psbt = new PSBT(cryptoPSBT.getPsbt());
|
||||
return new Result(psbt);
|
||||
} catch(Exception e) {
|
||||
log.error("Error parsing PSBT from UR type " + urRegistryType, e);
|
||||
return new Result(new URException("Error parsing PSBT from UR type " + urRegistryType, e));
|
||||
}
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_ADDRESS)) {
|
||||
CryptoAddress cryptoAddress = (CryptoAddress)ur.decodeFromRegistry();
|
||||
Address address = getAddress(cryptoAddress);
|
||||
if(address != null) {
|
||||
return new Result(BitcoinURI.fromAddress(address));
|
||||
} else {
|
||||
return new Result(new URException("Unknown " + urRegistryType + " type of " + cryptoAddress.getType()));
|
||||
}
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_HDKEY)) {
|
||||
CryptoHDKey cryptoHDKey = (CryptoHDKey)ur.decodeFromRegistry();
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
|
||||
return new Result(extendedKey);
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_OUTPUT)) {
|
||||
CryptoOutput cryptoOutput = (CryptoOutput)ur.decodeFromRegistry();
|
||||
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
|
||||
return new Result(outputDescriptor);
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_ACCOUNT)) {
|
||||
CryptoAccount cryptoAccount = (CryptoAccount)ur.decodeFromRegistry();
|
||||
List<Wallet> wallets = getWallets(cryptoAccount);
|
||||
return new Result(wallets);
|
||||
} else {
|
||||
log.error("Unsupported UR type " + urRegistryType);
|
||||
return new Result(new URException("UR type " + urRegistryType + " is not supported"));
|
||||
}
|
||||
} catch(IllegalArgumentException e) {
|
||||
log.error("Unknown UR type of " + ur.getType(), e);
|
||||
return new Result(new URException("Unknown UR type of " + ur.getType(), e));
|
||||
} catch(UR.InvalidCBORException e) {
|
||||
log.error("Invalid CBOR in UR", e);
|
||||
return new Result(new URException("Invalid CBOR in UR", e));
|
||||
} catch(Exception e) {
|
||||
log.error("Error parsing UR CBOR", e);
|
||||
return new Result(new URException("Error parsing UR CBOR", e));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Address getAddress(CryptoAddress cryptoAddress) {
|
||||
Address address = null;
|
||||
if(cryptoAddress.getType() == null || cryptoAddress.getType() == CryptoAddress.Type.P2PKH) {
|
||||
address = new P2PKHAddress(cryptoAddress.getData());
|
||||
} else if(cryptoAddress.getType() == CryptoAddress.Type.P2SH) {
|
||||
address = new P2SHAddress(cryptoAddress.getData());
|
||||
} else if(cryptoAddress.getType() == CryptoAddress.Type.P2WPKH) {
|
||||
address = new P2WPKHAddress(cryptoAddress.getData());
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
private ExtendedKey getExtendedKey(CryptoHDKey cryptoHDKey) {
|
||||
if(cryptoHDKey.isPrivateKey()) {
|
||||
DeterministicKey prvKey = HDKeyDerivation.createMasterPrivKeyFromBytes(Arrays.copyOfRange(cryptoHDKey.getKey(), 1, 33), cryptoHDKey.getChainCode(), List.of(ChildNumber.ZERO));
|
||||
return new ExtendedKey(prvKey, new byte[4], ChildNumber.ZERO);
|
||||
} else {
|
||||
ChildNumber lastChild = ChildNumber.ZERO;
|
||||
int depth = 1;
|
||||
byte[] parentFingerprint = new byte[4];
|
||||
if(cryptoHDKey.getOrigin() != null) {
|
||||
if(!cryptoHDKey.getOrigin().getComponents().isEmpty()) {
|
||||
PathComponent lastComponent = cryptoHDKey.getOrigin().getComponents().get(cryptoHDKey.getOrigin().getComponents().size() - 1);
|
||||
lastChild = new ChildNumber(lastComponent.getIndex(), lastComponent.isHardened());
|
||||
depth = cryptoHDKey.getOrigin().getComponents().size();
|
||||
}
|
||||
if(cryptoHDKey.getParentFingerprint() != null) {
|
||||
parentFingerprint = cryptoHDKey.getParentFingerprint();
|
||||
}
|
||||
}
|
||||
DeterministicKey pubKey = new DeterministicKey(List.of(lastChild), cryptoHDKey.getChainCode(), cryptoHDKey.getKey(), depth, parentFingerprint);
|
||||
return new ExtendedKey(pubKey, parentFingerprint, lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
private OutputDescriptor getOutputDescriptor(CryptoOutput cryptoOutput) {
|
||||
ScriptType scriptType = getScriptType(cryptoOutput.getScriptExpressions());
|
||||
|
||||
if(cryptoOutput.getMultiKey() != null) {
|
||||
MultiKey multiKey = cryptoOutput.getMultiKey();
|
||||
Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>();
|
||||
for(CryptoHDKey cryptoHDKey : multiKey.getHdKeys()) {
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
|
||||
KeyDerivation keyDerivation = getKeyDerivation(cryptoHDKey.getOrigin());
|
||||
extendedPublicKeys.put(extendedKey, keyDerivation);
|
||||
}
|
||||
return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys);
|
||||
} else if(cryptoOutput.getEcKey() != null) {
|
||||
throw new IllegalArgumentException("EC keys are currently unsupported");
|
||||
} else if(cryptoOutput.getHdKey() != null) {
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoOutput.getHdKey());
|
||||
KeyDerivation keyDerivation = getKeyDerivation(cryptoOutput.getHdKey().getOrigin());
|
||||
return new OutputDescriptor(scriptType, extendedKey, keyDerivation);
|
||||
}
|
||||
|
||||
throw new IllegalStateException("CryptoOutput did not contain sufficient information");
|
||||
}
|
||||
|
||||
private List<Wallet> getWallets(CryptoAccount cryptoAccount) {
|
||||
List<Wallet> wallets = new ArrayList<>();
|
||||
String masterFingerprint = Utils.bytesToHex(cryptoAccount.getMasterFingerprint());
|
||||
for(CryptoOutput cryptoOutput : cryptoAccount.getOutputDescriptors()) {
|
||||
Wallet wallet = new Wallet();
|
||||
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
|
||||
if(outputDescriptor.isMultisig()) {
|
||||
throw new IllegalStateException("Multisig output descriptors are unsupported in CryptoAccount");
|
||||
}
|
||||
|
||||
ExtendedKey extendedKey = outputDescriptor.getSingletonExtendedPublicKey();
|
||||
wallet.setScriptType(outputDescriptor.getScriptType());
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, outputDescriptor.getKeyDerivation(extendedKey).getDerivationPath()));
|
||||
keystore.setExtendedPublicKey(extendedKey);
|
||||
wallet.getKeystores().add(keystore);
|
||||
wallets.add(wallet);
|
||||
}
|
||||
|
||||
return wallets;
|
||||
}
|
||||
|
||||
private ScriptType getScriptType(List<ScriptExpression> expressions) {
|
||||
if(List.of(ScriptExpression.PUBLIC_KEY_HASH).equals(expressions)) {
|
||||
return ScriptType.P2PKH;
|
||||
} else if(List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_PUBLIC_KEY_HASH).equals(expressions)) {
|
||||
return ScriptType.P2SH_P2WPKH;
|
||||
} else if(List.of(ScriptExpression.WITNESS_PUBLIC_KEY_HASH).equals(expressions)) {
|
||||
return ScriptType.P2WPKH;
|
||||
} else if(List.of(ScriptExpression.SCRIPT_HASH).equals(expressions)) {
|
||||
return ScriptType.P2SH;
|
||||
} else if(List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_SCRIPT_HASH).equals(expressions)) {
|
||||
return ScriptType.P2SH_P2WSH;
|
||||
} else if(List.of(ScriptExpression.WITNESS_SCRIPT_HASH).equals(expressions)) {
|
||||
return ScriptType.P2WSH;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unknown script of " + expressions);
|
||||
}
|
||||
|
||||
private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) {
|
||||
if(cryptoKeypath != null) {
|
||||
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), cryptoKeypath.getPath());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private class QRScanListener implements WebcamListener {
|
||||
@Override
|
||||
public void webcamOpen(WebcamEvent webcamEvent) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void webcamClosed(WebcamEvent webcamEvent) {
|
||||
if(webcamResolutionProperty.get() != null) {
|
||||
webcamService.setResolution(webcamResolutionProperty.get());
|
||||
Platform.runLater(() -> {
|
||||
if(!webcamService.isRunning()) {
|
||||
webcamService.reset();
|
||||
webcamService.start();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void webcamDisposed(WebcamEvent webcamEvent) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void webcamImageObtained(WebcamEvent webcamEvent) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private class QRScanDialogPane extends DialogPane {
|
||||
@Override
|
||||
protected Node createButton(ButtonType buttonType) {
|
||||
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
|
||||
ToggleButton hd = new ToggleButton(buttonType.getText());
|
||||
hd.setSelected(webcamResolutionProperty.get() == WebcamResolution.HD);
|
||||
hd.setGraphicTextGap(5);
|
||||
setHdGraphic(hd, hd.isSelected());
|
||||
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(hd, buttonData);
|
||||
hd.selectedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
webcamResolutionProperty.set(newValue ? WebcamResolution.HD : WebcamResolution.VGA);
|
||||
setHdGraphic(hd, newValue);
|
||||
});
|
||||
|
||||
return hd;
|
||||
}
|
||||
|
||||
return super.createButton(buttonType);
|
||||
}
|
||||
|
||||
private void setHdGraphic(ToggleButton hd, boolean isHd) {
|
||||
if(isHd) {
|
||||
hd.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE));
|
||||
} else {
|
||||
hd.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE));
|
||||
}
|
||||
}
|
||||
|
||||
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
|
||||
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
|
||||
glyph.setFontSize(11);
|
||||
return glyph;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Result {
|
||||
public final Transaction transaction;
|
||||
public final PSBT psbt;
|
||||
public final BitcoinURI uri;
|
||||
public final ExtendedKey extendedKey;
|
||||
public final OutputDescriptor outputDescriptor;
|
||||
public final List<Wallet> wallets;
|
||||
public final String payload;
|
||||
public final Throwable exception;
|
||||
|
||||
public Result(Transaction transaction) {
|
||||
this.transaction = transaction;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(PSBT psbt) {
|
||||
this.transaction = null;
|
||||
this.psbt = psbt;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(BitcoinURI uri) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = uri;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(Address address) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = BitcoinURI.fromAddress(address);
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(ExtendedKey extendedKey) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = extendedKey;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(OutputDescriptor outputDescriptor) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = outputDescriptor;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(List<Wallet> wallets) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = wallets;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(String payload) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = payload;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(Throwable exception) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = exception;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ScanException extends Exception {
|
||||
public ScanException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public ScanException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ScanException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public ScanException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
public static class URException extends ScanException {
|
||||
public URException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public URException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public URException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public URException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.util.Callback;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class RecursiveTreeItem<T> extends TreeItem<T> {
|
||||
private final Callback<T, ObservableList<T>> childrenFactory;
|
||||
private final Callback<T, Node> graphicsFactory;
|
||||
|
||||
public RecursiveTreeItem(Callback<T, ObservableList<T>> childrenFactory){
|
||||
this(null, childrenFactory);
|
||||
}
|
||||
|
||||
public RecursiveTreeItem(final T value, Callback<T, ObservableList<T>> childrenFactory){
|
||||
this(value, (item) -> null, childrenFactory);
|
||||
}
|
||||
|
||||
public RecursiveTreeItem(final T value, Callback<T, Node> graphicsFactory, Callback<T, ObservableList<T>> childrenFactory){
|
||||
super(value, graphicsFactory.call(value));
|
||||
|
||||
this.graphicsFactory = graphicsFactory;
|
||||
this.childrenFactory = childrenFactory;
|
||||
|
||||
if(value != null) {
|
||||
addChildrenListener(value);
|
||||
}
|
||||
|
||||
valueProperty().addListener((obs, oldValue, newValue)->{
|
||||
if(newValue != null){
|
||||
addChildrenListener(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
this.setExpanded(false);
|
||||
}
|
||||
|
||||
private void addChildrenListener(T value){
|
||||
final ObservableList<T> children = childrenFactory.call(value);
|
||||
|
||||
children.forEach(child -> RecursiveTreeItem.this.getChildren().add(
|
||||
new RecursiveTreeItem<>(child, this.graphicsFactory, childrenFactory)));
|
||||
|
||||
children.addListener((ListChangeListener<T>) change -> {
|
||||
while(change.next()){
|
||||
|
||||
if(change.wasAdded()){
|
||||
if(change.getFrom() >= RecursiveTreeItem.this.getChildren().size()) {
|
||||
change.getAddedSubList().forEach(t-> RecursiveTreeItem.this.getChildren().add(new RecursiveTreeItem<>(t, this.graphicsFactory, childrenFactory)));
|
||||
} else {
|
||||
change.getAddedSubList().forEach(t-> RecursiveTreeItem.this.getChildren().add(change.getFrom(), new RecursiveTreeItem<>(t, this.graphicsFactory, childrenFactory)));
|
||||
}
|
||||
}
|
||||
|
||||
if(change.wasRemoved()){
|
||||
change.getRemoved().forEach(t->{
|
||||
final List<TreeItem<T>> itemsToRemove = RecursiveTreeItem.this
|
||||
.getChildren()
|
||||
.stream()
|
||||
.filter(treeItem -> treeItem.getValue().equals(t))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
RecursiveTreeItem.this.getChildren().removeAll(itemsToRemove);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Script;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptChunk;
|
||||
import javafx.geometry.Pos;
|
||||
import org.controlsfx.control.decoration.Decorator;
|
||||
import org.controlsfx.control.decoration.GraphicDecoration;
|
||||
import org.fxmisc.richtext.CodeArea;
|
||||
|
||||
import static com.sparrowwallet.drongo.protocol.ScriptType.*;
|
||||
|
||||
public class ScriptArea extends CodeArea {
|
||||
private Script script;
|
||||
|
||||
public void appendScript(Script script) {
|
||||
appendScript(script, null, null);
|
||||
}
|
||||
|
||||
public void appendScript(Script script, Script redeemScript, Script witnessScript) {
|
||||
if(this.script == null) {
|
||||
this.script = script;
|
||||
ScriptContextMenu contextMenu = new ScriptContextMenu(this, script);
|
||||
setContextMenu(contextMenu);
|
||||
}
|
||||
|
||||
if(P2PKH.isScriptType(script)) {
|
||||
append(script.getChunks().get(0).toString(), "script-opcode");
|
||||
append(" ", "");
|
||||
append(script.getChunks().get(1).toString(), "script-opcode");
|
||||
append(" ", "");
|
||||
append("<pkh>", "script-hash");
|
||||
append(" ", "");
|
||||
append(script.getChunks().get(3).toString(), "script-opcode");
|
||||
append(" ", "");
|
||||
append(script.getChunks().get(4).toString(), "script-opcode");
|
||||
} else if(P2SH.isScriptType(script)) {
|
||||
append(script.getChunks().get(0).toString(), "script-opcode");
|
||||
append(" ", "");
|
||||
append("<sh>", "script-hash");
|
||||
append(" ", "");
|
||||
append(script.getChunks().get(2).toString(), "script-opcode");
|
||||
} else if(P2WPKH.isScriptType(script)) {
|
||||
append(script.getChunks().get(0).toString(), "script-opcode");
|
||||
append(" ", "");
|
||||
append("<wpkh>", "script-hash");
|
||||
} else if(P2WSH.isScriptType(script)) {
|
||||
append(script.getChunks().get(0).toString(), "script-opcode");
|
||||
append(" ", "");
|
||||
append("<wsh>", "script-hash");
|
||||
} else {
|
||||
int signatureCount = 1;
|
||||
int pubKeyCount = 1;
|
||||
for (int i = 0; i < script.getChunks().size(); i++) {
|
||||
ScriptChunk chunk = script.getChunks().get(i);
|
||||
if(chunk.isOpCode()) {
|
||||
append(chunk.toString(), "script-opcode");
|
||||
} else if(chunk.isSignature()) {
|
||||
append("<signature" + signatureCount++ + ">", "script-signature");
|
||||
} else if(chunk.isPubKey()) {
|
||||
append("<pubkey" + pubKeyCount++ + ">", "script-pubkey");
|
||||
} else if(chunk.isScript()) {
|
||||
Script nestedScript = chunk.getScript();
|
||||
if (nestedScript.equals(redeemScript)) {
|
||||
append("<RedeemScript>", "script-redeem");
|
||||
} else if(nestedScript.equals(witnessScript)) {
|
||||
append("<WitnessScript>", "script-redeem");
|
||||
} else {
|
||||
append("(", "script-nest");
|
||||
appendScript(nestedScript);
|
||||
append(")", "script-nest");
|
||||
}
|
||||
} else {
|
||||
append(chunk.toString(), "script-other");
|
||||
}
|
||||
|
||||
if(i < script.getChunks().size() - 1) {
|
||||
append(" ", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
super.clear();
|
||||
this.script = null;
|
||||
setDisable(false);
|
||||
setContextMenu(null);
|
||||
Decorator.removeAllDecorations(this);
|
||||
}
|
||||
|
||||
public void addPSBTDecoration(String description, String styleClass) {
|
||||
Decorator.addDecoration(this, new GraphicDecoration(new TextDecoration("PSBT", description, styleClass), Pos.TOP_RIGHT));
|
||||
}
|
||||
|
||||
public Script getScript() {
|
||||
return script;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.sparrowwallet.sparrow.transaction;
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Script;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptChunk;
|
||||
@@ -33,7 +33,7 @@ public class ScriptContextMenu extends ContextMenu {
|
||||
});
|
||||
|
||||
getItems().add(copyvalue);
|
||||
this.setStyle("-fx-background-color: -fx-color; -fx-font-family: sans-serif; -fx-font-size: 1em;");
|
||||
this.setStyle("-fx-background-color: -fx-color; -fx-font-family: System; -fx-font-size: 1em;");
|
||||
|
||||
area.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
|
||||
hoverChunk = null;
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
public class SeedDisplayDialog extends Dialog<Void> {
|
||||
public SeedDisplayDialog(Keystore decryptedKeystore) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
int lines = decryptedKeystore.getSeed().getMnemonicCode().size() / 3;
|
||||
int height = lines * 40;
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
dialogPane.setContent(stackPane);
|
||||
|
||||
AnchorPane anchorPane = new AnchorPane();
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.getStyleClass().add("edge-to-edge");
|
||||
scrollPane.setPrefHeight(74 + height);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
anchorPane.getChildren().add(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
AnchorPane.setLeftAnchor(scrollPane, 0.0);
|
||||
AnchorPane.setRightAnchor(scrollPane, 0.0);
|
||||
|
||||
Accordion keystoreAccordion = new Accordion();
|
||||
scrollPane.setContent(keystoreAccordion);
|
||||
|
||||
MnemonicKeystoreImportPane keystorePane = new MnemonicKeystoreImportPane(decryptedKeystore);
|
||||
keystorePane.setAnimated(false);
|
||||
keystoreAccordion.getPanes().add(keystorePane);
|
||||
|
||||
stackPane.getChildren().addAll(anchorPane);
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(150 + height);
|
||||
|
||||
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.TransactionSignature;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreSignedEvent;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.collections.MapChangeListener;
|
||||
import javafx.collections.ObservableMap;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ProgressBar;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.util.Duration;
|
||||
import org.controlsfx.control.SegmentedBar;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SignaturesProgressBar extends SegmentedBar<SignaturesProgressBar.SignatureProgressSegment> {
|
||||
public SignaturesProgressBar() {
|
||||
setOrientation(Orientation.HORIZONTAL);
|
||||
setSegmentViewFactory(SignatureProgressSegmentView::new);
|
||||
setInfoNodeFactory(segment -> segment.getKeystore() == null ? null : new SignatureProgressSegmentLabel(segment.getKeystore().getLabel()));
|
||||
}
|
||||
|
||||
public void initialize(ObservableMap<TransactionSignature, Keystore> signatureKeystoreMap, int threshold) {
|
||||
getStyleClass().add("signatures-progress-bar");
|
||||
getSegments().clear();
|
||||
|
||||
List<Keystore> signedKeystores = new ArrayList<>(signatureKeystoreMap.values());
|
||||
int numSegments = Math.max(threshold, signedKeystores.size());
|
||||
double segmentSize = 100d / numSegments;
|
||||
for(int i = 0; i < numSegments; i++) {
|
||||
if(i < signedKeystores.size()) {
|
||||
getSegments().add(new SignatureProgressSegment(segmentSize, i, signedKeystores.get(i)));
|
||||
} else {
|
||||
getSegments().add(new SignatureProgressSegment(segmentSize, i, null));
|
||||
}
|
||||
}
|
||||
|
||||
signatureKeystoreMap.addListener((MapChangeListener<TransactionSignature, Keystore>) c -> {
|
||||
List<Keystore> newSignedKeystores = new ArrayList<>(c.getMap().values());
|
||||
int newNumSegments = Math.max(threshold, newSignedKeystores.size());
|
||||
double newSegmentSize = 100d / newNumSegments;
|
||||
|
||||
for(int i = 0; i < newNumSegments; i++) {
|
||||
SignatureProgressSegment segment = null;
|
||||
if(i < getSegments().size()) {
|
||||
segment = getSegments().get(i);
|
||||
}
|
||||
|
||||
Keystore signedKeystore = null;
|
||||
if(i < newSignedKeystores.size()) {
|
||||
signedKeystore = newSignedKeystores.get(i);
|
||||
}
|
||||
|
||||
if(segment != null) {
|
||||
//Animate new signature if changed
|
||||
segment.setKeystore(signedKeystore);
|
||||
} else {
|
||||
//Add extra (unnecessary) signature
|
||||
for(SignaturesProgressBar.SignatureProgressSegment existingSegment : getSegments()) {
|
||||
existingSegment.setValue(newSegmentSize);
|
||||
}
|
||||
|
||||
SignaturesProgressBar.SignatureProgressSegment newSegment = new SignatureProgressSegment(newSegmentSize, i, null);
|
||||
getSegments().add(newSegment);
|
||||
newSegment.setKeystore(signedKeystore);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static class SignatureProgressSegment extends SegmentedBar.Segment {
|
||||
private final SimpleObjectProperty<Keystore> keystoreProperty;
|
||||
private final int index;
|
||||
|
||||
public SignatureProgressSegment(double value, int index, Keystore keystore) {
|
||||
super(value);
|
||||
this.index = index;
|
||||
|
||||
this.keystoreProperty = new SimpleObjectProperty<>(this, "keystore", null);
|
||||
keystoreProperty.addListener((observable, oldValue, newValue) -> {
|
||||
setText(newValue == null ? "No keystore" : newValue.getLabel());
|
||||
});
|
||||
|
||||
setKeystore(keystore);
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
public Keystore getKeystore() {
|
||||
return keystoreProperty.get();
|
||||
}
|
||||
|
||||
public SimpleObjectProperty<Keystore> keystoreProperty() {
|
||||
return keystoreProperty;
|
||||
}
|
||||
|
||||
public void setKeystore(Keystore keystore) {
|
||||
keystoreProperty.set(keystore);
|
||||
}
|
||||
|
||||
public void signatureCompleted() {
|
||||
EventManager.get().post(new KeystoreSignedEvent(getKeystore()));
|
||||
}
|
||||
}
|
||||
|
||||
public static class SignatureProgressSegmentView extends StackPane {
|
||||
private final ProgressBar progressBar;
|
||||
private final Label label;
|
||||
|
||||
public SignatureProgressSegmentView(SignatureProgressSegment segment) {
|
||||
getStyleClass().add("signature-progress-segment");
|
||||
getStyleClass().add("segment" + segment.getIndex());
|
||||
|
||||
label = new Label();
|
||||
label.textProperty().bind(segment.textProperty());
|
||||
label.getStyleClass().add("signature-progress-segment-label");
|
||||
StackPane.setAlignment(label, Pos.CENTER);
|
||||
|
||||
progressBar = new ProgressBar(segment.getKeystore() == null ? 0.0 : 1.0);
|
||||
progressBar.setPrefWidth(Double.MAX_VALUE);
|
||||
progressBar.setPrefHeight(30);
|
||||
|
||||
setPrefHeight(50);
|
||||
getChildren().addAll(progressBar, label);
|
||||
|
||||
segment.keystoreProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(oldValue == null && newValue != null) {
|
||||
Timeline timeline = new Timeline(
|
||||
new KeyFrame(Duration.ZERO, new KeyValue(progressBar.progressProperty(), 0)),
|
||||
new KeyFrame(Duration.millis(800), e -> {
|
||||
segment.signatureCompleted();
|
||||
}, new KeyValue(progressBar.progressProperty(), 1))
|
||||
);
|
||||
timeline.setCycleCount(1);
|
||||
timeline.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
label.setVisible(label.prefWidth(-1) < getWidth() - getPadding().getLeft() - getPadding().getRight() - 8);
|
||||
}
|
||||
}
|
||||
|
||||
public static class SignatureProgressSegmentLabel extends Label {
|
||||
public SignatureProgressSegmentLabel(String text) {
|
||||
super(text);
|
||||
setPadding(new Insets(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
|
||||
public class TextAreaDialog extends Dialog<String> {
|
||||
private final TextArea textArea;
|
||||
@@ -20,11 +22,15 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
this.textArea = new TextArea(defaultValue);
|
||||
this.textArea.setMaxWidth(Double.MAX_VALUE);
|
||||
this.textArea.setWrapText(true);
|
||||
this.textArea.getStyleClass().add("fixed-width");
|
||||
hbox.getChildren().add(textArea);
|
||||
HBox.setHgrow(this.textArea, Priority.ALWAYS);
|
||||
|
||||
this.defaultValue = defaultValue;
|
||||
|
||||
getDialogPane().setContent(hbox);
|
||||
dialogPane.setContent(hbox);
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
dialogPane.getStyleClass().add("text-input-dialog");
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
@@ -35,6 +41,9 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
|
||||
return data == ButtonBar.ButtonData.OK_DONE ? textArea.getText() : null;
|
||||
});
|
||||
|
||||
dialogPane.setPrefWidth(700);
|
||||
dialogPane.setPrefHeight(400);
|
||||
}
|
||||
|
||||
public final TextArea getEditor() {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.control.TextFormatter;
|
||||
import javafx.scene.control.TextFormatter.Change;
|
||||
|
||||
public class TextFieldValidator {
|
||||
|
||||
private static final String CURRENCY_SYMBOL = DecimalFormatSymbols.getInstance().getCurrencySymbol();
|
||||
private static final String DECIMAL_SEPARATOR = ".";
|
||||
|
||||
private final Pattern INPUT_PATTERN;
|
||||
|
||||
public TextFieldValidator(@NamedArg("modus") ValidationModus modus, @NamedArg("countOf") int countOf) {
|
||||
this(modus.createPattern(countOf));
|
||||
}
|
||||
|
||||
public TextFieldValidator(@NamedArg("regex") String regex) {
|
||||
this(Pattern.compile(regex));
|
||||
}
|
||||
|
||||
public TextFieldValidator(Pattern inputPattern) {
|
||||
INPUT_PATTERN = inputPattern;
|
||||
}
|
||||
|
||||
public static TextFieldValidator maxFractionDigits(int countOf) {
|
||||
return new TextFieldValidator(maxFractionPattern(countOf));
|
||||
}
|
||||
|
||||
public static TextFieldValidator maxIntegers(int countOf) {
|
||||
return new TextFieldValidator(maxIntegerPattern(countOf));
|
||||
}
|
||||
|
||||
public static TextFieldValidator integersOnly() {
|
||||
return new TextFieldValidator(integersOnlyPattern());
|
||||
}
|
||||
|
||||
public TextFormatter<String> getFormatter() {
|
||||
return new TextFormatter<>(TextFormatter.IDENTITY_STRING_CONVERTER, "", this::validateChange);
|
||||
}
|
||||
|
||||
private Change validateChange(Change c) {
|
||||
if (validate(c.getControlNewText())) {
|
||||
return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean validate(String input) {
|
||||
return INPUT_PATTERN.matcher(input).matches();
|
||||
}
|
||||
|
||||
private static Pattern maxFractionPattern(int countOf) {
|
||||
return Pattern.compile("\\d*(\\" + DECIMAL_SEPARATOR + "\\d{0," + countOf + "})?");
|
||||
}
|
||||
|
||||
private static Pattern maxCurrencyFractionPattern(int countOf) {
|
||||
return Pattern.compile("^\\\\" + CURRENCY_SYMBOL + "?\\s?\\d*(\\" + DECIMAL_SEPARATOR + "\\d{0," + countOf + "})?\\s?\\\\" + CURRENCY_SYMBOL + "?");
|
||||
}
|
||||
|
||||
private static Pattern maxIntegerPattern(int countOf) {
|
||||
return Pattern.compile("\\d{0," + countOf + "}");
|
||||
}
|
||||
|
||||
private static Pattern integersOnlyPattern() {
|
||||
return Pattern.compile("\\d*");
|
||||
}
|
||||
|
||||
public enum ValidationModus {
|
||||
MAX_CURRENCY_FRACTION_DIGITS {
|
||||
@Override
|
||||
public Pattern createPattern(int countOf) {
|
||||
return maxCurrencyFractionPattern(countOf);
|
||||
}
|
||||
},
|
||||
|
||||
MAX_FRACTION_DIGITS {
|
||||
@Override
|
||||
public Pattern createPattern(int countOf) {
|
||||
return maxFractionPattern(countOf);
|
||||
}
|
||||
},
|
||||
MAX_INTEGERS {
|
||||
@Override
|
||||
public Pattern createPattern(int countOf) {
|
||||
return maxIntegerPattern(countOf);
|
||||
}
|
||||
},
|
||||
|
||||
INTEGERS_ONLY {
|
||||
@Override
|
||||
public Pattern createPattern(int countOf) {
|
||||
return integersOnlyPattern();
|
||||
}
|
||||
};
|
||||
|
||||
public abstract Pattern createPattern(int countOf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
public class TitledDescriptionPane extends TitledPane {
|
||||
private Label descriptionLabel;
|
||||
protected Hyperlink showHideLink;
|
||||
protected HBox buttonBox;
|
||||
|
||||
public TitledDescriptionPane(String title, String description, String content, String imageUrl) {
|
||||
getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
getStyleClass().add("titled-description-pane");
|
||||
|
||||
setPadding(Insets.EMPTY);
|
||||
setGraphic(getTitle(title, description, imageUrl));
|
||||
setContent(getContentBox(content));
|
||||
removeArrow();
|
||||
}
|
||||
|
||||
protected Node getTitle(String title, String description, String imageUrl) {
|
||||
HBox listItem = new HBox();
|
||||
listItem.setPadding(new Insets(10, 20, 10, 10));
|
||||
listItem.setSpacing(10);
|
||||
|
||||
HBox imageBox = new HBox();
|
||||
imageBox.setMinWidth(50);
|
||||
imageBox.setMinHeight(50);
|
||||
listItem.getChildren().add(imageBox);
|
||||
|
||||
Image image = new Image(imageUrl, 50, 50, true, true);
|
||||
if (!image.isError()) {
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setImage(image);
|
||||
imageBox.getChildren().add(imageView);
|
||||
}
|
||||
|
||||
VBox labelsBox = new VBox();
|
||||
labelsBox.setSpacing(5);
|
||||
labelsBox.setAlignment(Pos.CENTER_LEFT);
|
||||
Label mainLabel = new Label();
|
||||
mainLabel.setText(title);
|
||||
mainLabel.getStyleClass().add("main-label");
|
||||
labelsBox.getChildren().add(mainLabel);
|
||||
|
||||
HBox descriptionBox = new HBox();
|
||||
descriptionBox.setSpacing(7);
|
||||
labelsBox.getChildren().add(descriptionBox);
|
||||
|
||||
descriptionLabel = new Label(description);
|
||||
descriptionLabel.getStyleClass().add("description-label");
|
||||
showHideLink = new Hyperlink("Details...");
|
||||
showHideLink.managedProperty().bind(showHideLink.visibleProperty());
|
||||
showHideLink.setOnAction(event -> {
|
||||
setExpanded(!this.isExpanded());
|
||||
});
|
||||
this.expandedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(newValue) {
|
||||
showHideLink.setText(showHideLink.getText().replace("Show", "Hide"));
|
||||
} else {
|
||||
showHideLink.setText(showHideLink.getText().replace("Hide", "Show"));
|
||||
}
|
||||
});
|
||||
descriptionBox.getChildren().addAll(descriptionLabel, showHideLink);
|
||||
|
||||
listItem.getChildren().add(labelsBox);
|
||||
HBox.setHgrow(labelsBox, Priority.ALWAYS);
|
||||
|
||||
buttonBox = new HBox();
|
||||
buttonBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
Control button = createButton();
|
||||
if(button != null) {
|
||||
buttonBox.getChildren().add(button);
|
||||
}
|
||||
listItem.getChildren().add(buttonBox);
|
||||
|
||||
this.layoutBoundsProperty().addListener((observable, oldValue, newValue) -> {
|
||||
//Hack to force listItem to expand to full available width less border
|
||||
listItem.setPrefWidth(newValue.getWidth() - 3);
|
||||
});
|
||||
|
||||
return listItem;
|
||||
}
|
||||
|
||||
protected Control createButton() {
|
||||
//No buttons by default
|
||||
return null;
|
||||
}
|
||||
|
||||
protected void setDescription(String text) {
|
||||
descriptionLabel.getStyleClass().remove("description-error");
|
||||
descriptionLabel.getStyleClass().add("description-label");
|
||||
descriptionLabel.setText(text);
|
||||
}
|
||||
|
||||
protected void setError(String title, String detail) {
|
||||
descriptionLabel.getStyleClass().remove("description-label");
|
||||
descriptionLabel.getStyleClass().add("description-error");
|
||||
descriptionLabel.setText(title);
|
||||
if(detail != null && !detail.isEmpty()) {
|
||||
setContent(getContentBox(detail));
|
||||
setExpanded(true);
|
||||
}
|
||||
}
|
||||
|
||||
protected Node getContentBox(String message) {
|
||||
Label details = new Label(message);
|
||||
details.setWrapText(true);
|
||||
|
||||
HBox contentBox = new HBox();
|
||||
contentBox.setAlignment(Pos.TOP_LEFT);
|
||||
contentBox.getChildren().add(details);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
|
||||
double width = TextUtils.computeTextWidth(details.getFont(), message, 0.0D);
|
||||
double numLines = Math.max(1, Math.ceil(width / 400d));
|
||||
|
||||
//Handle long words like txids
|
||||
OptionalDouble maxWordLength = Arrays.stream(message.split(" ")).mapToDouble(word -> TextUtils.computeTextWidth(details.getFont(), message, 0.0D)).max();
|
||||
if(maxWordLength.isPresent() && maxWordLength.getAsDouble() > 300.0) {
|
||||
numLines += 1.0;
|
||||
}
|
||||
|
||||
double height = Math.max(60, numLines * 20);
|
||||
contentBox.setPrefHeight(height);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private void removeArrow() {
|
||||
removeArrow(0);
|
||||
}
|
||||
|
||||
private void removeArrow(int count) {
|
||||
Platform.runLater(() -> {
|
||||
Node arrow = this.lookup(".arrow");
|
||||
if (arrow != null) {
|
||||
arrow.setVisible(false);
|
||||
arrow.setManaged(false);
|
||||
} else if(count < 20) {
|
||||
removeArrow(count+1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.drongo.wallet.Payment;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.drongo.wallet.WalletTransaction;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ContentDisplay;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.shape.CubicCurve;
|
||||
import javafx.scene.shape.Line;
|
||||
import javafx.util.Duration;
|
||||
import org.controlsfx.glyphfont.FontAwesome;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TransactionDiagram extends GridPane {
|
||||
private static final int MAX_UTXOS = 7;
|
||||
private static final int MAX_PAYMENTS = 5;
|
||||
private static final double DIAGRAM_HEIGHT = 215.0;
|
||||
private static final int TOOLTIP_SHOW_DELAY = 50;
|
||||
|
||||
private WalletTransaction walletTx;
|
||||
|
||||
public void update(WalletTransaction walletTx) {
|
||||
setMinHeight(getDiagramHeight());
|
||||
setMaxHeight(getDiagramHeight());
|
||||
|
||||
if(walletTx == null) {
|
||||
getChildren().clear();
|
||||
} else {
|
||||
this.walletTx = walletTx;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
public void update() {
|
||||
Map<BlockTransactionHashIndex, WalletNode> displayedUtxos = getDisplayedUtxos();
|
||||
|
||||
Pane inputsTypePane = getInputsType(displayedUtxos);
|
||||
GridPane.setConstraints(inputsTypePane, 0, 0);
|
||||
|
||||
Pane inputsPane = getInputsLabels(displayedUtxos);
|
||||
GridPane.setConstraints(inputsPane, 1, 0);
|
||||
|
||||
Node inputsLinesPane = getInputsLines(displayedUtxos);
|
||||
GridPane.setConstraints(inputsLinesPane, 2, 0);
|
||||
|
||||
Pane txPane = getTransactionPane();
|
||||
GridPane.setConstraints(txPane, 3, 0);
|
||||
|
||||
List<Payment> displayedPayments = getDisplayedPayments();
|
||||
|
||||
Pane outputsLinesPane = getOutputsLines(displayedPayments);
|
||||
GridPane.setConstraints(outputsLinesPane, 4, 0);
|
||||
|
||||
Pane outputsPane = getOutputsLabels(displayedPayments);
|
||||
GridPane.setConstraints(outputsPane, 5, 0);
|
||||
|
||||
getChildren().clear();
|
||||
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
|
||||
}
|
||||
|
||||
private Map<BlockTransactionHashIndex, WalletNode> getDisplayedUtxos() {
|
||||
Map<BlockTransactionHashIndex, WalletNode> selectedUtxos = walletTx.getSelectedUtxos();
|
||||
|
||||
if(getPayjoinURI() != null) {
|
||||
selectedUtxos = new LinkedHashMap<>(selectedUtxos);
|
||||
selectedUtxos.put(new PayjoinBlockTransactionHashIndex(), null);
|
||||
}
|
||||
|
||||
if(selectedUtxos.size() > MAX_UTXOS) {
|
||||
Map<BlockTransactionHashIndex, WalletNode> utxos = new LinkedHashMap<>();
|
||||
List<BlockTransactionHashIndex> additional = new ArrayList<>();
|
||||
for(BlockTransactionHashIndex reference : selectedUtxos.keySet()) {
|
||||
if(utxos.size() < MAX_UTXOS - 1) {
|
||||
utxos.put(reference, selectedUtxos.get(reference));
|
||||
} else {
|
||||
additional.add(reference);
|
||||
}
|
||||
}
|
||||
|
||||
utxos.put(new AdditionalBlockTransactionHashIndex(additional), null);
|
||||
return utxos;
|
||||
} else {
|
||||
return selectedUtxos;
|
||||
}
|
||||
}
|
||||
|
||||
private BitcoinURI getPayjoinURI() {
|
||||
for(Payment payment : walletTx.getPayments()) {
|
||||
try {
|
||||
Address address = payment.getAddress();
|
||||
BitcoinURI bitcoinURI = AppServices.getPayjoinURI(address);
|
||||
if(bitcoinURI != null) {
|
||||
return bitcoinURI;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Pane getInputsType(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
|
||||
StackPane stackPane = new StackPane();
|
||||
|
||||
if(walletTx.isCoinControlUsed()) {
|
||||
VBox pane = new VBox();
|
||||
double width = 22.0;
|
||||
Group group = new Group();
|
||||
VBox.setVgrow(group, Priority.ALWAYS);
|
||||
|
||||
Line widthLine = new Line();
|
||||
widthLine.setStartX(0);
|
||||
widthLine.setEndX(width);
|
||||
widthLine.getStyleClass().add("boundary");
|
||||
|
||||
Line topYaxis = new Line();
|
||||
topYaxis.setStartX(width * 0.5);
|
||||
topYaxis.setStartY(getDiagramHeight() * 0.5 - 20.0);
|
||||
topYaxis.setEndX(width * 0.5);
|
||||
topYaxis.setEndY(0);
|
||||
topYaxis.getStyleClass().add("inputs-type");
|
||||
|
||||
Line topBracket = new Line();
|
||||
topBracket.setStartX(width * 0.5);
|
||||
topBracket.setStartY(0);
|
||||
topBracket.setEndX(width);
|
||||
topBracket.setEndY(0);
|
||||
topBracket.getStyleClass().add("inputs-type");
|
||||
|
||||
Line bottomYaxis = new Line();
|
||||
bottomYaxis.setStartX(width * 0.5);
|
||||
bottomYaxis.setStartY(getDiagramHeight());
|
||||
bottomYaxis.setEndX(width * 0.5);
|
||||
bottomYaxis.setEndY(getDiagramHeight() * 0.5 + 20.0);
|
||||
bottomYaxis.getStyleClass().add("inputs-type");
|
||||
|
||||
Line bottomBracket = new Line();
|
||||
bottomBracket.setStartX(width * 0.5);
|
||||
bottomBracket.setStartY(getDiagramHeight());
|
||||
bottomBracket.setEndX(width);
|
||||
bottomBracket.setEndY(getDiagramHeight());
|
||||
bottomBracket.getStyleClass().add("inputs-type");
|
||||
|
||||
group.getChildren().addAll(widthLine, topYaxis, topBracket, bottomYaxis, bottomBracket);
|
||||
pane.getChildren().add(group);
|
||||
|
||||
Glyph lockGlyph = getLockGlyph();
|
||||
lockGlyph.getStyleClass().add("inputs-type");
|
||||
Tooltip tooltip = new Tooltip("Coin control active");
|
||||
lockGlyph.setTooltip(tooltip);
|
||||
stackPane.getChildren().addAll(pane, lockGlyph);
|
||||
}
|
||||
|
||||
return stackPane;
|
||||
}
|
||||
|
||||
private Pane getInputsLabels(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
|
||||
VBox inputsBox = new VBox();
|
||||
inputsBox.setMaxWidth(150);
|
||||
inputsBox.setPadding(new Insets(0, 10, 0, 10));
|
||||
inputsBox.minHeightProperty().bind(minHeightProperty());
|
||||
inputsBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
inputsBox.getChildren().add(createSpacer());
|
||||
for(BlockTransactionHashIndex input : displayedUtxos.keySet()) {
|
||||
WalletNode walletNode = displayedUtxos.get(input);
|
||||
String desc = getInputDescription(input);
|
||||
Label label = new Label(desc);
|
||||
label.getStyleClass().add("utxo-label");
|
||||
|
||||
Button excludeUtxoButton = new Button("");
|
||||
excludeUtxoButton.setGraphic(getExcludeGlyph());
|
||||
excludeUtxoButton.setOnAction(event -> {
|
||||
EventManager.get().post(new ExcludeUtxoEvent(walletTx, input));
|
||||
});
|
||||
|
||||
Tooltip tooltip = new Tooltip();
|
||||
if(walletNode != null) {
|
||||
tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + walletNode.getDerivationPath().replace("m", "..") + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
|
||||
tooltip.getStyleClass().add("input-label");
|
||||
|
||||
if(input.getLabel() == null || input.getLabel().isEmpty()) {
|
||||
label.getStyleClass().add("input-label");
|
||||
}
|
||||
|
||||
label.setGraphic(excludeUtxoButton);
|
||||
label.setContentDisplay(ContentDisplay.LEFT);
|
||||
} else {
|
||||
if(input instanceof PayjoinBlockTransactionHashIndex) {
|
||||
tooltip.setText("Added once transaction is signed and sent to the payjoin server");
|
||||
} else {
|
||||
AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex) input;
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
|
||||
joiner.add(getInputDescription(additionalInput));
|
||||
}
|
||||
tooltip.setText(joiner.toString());
|
||||
}
|
||||
tooltip.getStyleClass().add("input-label");
|
||||
}
|
||||
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
label.setTooltip(tooltip);
|
||||
|
||||
inputsBox.getChildren().add(label);
|
||||
inputsBox.getChildren().add(createSpacer());
|
||||
}
|
||||
|
||||
return inputsBox;
|
||||
}
|
||||
|
||||
private String getInputDescription(BlockTransactionHashIndex input) {
|
||||
return input.getLabel() != null && !input.getLabel().isEmpty() ? input.getLabel() : input.getHashAsString().substring(0, 8) + "..:" + input.getIndex();
|
||||
}
|
||||
|
||||
private String getSatsValue(long amount) {
|
||||
return String.format(Locale.ENGLISH, "%,d", amount);
|
||||
}
|
||||
|
||||
private Pane getInputsLines(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
|
||||
VBox pane = new VBox();
|
||||
Group group = new Group();
|
||||
VBox.setVgrow(group, Priority.ALWAYS);
|
||||
|
||||
Line yaxisLine = new Line();
|
||||
yaxisLine.setStartX(0);
|
||||
yaxisLine.setStartY(0);
|
||||
yaxisLine.setEndX(0);
|
||||
yaxisLine.setEndY(getDiagramHeight());
|
||||
yaxisLine.getStyleClass().add("boundary");
|
||||
group.getChildren().add(yaxisLine);
|
||||
|
||||
double width = 140.0;
|
||||
List<BlockTransactionHashIndex> inputs = new ArrayList<>(displayedUtxos.keySet());
|
||||
int numUtxos = displayedUtxos.size();
|
||||
for(int i = 1; i <= numUtxos; i++) {
|
||||
CubicCurve curve = new CubicCurve();
|
||||
curve.getStyleClass().add("input-line");
|
||||
|
||||
if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex) {
|
||||
curve.getStyleClass().add("input-dashed-line");
|
||||
}
|
||||
|
||||
curve.setStartX(0);
|
||||
double scaleFactor = (double)i / (numUtxos + 1);
|
||||
int nodeHeight = 17;
|
||||
double additional = (0.5 - scaleFactor) * ((double)nodeHeight);
|
||||
curve.setStartY(scale(getDiagramHeight(), scaleFactor, additional));
|
||||
curve.setEndX(width);
|
||||
curve.setEndY(scale(getDiagramHeight(), 0.5, 0));
|
||||
|
||||
curve.setControlX1(scale(width, 0.2, 0));
|
||||
curve.setControlY1(curve.getStartY());
|
||||
curve.setControlX2(scale(width, 0.8, 0));
|
||||
curve.setControlY2(curve.getEndY());
|
||||
|
||||
group.getChildren().add(curve);
|
||||
}
|
||||
|
||||
pane.getChildren().add(group);
|
||||
return pane;
|
||||
}
|
||||
|
||||
private static double scale(Double value, double scaleFactor, double additional) {
|
||||
return value * (1.0 - scaleFactor) + additional;
|
||||
}
|
||||
|
||||
private List<Payment> getDisplayedPayments() {
|
||||
List<Payment> payments = walletTx.getPayments();
|
||||
|
||||
if(payments.size() > MAX_PAYMENTS) {
|
||||
List<Payment> displayedPayments = new ArrayList<>();
|
||||
List<Payment> additional = new ArrayList<>();
|
||||
for(Payment payment : payments) {
|
||||
if(displayedPayments.size() < MAX_PAYMENTS - 1) {
|
||||
displayedPayments.add(payment);
|
||||
} else {
|
||||
additional.add(payment);
|
||||
}
|
||||
}
|
||||
|
||||
displayedPayments.add(new AdditionalPayment(additional));
|
||||
return displayedPayments;
|
||||
} else {
|
||||
return payments;
|
||||
}
|
||||
}
|
||||
|
||||
private Pane getOutputsLines(List<Payment> displayedPayments) {
|
||||
VBox pane = new VBox();
|
||||
Group group = new Group();
|
||||
VBox.setVgrow(group, Priority.ALWAYS);
|
||||
|
||||
Line yaxisLine = new Line();
|
||||
yaxisLine.setStartX(0);
|
||||
yaxisLine.setStartY(0);
|
||||
yaxisLine.setEndX(0);
|
||||
yaxisLine.endYProperty().bind(this.heightProperty());
|
||||
yaxisLine.getStyleClass().add("boundary");
|
||||
group.getChildren().add(yaxisLine);
|
||||
|
||||
double width = 140.0;
|
||||
int numOutputs = displayedPayments.size() + (walletTx.getChangeNode() == null ? 1 : 2);
|
||||
for(int i = 1; i <= numOutputs; i++) {
|
||||
CubicCurve curve = new CubicCurve();
|
||||
curve.getStyleClass().add("output-line");
|
||||
|
||||
curve.setStartX(0);
|
||||
curve.setStartY(scale(getDiagramHeight(), 0.5, 0));
|
||||
curve.setEndX(width);
|
||||
double scaleFactor = (double)i / (numOutputs + 1);
|
||||
int nodeHeight = 20;
|
||||
double additional = (0.5 - scaleFactor) * ((double)nodeHeight);
|
||||
curve.setEndY(scale(getDiagramHeight(), scaleFactor, additional));
|
||||
|
||||
curve.setControlX1(scale(width, 0.2, 0));
|
||||
curve.controlY1Property().bind(curve.startYProperty());
|
||||
curve.setControlX2(scale(width, 0.8, 0));
|
||||
curve.controlY2Property().bind(curve.endYProperty());
|
||||
|
||||
group.getChildren().add(curve);
|
||||
}
|
||||
|
||||
pane.getChildren().add(group);
|
||||
return pane;
|
||||
}
|
||||
|
||||
private Pane getOutputsLabels(List<Payment> displayedPayments) {
|
||||
VBox outputsBox = new VBox();
|
||||
outputsBox.setMaxWidth(150);
|
||||
outputsBox.setPadding(new Insets(0, 20, 0, 10));
|
||||
outputsBox.setAlignment(Pos.CENTER_LEFT);
|
||||
outputsBox.getChildren().add(createSpacer());
|
||||
|
||||
for(Payment payment : displayedPayments) {
|
||||
boolean isConsolidation = walletTx.isConsolidationSend(payment);
|
||||
String recipientDesc = payment instanceof AdditionalPayment ? payment.getLabel() : payment.getAddress().toString().substring(0, 8) + "...";
|
||||
Label recipientLabel = new Label(recipientDesc, isConsolidation ? getConsolidationGlyph() : getPaymentGlyph());
|
||||
recipientLabel.getStyleClass().add("output-label");
|
||||
recipientLabel.getStyleClass().add(payment instanceof AdditionalPayment ? "additional-label" : "recipient-label");
|
||||
Tooltip recipientTooltip = new Tooltip((isConsolidation ? "Consolidate " : "Pay ") + getSatsValue(payment.getAmount()) + " sats to " + (payment instanceof AdditionalPayment ? "\n" + payment : payment.getLabel() + "\n" + payment.getAddress().toString()));
|
||||
recipientTooltip.getStyleClass().add("recipient-label");
|
||||
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
recipientLabel.setTooltip(recipientTooltip);
|
||||
outputsBox.getChildren().add(recipientLabel);
|
||||
outputsBox.getChildren().add(createSpacer());
|
||||
}
|
||||
|
||||
if(walletTx.getChangeNode() != null) {
|
||||
String changeDesc = walletTx.getChangeAddress().toString().substring(0, 8) + "...";
|
||||
Label changeLabel = new Label(changeDesc, getChangeGlyph());
|
||||
changeLabel.getStyleClass().addAll("output-label", "change-label");
|
||||
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath().replace("m", "..") + "\n" + walletTx.getChangeAddress().toString());
|
||||
changeTooltip.getStyleClass().add("change-label");
|
||||
changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
changeLabel.setTooltip(changeTooltip);
|
||||
outputsBox.getChildren().add(changeLabel);
|
||||
outputsBox.getChildren().add(createSpacer());
|
||||
}
|
||||
|
||||
boolean highFee = (walletTx.getFeePercentage() > 0.1);
|
||||
Label feeLabel = highFee ? new Label("High Fee", getWarningGlyph()) : new Label("Fee", getFeeGlyph());
|
||||
feeLabel.getStyleClass().addAll("output-label", "fee-label");
|
||||
String percentage = String.format("%.2f", walletTx.getFeePercentage() * 100.0);
|
||||
Tooltip feeTooltip = new Tooltip("Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)");
|
||||
feeTooltip.getStyleClass().add("fee-tooltip");
|
||||
feeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
feeLabel.setTooltip(feeTooltip);
|
||||
outputsBox.getChildren().add(feeLabel);
|
||||
outputsBox.getChildren().add(createSpacer());
|
||||
|
||||
return outputsBox;
|
||||
}
|
||||
|
||||
private Pane getTransactionPane() {
|
||||
VBox txPane = new VBox();
|
||||
txPane.setPadding(new Insets(0, 10, 0, 10));
|
||||
txPane.setAlignment(Pos.CENTER);
|
||||
txPane.getChildren().add(createSpacer());
|
||||
|
||||
String txDesc = "Transaction";
|
||||
Label txLabel = new Label(txDesc);
|
||||
Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + walletTx.getTransaction().getVirtualSize() + " vBytes");
|
||||
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
tooltip.getStyleClass().add("transaction-tooltip");
|
||||
txLabel.setTooltip(tooltip);
|
||||
txPane.getChildren().add(txLabel);
|
||||
txPane.getChildren().add(createSpacer());
|
||||
|
||||
return txPane;
|
||||
}
|
||||
|
||||
public double getDiagramHeight() {
|
||||
if(AppServices.isReducedWindowHeight(this)) {
|
||||
return DIAGRAM_HEIGHT - 40;
|
||||
}
|
||||
|
||||
return DIAGRAM_HEIGHT;
|
||||
}
|
||||
|
||||
private Node createSpacer() {
|
||||
final Region spacer = new Region();
|
||||
VBox.setVgrow(spacer, Priority.ALWAYS);
|
||||
return spacer;
|
||||
}
|
||||
|
||||
public static Glyph getExcludeGlyph() {
|
||||
Glyph excludeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TIMES_CIRCLE);
|
||||
excludeGlyph.getStyleClass().add("exclude-utxo");
|
||||
excludeGlyph.setFontSize(12);
|
||||
return excludeGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getPaymentGlyph() {
|
||||
Glyph paymentGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
|
||||
paymentGlyph.getStyleClass().add("payment-icon");
|
||||
paymentGlyph.setFontSize(12);
|
||||
return paymentGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getConsolidationGlyph() {
|
||||
Glyph consolidationGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.REPLY_ALL);
|
||||
consolidationGlyph.getStyleClass().add("consolidation-icon");
|
||||
consolidationGlyph.setFontSize(12);
|
||||
return consolidationGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getTxoGlyph() {
|
||||
return getChangeGlyph();
|
||||
}
|
||||
|
||||
public static Glyph getPayjoinGlyph() {
|
||||
Glyph payjoinGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
|
||||
payjoinGlyph.getStyleClass().add("payjoin-icon");
|
||||
payjoinGlyph.setFontSize(12);
|
||||
return payjoinGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getChangeGlyph() {
|
||||
Glyph changeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
|
||||
changeGlyph.getStyleClass().add("change-icon");
|
||||
changeGlyph.setFontSize(12);
|
||||
return changeGlyph;
|
||||
}
|
||||
|
||||
private Glyph getFeeGlyph() {
|
||||
Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING);
|
||||
feeGlyph.getStyleClass().add("fee-icon");
|
||||
feeGlyph.setFontSize(12);
|
||||
return feeGlyph;
|
||||
}
|
||||
|
||||
private Glyph getWarningGlyph() {
|
||||
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
|
||||
feeWarningGlyph.getStyleClass().add("fee-warning-icon");
|
||||
feeWarningGlyph.setFontSize(12);
|
||||
return feeWarningGlyph;
|
||||
}
|
||||
|
||||
private Glyph getLockGlyph() {
|
||||
Glyph lockGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LOCK);
|
||||
lockGlyph.getStyleClass().add("lock-icon");
|
||||
lockGlyph.setFontSize(12);
|
||||
return lockGlyph;
|
||||
}
|
||||
|
||||
private static class PayjoinBlockTransactionHashIndex extends BlockTransactionHashIndex {
|
||||
public PayjoinBlockTransactionHashIndex() {
|
||||
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLabel() {
|
||||
return "Payjoin input";
|
||||
}
|
||||
}
|
||||
|
||||
private static class AdditionalBlockTransactionHashIndex extends BlockTransactionHashIndex {
|
||||
private final List<BlockTransactionHashIndex> additionalInputs;
|
||||
|
||||
public AdditionalBlockTransactionHashIndex(List<BlockTransactionHashIndex> additionalInputs) {
|
||||
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
|
||||
this.additionalInputs = additionalInputs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLabel() {
|
||||
return additionalInputs.size() + " more...";
|
||||
}
|
||||
|
||||
public List<BlockTransactionHashIndex> getAdditionalInputs() {
|
||||
return additionalInputs;
|
||||
}
|
||||
}
|
||||
|
||||
private static class AdditionalPayment extends Payment {
|
||||
private final List<Payment> additionalPayments;
|
||||
|
||||
public AdditionalPayment(List<Payment> additionalPayments) {
|
||||
super(null, additionalPayments.size() + " more...", additionalPayments.stream().map(Payment::getAmount).mapToLong(v -> v).sum(), false);
|
||||
this.additionalPayments = additionalPayments;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return additionalPayments.stream().map(payment -> payment.getAddress().toString()).collect(Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import javafx.application.Platform;
|
||||
import org.fxmisc.richtext.CodeArea;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class TransactionHexArea extends CodeArea {
|
||||
private static final int TRUNCATE_AT = 30000;
|
||||
private static final int SEGMENTS_INTERVAL = 250;
|
||||
|
||||
private List<TransactionSegment> previousSegmentList = new ArrayList<>();
|
||||
|
||||
public void setTransaction(Transaction transaction) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
transaction.bitcoinSerializeToStream(baos);
|
||||
|
||||
String hex = Utils.bytesToHex(baos.toByteArray());
|
||||
if(hex.length() > TRUNCATE_AT) {
|
||||
hex = hex.substring(0, TRUNCATE_AT);
|
||||
hex += "[truncated]";
|
||||
}
|
||||
|
||||
clear();
|
||||
appendText(hex);
|
||||
previousSegmentList = new ArrayList<>();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Can't happen");
|
||||
}
|
||||
}
|
||||
|
||||
public void applyHighlighting(Transaction transaction, int selectedInputIndex, int selectedOutputIndex) {
|
||||
List<TransactionSegment> segments = getTransactionSegments(transaction, selectedInputIndex, selectedOutputIndex);
|
||||
List<TransactionSegment> changedSegments = new ArrayList<>(segments);
|
||||
changedSegments.removeAll(previousSegmentList);
|
||||
applyHighlighting(0, changedSegments);
|
||||
previousSegmentList = segments;
|
||||
}
|
||||
|
||||
private void applyHighlighting(int start, List<TransactionSegment> segments) {
|
||||
int end = Math.min(segments.size(), start + SEGMENTS_INTERVAL);
|
||||
for(int i = start; i < end; i++) {
|
||||
TransactionSegment segment = segments.get(i);
|
||||
if(segment.start < TRUNCATE_AT) {
|
||||
setStyleClass(segment.start, Math.min(TRUNCATE_AT, segment.start + segment.length), segment.style);
|
||||
}
|
||||
}
|
||||
|
||||
if(end < segments.size()) {
|
||||
Platform.runLater(() -> {
|
||||
applyHighlighting(end, segments);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public List<TransactionSegment> getTransactionSegments(Transaction transaction, int selectedInputIndex, int selectedOutputIndex) {
|
||||
List<TransactionSegment> segments = new ArrayList<>();
|
||||
|
||||
int cursor = 0;
|
||||
|
||||
//Version
|
||||
cursor = addSegment(segments, cursor, 8, "version");
|
||||
|
||||
if(transaction.isSegwit()) {
|
||||
//Segwit marker
|
||||
cursor = addSegment(segments, cursor, 2, "segwit-marker");
|
||||
//Segwit flag
|
||||
cursor = addSegment(segments, cursor, 2, "segwit-flag");
|
||||
}
|
||||
|
||||
//Number of inputs
|
||||
VarInt numInputs = new VarInt(transaction.getInputs().size());
|
||||
cursor = addSegment(segments, cursor, numInputs.getSizeInBytes() * 2, "num-inputs");
|
||||
|
||||
//Inputs
|
||||
for(int i = 0; i < transaction.getInputs().size(); i++) {
|
||||
TransactionInput input = transaction.getInputs().get(i);
|
||||
cursor = addSegment(segments, cursor, 32 * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "hash"));
|
||||
cursor = addSegment(segments, cursor, 4 * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "index"));
|
||||
VarInt scriptLen = new VarInt(input.getScriptBytes().length);
|
||||
cursor = addSegment(segments, cursor, scriptLen.getSizeInBytes() * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sigscript-length"));
|
||||
cursor = addSegment(segments, cursor, (int) scriptLen.value * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sigscript"));
|
||||
cursor = addSegment(segments, cursor, 4 * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sequence"));
|
||||
}
|
||||
|
||||
//Number of outputs
|
||||
VarInt numOutputs = new VarInt(transaction.getOutputs().size());
|
||||
cursor = addSegment(segments, cursor, numOutputs.getSizeInBytes() * 2, "num-outputs");
|
||||
|
||||
//Outputs
|
||||
for(int i = 0; i < transaction.getOutputs().size(); i++) {
|
||||
TransactionOutput output = transaction.getOutputs().get(i);
|
||||
cursor = addSegment(segments, cursor, 8 * 2, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "value"));
|
||||
VarInt scriptLen = new VarInt(output.getScriptBytes().length);
|
||||
cursor = addSegment(segments, cursor, scriptLen.getSizeInBytes() * 2, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "pubkeyscript-length"));
|
||||
cursor = addSegment(segments, cursor, (int) scriptLen.value * 2, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "pubkeyscript"));
|
||||
}
|
||||
|
||||
if(transaction.hasWitnesses()) {
|
||||
for (int i = 0; i < transaction.getInputs().size(); i++) {
|
||||
TransactionInput input = transaction.getInputs().get(i);
|
||||
if (input.hasWitness()) {
|
||||
TransactionWitness witness = input.getWitness();
|
||||
VarInt witnessCount = new VarInt(witness.getPushCount());
|
||||
cursor = addSegment(segments, cursor, witnessCount.getSizeInBytes() * 2, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "count"));
|
||||
for (byte[] push : witness.getPushes()) {
|
||||
VarInt witnessLen = new VarInt(push.length);
|
||||
cursor = addSegment(segments, cursor, witnessLen.getSizeInBytes() * 2, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "length"));
|
||||
cursor = addSegment(segments, cursor, (int) witnessLen.value * 2, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "data"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Locktime
|
||||
cursor = addSegment(segments, cursor, 8, "locktime");
|
||||
|
||||
if(cursor != getLength()) {
|
||||
//While this is normally a good sanity check, the truncation applied means it may fail, so it is left commented out
|
||||
//throw new IllegalStateException("Cursor position does not match transaction serialisation " + cursor + ": " + getLength());
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private int addSegment(List<TransactionSegment> segments, int start, int length, String style) {
|
||||
segments.add(new TransactionSegment(start, length, style));
|
||||
return start + length;
|
||||
}
|
||||
|
||||
private String getIndexedStyleClass(int iterableIndex, int selectedIndex, String styleClass) {
|
||||
if (selectedIndex == -1 || selectedIndex == iterableIndex) {
|
||||
return styleClass;
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
private static class TransactionSegment {
|
||||
public TransactionSegment(int start, int length, String style) {
|
||||
this.start = start;
|
||||
this.length = length;
|
||||
this.style = style;
|
||||
}
|
||||
|
||||
public int start;
|
||||
public int length;
|
||||
public String style;
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
TransactionSegment segment = (TransactionSegment) o;
|
||||
return start == segment.start &&
|
||||
length == segment.length &&
|
||||
style.equals(segment.style);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(start, length, style);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.text.Font;
|
||||
import org.controlsfx.control.textfield.CustomTextField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.FontAwesome;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
|
||||
public class TransactionIdDialog extends Dialog<Sha256Hash> {
|
||||
private final CustomTextField txid;
|
||||
|
||||
public TransactionIdDialog() {
|
||||
this.txid = (CustomTextField) TextFields.createClearableTextField();
|
||||
txid.setFont(AppServices.getMonospaceFont());
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
setTitle("Load Transaction");
|
||||
dialogPane.setHeaderText("Enter the transaction ID:");
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
|
||||
dialogPane.setPrefWidth(550);
|
||||
dialogPane.setPrefHeight(200);
|
||||
|
||||
Glyph wallet = new Glyph("FontAwesome", FontAwesome.Glyph.BITCOIN);
|
||||
wallet.setFontSize(50);
|
||||
dialogPane.setGraphic(wallet);
|
||||
|
||||
final VBox content = new VBox(10);
|
||||
content.getChildren().add(txid);
|
||||
|
||||
dialogPane.setContent(content);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
Platform.runLater(() -> {
|
||||
validationSupport.registerValidator(txid, Validator.combine(
|
||||
Validator.createEmptyValidator("Transaction id is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Transaction ID length incorrect", newValue.length() != 64),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Transaction ID must be hexadecimal", !Utils.isHex(newValue))
|
||||
));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
});
|
||||
|
||||
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Open Transaction", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() ->
|
||||
txid.getText().length() != 64 || !Utils.isHex(txid.getText()), txid.textProperty());
|
||||
okButton.disableProperty().bind(isInvalid);
|
||||
|
||||
txid.setPromptText("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16");
|
||||
Platform.runLater(txid::requestFocus);
|
||||
setResultConverter(dialogButton -> dialogButton == okButtonType ? Sha256Hash.wrap(txid.getText()) : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
import javafx.scene.control.TreeTableView;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class TransactionsTreeTable extends CoinTreeTable {
|
||||
public void initialize(WalletTransactionsEntry rootEntry) {
|
||||
getStyleClass().add("transactions-treetable");
|
||||
setBitcoinUnit(rootEntry.getWallet());
|
||||
|
||||
updateAll(rootEntry);
|
||||
setShowRoot(false);
|
||||
|
||||
TreeTableColumn<Entry, Entry> dateCol = new TreeTableColumn<>("Date");
|
||||
dateCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
|
||||
});
|
||||
dateCol.setCellFactory(p -> new EntryCell());
|
||||
dateCol.setSortable(true);
|
||||
getColumns().add(dateCol);
|
||||
|
||||
TreeTableColumn<Entry, String> labelCol = new TreeTableColumn<>("Label");
|
||||
labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return param.getValue().getValue().labelProperty();
|
||||
});
|
||||
labelCol.setCellFactory(p -> new LabelCell());
|
||||
labelCol.setSortable(true);
|
||||
getColumns().add(labelCol);
|
||||
|
||||
TreeTableColumn<Entry, Number> amountCol = new TreeTableColumn<>("Value");
|
||||
amountCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
|
||||
});
|
||||
amountCol.setCellFactory(p -> new CoinCell());
|
||||
amountCol.setSortable(true);
|
||||
getColumns().add(amountCol);
|
||||
|
||||
TreeTableColumn<Entry, Number> balanceCol = new TreeTableColumn<>("Balance");
|
||||
balanceCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
|
||||
return param.getValue().getValue() instanceof TransactionEntry ? ((TransactionEntry)param.getValue().getValue()).balanceProperty() : new ReadOnlyObjectWrapper<>(null);
|
||||
});
|
||||
balanceCol.setCellFactory(p -> new CoinCell());
|
||||
balanceCol.setSortable(true);
|
||||
getColumns().add(balanceCol);
|
||||
|
||||
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
|
||||
setEditable(true);
|
||||
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
getSortOrder().add(dateCol);
|
||||
}
|
||||
|
||||
public void updateAll(WalletTransactionsEntry rootEntry) {
|
||||
setBitcoinUnit(rootEntry.getWallet());
|
||||
|
||||
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
|
||||
setRoot(rootItem);
|
||||
rootItem.setExpanded(true);
|
||||
|
||||
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
|
||||
TreeTableColumn<Entry, ?> dateCol = getColumns().get(0);
|
||||
getSortOrder().add(dateCol);
|
||||
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateHistory(List<WalletNode> updatedNodes) {
|
||||
//Transaction entries should have already been updated using WalletTransactionsEntry.updateHistory, so only a resort required
|
||||
sort();
|
||||
}
|
||||
|
||||
public void updateLabel(Entry entry) {
|
||||
Entry rootEntry = getRoot().getValue();
|
||||
rootEntry.updateLabel(entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import impl.org.controlsfx.skin.ToggleSwitchSkin;
|
||||
import javafx.scene.control.Skin;
|
||||
import org.controlsfx.control.ToggleSwitch;
|
||||
|
||||
public class UnlabeledToggleSwitch extends ToggleSwitch {
|
||||
@Override protected Skin<?> createDefaultSkin() {
|
||||
return new ToggleSwitchSkin(this) {
|
||||
@Override
|
||||
protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
|
||||
return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) - 20;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
import javafx.geometry.Side;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class UsbStatusButton extends MenuButton {
|
||||
public UsbStatusButton() {
|
||||
super("");
|
||||
setGraphic(getIcon());
|
||||
|
||||
getStyleClass().add("status-bar-button");
|
||||
this.setPopupSide(Side.RIGHT);
|
||||
}
|
||||
|
||||
public void setDevices(List<Device> devices) {
|
||||
for(Device device : devices) {
|
||||
MenuItem deviceItem = new MenuItem(device.getModel().toDisplayString());
|
||||
if(device.getNeedsPinSent()) {
|
||||
deviceItem.setGraphic(getLockIcon());
|
||||
} else {
|
||||
deviceItem.setGraphic(getLockOpenIcon());
|
||||
}
|
||||
getItems().add(deviceItem);
|
||||
}
|
||||
}
|
||||
|
||||
private Node getIcon() {
|
||||
Glyph usb = new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
|
||||
usb.setFontSize(15);
|
||||
return usb;
|
||||
}
|
||||
|
||||
private Node getLockIcon() {
|
||||
Glyph usb = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LOCK);
|
||||
usb.setFontSize(12);
|
||||
return usb;
|
||||
}
|
||||
|
||||
private Node getLockOpenIcon() {
|
||||
Glyph usb = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LOCK_OPEN);
|
||||
usb.setFontSize(12);
|
||||
return usb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletUtxosEntry;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.chart.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class UtxosChart extends BarChart<String, Number> {
|
||||
private static final int MAX_BARS = 8;
|
||||
private static final String OTHER_CATEGORY = "Other";
|
||||
|
||||
private List<Entry> selectedEntries;
|
||||
private int totalUtxos;
|
||||
|
||||
private XYChart.Series<String, Number> utxoSeries;
|
||||
|
||||
public UtxosChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
|
||||
super(xAxis, yAxis);
|
||||
}
|
||||
|
||||
public void initialize(WalletUtxosEntry walletUtxosEntry) {
|
||||
utxoSeries = new XYChart.Series<>();
|
||||
getData().add(utxoSeries);
|
||||
update(walletUtxosEntry);
|
||||
|
||||
BitcoinUnit unit = Config.get().getBitcoinUnit();
|
||||
setBitcoinUnit(walletUtxosEntry.getWallet(), unit);
|
||||
}
|
||||
|
||||
public void update(WalletUtxosEntry walletUtxosEntry) {
|
||||
List<Data<String, Number>> utxoDataList = walletUtxosEntry.getChildren().stream()
|
||||
.map(entry -> new XYChart.Data<>(getCategoryName(entry), (Number)entry.getValue(), entry))
|
||||
.sorted((o1, o2) -> Long.compare(o2.getYValue().longValue(), o1.getYValue().longValue()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
totalUtxos = utxoDataList.size();
|
||||
if(utxoDataList.size() > MAX_BARS) {
|
||||
Long otherTotal = utxoDataList.subList(MAX_BARS - 1, utxoDataList.size()).stream().mapToLong(data -> data.getYValue().longValue()).sum();
|
||||
utxoDataList = utxoDataList.subList(0, MAX_BARS - 1);
|
||||
utxoDataList.add(new XYChart.Data<>(OTHER_CATEGORY, otherTotal));
|
||||
}
|
||||
|
||||
for(int i = 0; i < utxoDataList.size(); i++) {
|
||||
XYChart.Data<String, Number> newData = utxoDataList.get(i);
|
||||
if(i < utxoSeries.getData().size()) {
|
||||
XYChart.Data<String, Number> existingData = utxoSeries.getData().get(i);
|
||||
if(!newData.getXValue().equals(existingData.getXValue()) || !newData.getYValue().equals(existingData.getYValue()) || (newData.getExtraValue() != null && !newData.getExtraValue().equals(existingData.getExtraValue()))) {
|
||||
utxoSeries.getData().set(i, newData);
|
||||
}
|
||||
} else {
|
||||
utxoSeries.getData().add(newData);
|
||||
}
|
||||
}
|
||||
|
||||
if(utxoSeries.getData().size() > utxoDataList.size()) {
|
||||
utxoSeries.getData().remove(Math.max(0, utxoDataList.size() - 1), utxoSeries.getData().size());
|
||||
}
|
||||
|
||||
if(selectedEntries != null) {
|
||||
select(selectedEntries);
|
||||
}
|
||||
}
|
||||
|
||||
private String getCategoryName(Entry entry) {
|
||||
if(entry.getLabel() != null && !entry.getLabel().isEmpty()) {
|
||||
return entry.getLabel().length() > 15 ? entry.getLabel().substring(0, 15) + "..." : entry.getLabel();
|
||||
}
|
||||
|
||||
return ((UtxoEntry)entry).getDescription();
|
||||
}
|
||||
|
||||
public void select(List<Entry> entries) {
|
||||
Set<Node> selectedBars = lookupAll(".chart-bar.selected");
|
||||
for(Node selectedBar : selectedBars) {
|
||||
selectedBar.getStyleClass().remove("selected");
|
||||
}
|
||||
|
||||
for(int i = 0; i < utxoSeries.getData().size(); i++) {
|
||||
XYChart.Data<String, Number> data = utxoSeries.getData().get(i);
|
||||
|
||||
if((data.getExtraValue() != null && entries.contains((Entry)data.getExtraValue())) || (data.getExtraValue() == null && entries.size() == totalUtxos)) {
|
||||
Node bar = lookup(".data" + i);
|
||||
if(bar != null) {
|
||||
bar.getStyleClass().add("selected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedEntries = entries;
|
||||
}
|
||||
|
||||
public void setBitcoinUnit(Wallet wallet, BitcoinUnit unit) {
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
unit = wallet.getAutoUnit();
|
||||
}
|
||||
|
||||
NumberAxis yaxis = (NumberAxis)getYAxis();
|
||||
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, unit));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.sparrow.wallet.*;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.scene.control.SelectionMode;
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
import javafx.scene.control.TreeTableView;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class UtxosTreeTable extends CoinTreeTable {
|
||||
public void initialize(WalletUtxosEntry rootEntry) {
|
||||
getStyleClass().add("utxos-treetable");
|
||||
setBitcoinUnit(rootEntry.getWallet());
|
||||
|
||||
updateAll(rootEntry);
|
||||
setShowRoot(false);
|
||||
|
||||
TreeTableColumn<Entry, Entry> dateCol = new TreeTableColumn<>("Date");
|
||||
dateCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
|
||||
});
|
||||
dateCol.setCellFactory(p -> new DateCell());
|
||||
dateCol.setSortable(true);
|
||||
getColumns().add(dateCol);
|
||||
|
||||
TreeTableColumn<Entry, Entry> outputCol = new TreeTableColumn<>("Output");
|
||||
outputCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
|
||||
});
|
||||
outputCol.setCellFactory(p -> new EntryCell());
|
||||
outputCol.setSortable(true);
|
||||
outputCol.setComparator((o1, o2) -> {
|
||||
UtxoEntry entry1 = (UtxoEntry)o1;
|
||||
UtxoEntry entry2 = (UtxoEntry)o2;
|
||||
return entry1.getDescription().compareTo(entry2.getDescription());
|
||||
});
|
||||
getColumns().add(outputCol);
|
||||
|
||||
TreeTableColumn<Entry, Entry> addressCol = new TreeTableColumn<>("Address");
|
||||
addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
|
||||
});
|
||||
addressCol.setCellFactory(p -> new AddressCell());
|
||||
addressCol.setSortable(true);
|
||||
addressCol.setComparator((o1, o2) -> {
|
||||
UtxoEntry entry1 = (UtxoEntry)o1;
|
||||
UtxoEntry entry2 = (UtxoEntry)o2;
|
||||
return entry1.getAddress().toString().compareTo(entry2.getAddress().toString());
|
||||
});
|
||||
getColumns().add(addressCol);
|
||||
|
||||
TreeTableColumn<Entry, String> labelCol = new TreeTableColumn<>("Label");
|
||||
labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return param.getValue().getValue().labelProperty();
|
||||
});
|
||||
labelCol.setCellFactory(p -> new LabelCell());
|
||||
labelCol.setSortable(true);
|
||||
getColumns().add(labelCol);
|
||||
|
||||
TreeTableColumn<Entry, Number> amountCol = new TreeTableColumn<>("Value");
|
||||
amountCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
|
||||
});
|
||||
amountCol.setCellFactory(p -> new CoinCell());
|
||||
amountCol.setSortable(true);
|
||||
getColumns().add(amountCol);
|
||||
setTreeColumn(amountCol);
|
||||
|
||||
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
|
||||
setEditable(true);
|
||||
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
getSortOrder().add(amountCol);
|
||||
|
||||
getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
}
|
||||
|
||||
public void updateAll(WalletUtxosEntry rootEntry) {
|
||||
setBitcoinUnit(rootEntry.getWallet());
|
||||
|
||||
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
|
||||
setRoot(rootItem);
|
||||
rootItem.setExpanded(true);
|
||||
|
||||
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
|
||||
TreeTableColumn<Entry, ?> amountCol = getColumns().get(getColumns().size() - 1);
|
||||
getSortOrder().add(amountCol);
|
||||
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateHistory(List<WalletNode> updatedNodes) {
|
||||
//Utxo entries should have already been updated, so only a resort required
|
||||
sort();
|
||||
}
|
||||
|
||||
public void updateLabel(Entry entry) {
|
||||
Entry rootEntry = getRoot().getValue();
|
||||
rootEntry.updateLabel(entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
|
||||
public class WalletBirthDateDialog extends Dialog<Date> {
|
||||
private final DatePicker birthDatePicker;
|
||||
|
||||
public WalletBirthDateDialog(Date birthDate) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
setTitle("Wallet Birth Date");
|
||||
dialogPane.setHeaderText("Select an approximate date earlier than the first wallet transaction:");
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
|
||||
dialogPane.setPrefWidth(420);
|
||||
dialogPane.setPrefHeight(200);
|
||||
|
||||
Glyph wallet = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HISTORY);
|
||||
wallet.setFontSize(50);
|
||||
dialogPane.setGraphic(wallet);
|
||||
|
||||
HBox datePickerBox = new HBox(10);
|
||||
Label label = new Label("Start scanning from:");
|
||||
label.setPadding(new Insets(5, 0, 0, 8));
|
||||
datePickerBox.getChildren().add(label);
|
||||
|
||||
birthDatePicker = birthDate == null ? new DatePicker() : new DatePicker(birthDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
|
||||
birthDatePicker.setEditable(false);
|
||||
birthDatePicker.setConverter(new DateStringConverter());
|
||||
|
||||
datePickerBox.getChildren().add(birthDatePicker);
|
||||
|
||||
dialogPane.setContent(datePickerBox);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
Platform.runLater( () -> {
|
||||
validationSupport.registerValidator(birthDatePicker, Validator.combine(
|
||||
(Control c, LocalDate newValue) -> ValidationResult.fromErrorIf( c, "Birth date not specified", newValue == null)
|
||||
));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
});
|
||||
|
||||
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rescan Wallet", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> birthDatePicker.getValue() == null, birthDatePicker.valueProperty());
|
||||
okButton.disableProperty().bind(isInvalid);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton == okButtonType ? Date.from(birthDatePicker.getValue().atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletExportEvent;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class WalletExportDialog extends Dialog<Wallet> {
|
||||
private Wallet wallet;
|
||||
|
||||
public WalletExportDialog(Wallet wallet) {
|
||||
EventManager.get().register(this);
|
||||
setOnCloseRequest(event -> {
|
||||
EventManager.get().unregister(this);
|
||||
});
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
dialogPane.setContent(stackPane);
|
||||
|
||||
AnchorPane anchorPane = new AnchorPane();
|
||||
stackPane.getChildren().add(anchorPane);
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setPrefHeight(400);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
anchorPane.getChildren().add(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
AnchorPane.setLeftAnchor(scrollPane, 0.0);
|
||||
AnchorPane.setRightAnchor(scrollPane, 0.0);
|
||||
|
||||
List<WalletExport> exporters;
|
||||
if(wallet.getPolicyType() == PolicyType.SINGLE) {
|
||||
exporters = List.of(new Electrum(), new SpecterDesktop());
|
||||
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
|
||||
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop());
|
||||
} else {
|
||||
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
|
||||
}
|
||||
|
||||
Accordion exportAccordion = new Accordion();
|
||||
for (WalletExport exporter : exporters) {
|
||||
FileWalletExportPane exportPane = new FileWalletExportPane(wallet, exporter);
|
||||
exportAccordion.getPanes().add(exportPane);
|
||||
}
|
||||
scrollPane.setContent(exportAccordion);
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(480);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletExported(WalletExportEvent event) {
|
||||
wallet = event.getWallet();
|
||||
setResult(wallet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletImportEvent;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class WalletImportDialog extends Dialog<Wallet> {
|
||||
private Wallet wallet;
|
||||
|
||||
public WalletImportDialog() {
|
||||
EventManager.get().register(this);
|
||||
setOnCloseRequest(event -> {
|
||||
EventManager.get().unregister(this);
|
||||
});
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
dialogPane.setContent(stackPane);
|
||||
|
||||
AnchorPane anchorPane = new AnchorPane();
|
||||
stackPane.getChildren().add(anchorPane);
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setPrefHeight(420);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
anchorPane.getChildren().add(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
AnchorPane.setLeftAnchor(scrollPane, 0.0);
|
||||
AnchorPane.setRightAnchor(scrollPane, 0.0);
|
||||
|
||||
Accordion importAccordion = new Accordion();
|
||||
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new PassportSinglesig());
|
||||
for(KeystoreFileImport importer : keystoreImporters) {
|
||||
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
|
||||
importAccordion.getPanes().add(importPane);
|
||||
}
|
||||
|
||||
List<WalletImport> walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop());
|
||||
for(WalletImport importer : walletImporters) {
|
||||
FileWalletImportPane importPane = new FileWalletImportPane(importer);
|
||||
importAccordion.getPanes().add(importPane);
|
||||
}
|
||||
scrollPane.setContent(importAccordion);
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(500);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletImported(WalletImportEvent event) {
|
||||
wallet = event.getWallet();
|
||||
setResult(wallet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.net.ServerType;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.control.textfield.CustomTextField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
|
||||
public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate> {
|
||||
private final CustomTextField name;
|
||||
private final CheckBox existingCheck;
|
||||
private final DatePicker existingPicker;
|
||||
|
||||
public WalletNameDialog() {
|
||||
this("");
|
||||
}
|
||||
|
||||
public WalletNameDialog(String initialName) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
boolean requestBirthDate = (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
|
||||
|
||||
setTitle("Wallet Name");
|
||||
dialogPane.setHeaderText("Enter a name for this wallet:");
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
|
||||
dialogPane.setPrefWidth(460);
|
||||
dialogPane.setPrefHeight(requestBirthDate ? 250 : 200);
|
||||
|
||||
Glyph wallet = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
|
||||
wallet.setFontSize(50);
|
||||
dialogPane.setGraphic(wallet);
|
||||
|
||||
final VBox content = new VBox(20);
|
||||
name = (CustomTextField)TextFields.createClearableTextField();
|
||||
name.setText(initialName);
|
||||
content.getChildren().add(name);
|
||||
|
||||
HBox existingBox = new HBox(10);
|
||||
existingCheck = new CheckBox("Has existing transactions");
|
||||
existingCheck.setPadding(new Insets(5, 0, 0, 0));
|
||||
existingBox.getChildren().add(existingCheck);
|
||||
|
||||
existingPicker = new DatePicker();
|
||||
existingPicker.setConverter(new DateStringConverter());
|
||||
existingPicker.setEditable(false);
|
||||
existingPicker.setPrefWidth(130);
|
||||
existingPicker.managedProperty().bind(existingPicker.visibleProperty());
|
||||
existingPicker.setVisible(false);
|
||||
existingBox.getChildren().add(existingPicker);
|
||||
|
||||
HelpLabel helpLabel = new HelpLabel();
|
||||
helpLabel.setHelpText("Select an approximate date earlier than the first wallet transaction.");
|
||||
helpLabel.setTranslateY(5);
|
||||
helpLabel.managedProperty().bind(helpLabel.visibleProperty());
|
||||
helpLabel.visibleProperty().bind(existingPicker.visibleProperty());
|
||||
existingBox.getChildren().add(helpLabel);
|
||||
|
||||
existingCheck.selectedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(newValue) {
|
||||
existingCheck.setText("Has existing transactions starting from");
|
||||
existingPicker.setVisible(true);
|
||||
} else {
|
||||
existingCheck.setText("Has existing transactions");
|
||||
existingPicker.setVisible(false);
|
||||
}
|
||||
});
|
||||
|
||||
if(requestBirthDate) {
|
||||
content.getChildren().add(existingBox);
|
||||
}
|
||||
|
||||
dialogPane.setContent(content);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
Platform.runLater( () -> {
|
||||
validationSupport.registerValidator(name, Validator.combine(
|
||||
Validator.createEmptyValidator("Wallet name is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Wallet name is not unique", Storage.walletExists(newValue))
|
||||
));
|
||||
validationSupport.registerValidator(existingPicker, Validator.combine(
|
||||
(Control c, LocalDate newValue) -> ValidationResult.fromErrorIf( c, "Birth date not specified", existingCheck.isSelected() && newValue == null)
|
||||
));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
});
|
||||
|
||||
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Create Wallet", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() ->
|
||||
name.getText().length() == 0 || Storage.walletExists(name.getText()) || (existingCheck.isSelected() && existingPicker.getValue() == null), name.textProperty(), existingCheck.selectedProperty(), existingPicker.valueProperty());
|
||||
okButton.disableProperty().bind(isInvalid);
|
||||
|
||||
name.setPromptText("Wallet Name");
|
||||
Platform.runLater(name::requestFocus);
|
||||
setResultConverter(dialogButton -> dialogButton == okButtonType ? new NameAndBirthDate(name.getText(), existingPicker.getValue()) : null);
|
||||
}
|
||||
|
||||
public static class NameAndBirthDate {
|
||||
private final String name;
|
||||
private final Date birthDate;
|
||||
|
||||
public NameAndBirthDate(String name, LocalDate birthLocalDate) {
|
||||
this.name = name;
|
||||
this.birthDate = (birthLocalDate == null ? null : Date.from(birthLocalDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()));
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Date getBirthDate() {
|
||||
return birthDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.control.textfield.CustomPasswordField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.FontAwesome;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
|
||||
public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
private final ButtonType okButtonType;
|
||||
private final PasswordRequirement requirement;
|
||||
private final CustomPasswordField password;
|
||||
private final CustomPasswordField passwordConfirm;
|
||||
private final CheckBox backupExisting;
|
||||
private final CheckBox changePassword;
|
||||
|
||||
public WalletPasswordDialog(PasswordRequirement requirement) {
|
||||
this(null, requirement);
|
||||
}
|
||||
|
||||
public WalletPasswordDialog(String walletName, PasswordRequirement requirement) {
|
||||
this.requirement = requirement;
|
||||
this.password = (CustomPasswordField)TextFields.createClearablePasswordField();
|
||||
this.passwordConfirm = (CustomPasswordField)TextFields.createClearablePasswordField();
|
||||
this.backupExisting = new CheckBox("Backup existing wallet first");
|
||||
this.changePassword = new CheckBox("Change password");
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
setTitle("Wallet Password" + (walletName != null ? " - " + walletName : ""));
|
||||
dialogPane.setHeaderText(walletName != null ? requirement.description.substring(0, requirement.description.length() - 1) + " for " + walletName + ":" : requirement.description);
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
|
||||
dialogPane.setPrefWidth(380);
|
||||
dialogPane.setPrefHeight(260);
|
||||
|
||||
Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK);
|
||||
lock.setFontSize(50);
|
||||
dialogPane.setGraphic(lock);
|
||||
|
||||
final VBox content = new VBox(10);
|
||||
content.setPrefHeight(100);
|
||||
content.getChildren().add(password);
|
||||
content.getChildren().add(passwordConfirm);
|
||||
|
||||
if(requirement == PasswordRequirement.UPDATE_EMPTY || requirement == PasswordRequirement.UPDATE_SET) {
|
||||
content.getChildren().add(backupExisting);
|
||||
backupExisting.setSelected(true);
|
||||
}
|
||||
|
||||
if(requirement == PasswordRequirement.UPDATE_SET) {
|
||||
content.getChildren().add(changePassword);
|
||||
}
|
||||
|
||||
dialogPane.setContent(content);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
Platform.runLater( () -> {
|
||||
validationSupport.registerValidator(passwordConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Password confirmation does not match", !passwordConfirm.getText().equals(password.getText())));
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
});
|
||||
|
||||
okButtonType = new javafx.scene.control.ButtonType(requirement.okButtonText, ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
|
||||
okButton.setPrefWidth(130);
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> passwordConfirm.isVisible() && !password.getText().equals(passwordConfirm.getText()), password.textProperty(), passwordConfirm.textProperty());
|
||||
okButton.disableProperty().bind(isInvalid);
|
||||
|
||||
if(requirement != PasswordRequirement.UPDATE_NEW && requirement != PasswordRequirement.UPDATE_CHANGE) {
|
||||
passwordConfirm.setVisible(false);
|
||||
passwordConfirm.setManaged(false);
|
||||
}
|
||||
|
||||
if(requirement == PasswordRequirement.UPDATE_NEW || requirement == PasswordRequirement.UPDATE_EMPTY || requirement == PasswordRequirement.UPDATE_CHANGE) {
|
||||
password.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(newValue.isEmpty()) {
|
||||
okButton.setText("No Password");
|
||||
passwordConfirm.setVisible(false);
|
||||
passwordConfirm.setManaged(false);
|
||||
} else {
|
||||
okButton.setText("Set Password");
|
||||
passwordConfirm.setVisible(true);
|
||||
passwordConfirm.setManaged(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
password.setPromptText("Password");
|
||||
Platform.runLater(password::requestFocus);
|
||||
passwordConfirm.setPromptText("Password Confirmation");
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton == okButtonType ? new SecureString(password.getText()) : null);
|
||||
}
|
||||
|
||||
public boolean isBackupExisting() {
|
||||
return backupExisting.isSelected();
|
||||
}
|
||||
|
||||
public boolean isChangePassword() {
|
||||
return changePassword.isSelected();
|
||||
}
|
||||
|
||||
public enum PasswordRequirement {
|
||||
LOAD("Please enter the wallet password:", "Unlock"),
|
||||
UPDATE_NEW("Add a password to the wallet?\nLeave empty for none:", "No Password"),
|
||||
UPDATE_EMPTY("This wallet has no password.\nAdd a password to the wallet?\nLeave empty for none:", "No Password"),
|
||||
UPDATE_SET("Please re-enter the wallet password:", "Verify Password"),
|
||||
UPDATE_CHANGE("Enter the new wallet password.\nLeave empty for none:", "No Password");
|
||||
|
||||
private final String description;
|
||||
private final String okButtonText;
|
||||
|
||||
PasswordRequirement(String description, String okButtonText) {
|
||||
this.description = description;
|
||||
this.okButtonText = okButtonText;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.github.sarxos.webcam.Webcam;
|
||||
import com.github.sarxos.webcam.WebcamListener;
|
||||
import com.github.sarxos.webcam.WebcamResolution;
|
||||
import com.google.zxing.*;
|
||||
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
|
||||
import com.google.zxing.common.HybridBinarizer;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.concurrent.Service;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.embed.swing.SwingFXUtils;
|
||||
import javafx.scene.image.Image;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class WebcamService extends Service<Image> {
|
||||
private WebcamResolution resolution;
|
||||
private final WebcamListener listener;
|
||||
|
||||
private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null);
|
||||
|
||||
public WebcamService(WebcamResolution resolution, WebcamListener listener) {
|
||||
this.resolution = resolution;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<Image> createTask() {
|
||||
return new Task<Image>() {
|
||||
@Override
|
||||
protected Image call() throws Exception {
|
||||
Webcam cam = Webcam.getWebcams(1, TimeUnit.MINUTES).get(0);
|
||||
try {
|
||||
cam.setCustomViewSizes(resolution.getSize());
|
||||
cam.setViewSize(resolution.getSize());
|
||||
if(!Arrays.asList(cam.getWebcamListeners()).contains(listener)) {
|
||||
cam.addWebcamListener(listener);
|
||||
}
|
||||
|
||||
cam.open();
|
||||
while(!isCancelled()) {
|
||||
if(cam.isImageNew()) {
|
||||
BufferedImage bimg = cam.getImage();
|
||||
updateValue(SwingFXUtils.toFXImage(bimg, null));
|
||||
readQR(bimg);
|
||||
}
|
||||
}
|
||||
cam.close();
|
||||
return getValue();
|
||||
} finally {
|
||||
cam.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void readQR(BufferedImage bufferedImage) {
|
||||
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
|
||||
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
|
||||
|
||||
try {
|
||||
Result result = new MultiFormatReader().decode(bitmap);
|
||||
resultProperty.set(result);
|
||||
} catch(NotFoundException e) {
|
||||
// fall thru, it means there is no QR code in image
|
||||
}
|
||||
}
|
||||
|
||||
public Result getResult() {
|
||||
return resultProperty.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<Result> resultProperty() {
|
||||
return resultProperty;
|
||||
}
|
||||
|
||||
public int getCamWidth() {
|
||||
return resolution.getSize().width;
|
||||
}
|
||||
|
||||
public int getCamHeight() {
|
||||
return resolution.getSize().height;
|
||||
}
|
||||
|
||||
public void setResolution(WebcamResolution resolution) {
|
||||
this.resolution = resolution;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.Region;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class WebcamView {
|
||||
private static final Logger log = LoggerFactory.getLogger(WebcamView.class);
|
||||
|
||||
private final ImageView imageView;
|
||||
private final WebcamService service;
|
||||
private final Region view;
|
||||
|
||||
private final Label statusPlaceholder ;
|
||||
|
||||
public WebcamView(WebcamService service) {
|
||||
this.service = service ;
|
||||
this.imageView = new ImageView();
|
||||
imageView.setPreserveRatio(true);
|
||||
// make the cam behave like a mirror:
|
||||
imageView.setScaleX(-1);
|
||||
|
||||
this.statusPlaceholder = new Label();
|
||||
this.view = new Region() {
|
||||
{
|
||||
service.stateProperty().addListener((obs, oldState, newState) -> {
|
||||
switch (newState) {
|
||||
case READY:
|
||||
statusPlaceholder.setText("Initializing");
|
||||
getChildren().setAll(statusPlaceholder);
|
||||
break ;
|
||||
case SCHEDULED:
|
||||
statusPlaceholder.setText("Waiting");
|
||||
getChildren().setAll(statusPlaceholder);
|
||||
break ;
|
||||
case RUNNING:
|
||||
imageView.imageProperty().unbind();
|
||||
imageView.imageProperty().bind(service.valueProperty());
|
||||
getChildren().setAll(imageView);
|
||||
break ;
|
||||
case CANCELLED:
|
||||
imageView.imageProperty().unbind();
|
||||
imageView.setImage(null);
|
||||
statusPlaceholder.setText("Stopped");
|
||||
getChildren().setAll(statusPlaceholder);
|
||||
break;
|
||||
case FAILED:
|
||||
imageView.imageProperty().unbind();
|
||||
statusPlaceholder.setText("Error");
|
||||
getChildren().setAll(statusPlaceholder);
|
||||
log.error("Failed to start web cam", service.getException());
|
||||
break;
|
||||
case SUCCEEDED:
|
||||
// unreachable...
|
||||
imageView.imageProperty().unbind();
|
||||
statusPlaceholder.setText("");
|
||||
getChildren().clear();
|
||||
}
|
||||
requestLayout();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
double w = getWidth();
|
||||
double h = getHeight();
|
||||
if (service.isRunning()) {
|
||||
imageView.setFitWidth(w);
|
||||
imageView.setFitHeight(h);
|
||||
imageView.resizeRelocate(0, 0, w, h);
|
||||
} else {
|
||||
double labelHeight = statusPlaceholder.prefHeight(w);
|
||||
double labelWidth = statusPlaceholder.prefWidth(labelHeight);
|
||||
statusPlaceholder.resizeRelocate((w - labelWidth)/2, (h-labelHeight)/2, labelWidth, labelHeight);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected double computePrefWidth(double height) {
|
||||
return service.getCamWidth();
|
||||
}
|
||||
@Override
|
||||
protected double computePrefHeight(double width) {
|
||||
return service.getCamHeight();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public WebcamService getService() {
|
||||
return service;
|
||||
}
|
||||
|
||||
public Node getView() {
|
||||
return view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.Mode;
|
||||
import com.sparrowwallet.sparrow.net.ServerType;
|
||||
import javafx.application.HostServices;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.control.HyperlinkLabel;
|
||||
import org.controlsfx.control.StatusBar;
|
||||
import org.controlsfx.control.ToggleSwitch;
|
||||
|
||||
public class WelcomeDialog extends Dialog<Mode> {
|
||||
private final HostServices hostServices;
|
||||
|
||||
private ServerType serverType = ServerType.ELECTRUM_SERVER;
|
||||
|
||||
public WelcomeDialog(HostServices services) {
|
||||
this.hostServices = services;
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
|
||||
setTitle("Welcome to Sparrow");
|
||||
dialogPane.setHeaderText("Welcome to Sparrow!");
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.setPrefWidth(600);
|
||||
dialogPane.setPrefHeight(520);
|
||||
|
||||
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
|
||||
if (!image.isError()) {
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setSmooth(false);
|
||||
imageView.setImage(image);
|
||||
dialogPane.setGraphic(imageView);
|
||||
}
|
||||
|
||||
final ButtonType onlineButtonType = new javafx.scene.control.ButtonType("Configure Server", ButtonBar.ButtonData.OK_DONE);
|
||||
final ButtonType offlineButtonType = new javafx.scene.control.ButtonType("Later or Offline Mode", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(onlineButtonType, offlineButtonType);
|
||||
|
||||
final VBox content = new VBox(20);
|
||||
content.setPadding(new Insets(20, 20, 20, 20));
|
||||
content.getChildren().add(createParagraph("Sparrow can operate in both an online and offline mode. In the online mode it connects to your Bitcoin Core node or Electrum server to display transaction history. In the offline mode it is useful as a transaction editor and as an airgapped multisig coordinator."));
|
||||
content.getChildren().add(createParagraph("Connecting Sparrow to your Bitcoin Core node ensures your privacy, while connecting Sparrow to your own Electrum server ensures wallets load quicker, you have access to a full blockchain explorer, and your public keys are always encrypted on disk. Examples of Electrum servers include ElectrumX and electrs."));
|
||||
content.getChildren().add(createParagraph("It's also possible to connect Sparrow to a public Electrum server (such as blockstream.info:700) but this is not recommended as you will share your public key information with that server."));
|
||||
content.getChildren().add(createParagraph("You can change your mode at any time using the toggle in the status bar. A blue toggle indicates you are connected to an Electrum server, while a green toggle indicates you are connected to a Bitcoin Code node."));
|
||||
content.getChildren().add(createStatusBar(onlineButtonType, offlineButtonType));
|
||||
|
||||
dialogPane.setContent(content);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton == onlineButtonType ? Mode.ONLINE : Mode.OFFLINE);
|
||||
}
|
||||
|
||||
private Label createParagraph(String text) {
|
||||
Label label = new Label(text);
|
||||
label.setWrapText(true);
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
private StatusBar createStatusBar(ButtonType onlineButtonType, ButtonType offlineButtonType) {
|
||||
StatusBar statusBar = new StatusBar();
|
||||
statusBar.setText("Online Mode");
|
||||
statusBar.getRightItems().add(createToggle(statusBar, onlineButtonType, offlineButtonType));
|
||||
|
||||
return statusBar;
|
||||
}
|
||||
|
||||
private ToggleSwitch createToggle(StatusBar statusBar, ButtonType onlineButtonType, ButtonType offlineButtonType) {
|
||||
ToggleSwitch toggleSwitch = new UnlabeledToggleSwitch();
|
||||
toggleSwitch.setStyle("-fx-padding: 1px 0 0 0");
|
||||
|
||||
toggleSwitch.selectedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
Button onlineButton = (Button) getDialogPane().lookupButton(onlineButtonType);
|
||||
onlineButton.setDefaultButton(newValue);
|
||||
Button offlineButton = (Button) getDialogPane().lookupButton(offlineButtonType);
|
||||
offlineButton.setDefaultButton(!newValue);
|
||||
|
||||
if(!newValue) {
|
||||
serverType = (serverType == ServerType.BITCOIN_CORE ? ServerType.ELECTRUM_SERVER : ServerType.BITCOIN_CORE);
|
||||
|
||||
if(serverType == ServerType.BITCOIN_CORE && !toggleSwitch.getStyleClass().contains("core-server")) {
|
||||
toggleSwitch.getStyleClass().add("core-server");
|
||||
} else {
|
||||
toggleSwitch.getStyleClass().remove("core-server");
|
||||
}
|
||||
}
|
||||
|
||||
statusBar.setText(newValue ? "Online Mode: " + serverType.getName() : "Offline Mode");
|
||||
});
|
||||
|
||||
toggleSwitch.setSelected(true);
|
||||
return toggleSwitch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
/**
|
||||
* This event is used by the DeviceAddressDialog to indicate that a USB device has displayed an address
|
||||
*
|
||||
*/
|
||||
public class AddressDisplayedEvent {
|
||||
private final String address;
|
||||
|
||||
public AddressDisplayedEvent(String address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
|
||||
public class BitcoinUnitChangedEvent {
|
||||
private final BitcoinUnit bitcoinUnit;
|
||||
|
||||
public BitcoinUnitChangedEvent(BitcoinUnit bitcoinUnit) {
|
||||
this.bitcoinUnit = bitcoinUnit;
|
||||
}
|
||||
|
||||
public BitcoinUnit getBitcoinUnit() {
|
||||
return bitcoinUnit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class BlockTransactionFetchedEvent extends PagedEvent {
|
||||
private final Sha256Hash txId;
|
||||
private final BlockTransaction blockTransaction;
|
||||
private final Map<Sha256Hash, BlockTransaction> inputTransactions;
|
||||
|
||||
public BlockTransactionFetchedEvent(Sha256Hash txId, BlockTransaction blockTransaction, Map<Sha256Hash, BlockTransaction> inputTransactions) {
|
||||
this(txId, blockTransaction, inputTransactions, 0, blockTransaction.getTransaction().getInputs().size());
|
||||
}
|
||||
|
||||
public BlockTransactionFetchedEvent(Sha256Hash txId, BlockTransaction blockTransaction, Map<Sha256Hash, BlockTransaction> inputTransactions, int pageStart, int pageEnd) {
|
||||
super(pageStart, pageEnd);
|
||||
this.txId = txId;
|
||||
this.blockTransaction = blockTransaction;
|
||||
this.inputTransactions = inputTransactions;
|
||||
}
|
||||
|
||||
public Sha256Hash getTxId() {
|
||||
return txId;
|
||||
}
|
||||
|
||||
public BlockTransaction getBlockTransaction() {
|
||||
return blockTransaction;
|
||||
}
|
||||
|
||||
public Map<Sha256Hash, BlockTransaction> getInputTransactions() {
|
||||
return inputTransactions;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class BlockTransactionOutputsFetchedEvent extends PagedEvent {
|
||||
private final Sha256Hash txId;
|
||||
private final List<BlockTransaction> outputTransactions;
|
||||
|
||||
public BlockTransactionOutputsFetchedEvent(Sha256Hash txId, List<BlockTransaction> outputTransactions, int pageStart, int pageEnd) {
|
||||
super(pageStart, pageEnd);
|
||||
this.txId = txId;
|
||||
this.outputTransactions = outputTransactions;
|
||||
}
|
||||
|
||||
public Sha256Hash getTxId() {
|
||||
return txId;
|
||||
}
|
||||
|
||||
public List<BlockTransaction> getOutputTransactions() {
|
||||
return outputTransactions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class BwtBootStatusEvent extends BwtStatusEvent {
|
||||
public BwtBootStatusEvent(String status) {
|
||||
super(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class BwtElectrumReadyStatusEvent extends BwtStatusEvent {
|
||||
private final String electrumAddr;
|
||||
|
||||
public BwtElectrumReadyStatusEvent(String status, String electrumAddr) {
|
||||
super(status);
|
||||
this.electrumAddr = electrumAddr;
|
||||
}
|
||||
|
||||
public String getElectrumAddr() {
|
||||
return electrumAddr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class BwtReadyStatusEvent extends BwtStatusEvent {
|
||||
public BwtReadyStatusEvent(String status) {
|
||||
super(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
public class BwtScanStatusEvent extends BwtStatusEvent {
|
||||
private final int progress;
|
||||
private final Duration remainingDuration;
|
||||
|
||||
public BwtScanStatusEvent(String status, int progress, Duration remainingDuration) {
|
||||
super(status);
|
||||
this.progress = progress;
|
||||
this.remainingDuration = remainingDuration;
|
||||
}
|
||||
|
||||
public int getProgress() {
|
||||
return progress;
|
||||
}
|
||||
|
||||
public boolean isCompleted() {
|
||||
return progress == 100;
|
||||
}
|
||||
|
||||
public Duration getRemaining() {
|
||||
return remainingDuration;
|
||||
}
|
||||
|
||||
public String getRemainingAsString() {
|
||||
if(remainingDuration != null) {
|
||||
if(progress < 30) {
|
||||
return Math.round((double)remainingDuration.toSeconds() / 60) + "m";
|
||||
} else {
|
||||
return remainingDuration.toMinutesPart() + "m " + remainingDuration.toSecondsPart() + "s";
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
/**
|
||||
* Empty class used to notify the bwt has shut down.
|
||||
* Note this extends from DisconnectionEvent, which is the more general event fired on any type of disconnection.
|
||||
*/
|
||||
public class BwtShutdownEvent extends DisconnectionEvent {
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user