mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
1147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d62d8ed73 | ||
|
|
2c3a7daa65 | ||
|
|
1771b6e667 | ||
|
|
853dcb2127 | ||
|
|
b25f346370 | ||
|
|
edd13fbe5b | ||
|
|
92c72e5a7b | ||
|
|
e8e676afd5 | ||
|
|
bb16ee7f0f | ||
|
|
4eb1c101b9 | ||
|
|
eb67b6e6cf | ||
|
|
29541bd1e3 | ||
|
|
08f94e9199 | ||
|
|
a7c7773a81 | ||
|
|
75f2d5710a | ||
|
|
2bff9fe891 | ||
|
|
4d2c405aea | ||
|
|
ca24afd23e | ||
|
|
47e124c7fc | ||
|
|
885aded701 | ||
|
|
e0729b7236 | ||
|
|
a058b75ee4 | ||
|
|
0a5e84fd3c | ||
|
|
6fa36255b2 | ||
|
|
3de52a6c0d | ||
|
|
6d17c8d79f | ||
|
|
26c4677b75 | ||
|
|
c5882a9f05 | ||
|
|
f99dc471a1 | ||
|
|
25e31b00d2 | ||
|
|
c1a59a1d21 | ||
|
|
e0d2a71b47 | ||
|
|
ef90b82396 | ||
|
|
9ec6e6d955 | ||
|
|
6b7ec2caa9 | ||
|
|
17130c81c8 | ||
|
|
0571312dca | ||
|
|
717c3bc45e | ||
|
|
c304f7c69d | ||
|
|
6d2a1ec1b9 | ||
|
|
59f7f5257b | ||
|
|
ba1f2dc17d | ||
|
|
820def620c | ||
|
|
9fef3e9e3e | ||
|
|
e8998a26d7 | ||
|
|
221ab1ca1a | ||
|
|
2f1e67779b | ||
|
|
96663b822c | ||
|
|
c7895d1cdd | ||
|
|
85762c581a | ||
|
|
e189a4d68d | ||
|
|
3306dd88d2 | ||
|
|
18952bc8a7 | ||
|
|
f6cfba6e1f | ||
|
|
fb37ccd14f | ||
|
|
3858c57816 | ||
|
|
572c11e847 | ||
|
|
bd70bd708b | ||
|
|
9a2ecfc85d | ||
|
|
f20fd1aebe | ||
|
|
2f2914077b | ||
|
|
712d498788 | ||
|
|
0e51731f69 | ||
|
|
2cc9bb8b72 | ||
|
|
553bebb6cf | ||
|
|
e91e5c366d | ||
|
|
3222f88999 | ||
|
|
9d32718c13 | ||
|
|
9664e2f042 | ||
|
|
979c04c303 | ||
|
|
725baa8776 | ||
|
|
64c9505620 | ||
|
|
55db7221ad | ||
|
|
75dc55858c | ||
|
|
1f28d3f32f | ||
|
|
c606212043 | ||
|
|
2ccf7ec2f0 | ||
|
|
2baad54660 | ||
|
|
46cca5fd49 | ||
|
|
bb32477671 | ||
|
|
e89384ee61 | ||
|
|
b213137e7a | ||
|
|
8fab00300a | ||
|
|
92a39c4305 | ||
|
|
116bb19bc6 | ||
|
|
ce54d47f46 | ||
|
|
6a8c756f7a | ||
|
|
64921ab240 | ||
|
|
ab01113d0e | ||
|
|
26752084ae | ||
|
|
84bc9745be | ||
|
|
b535fde0ff | ||
|
|
424cbfcf1a | ||
|
|
56af44b00b | ||
|
|
3b93198219 | ||
|
|
98bb30fa59 | ||
|
|
3ffb43539f | ||
|
|
e3e989de53 | ||
|
|
86e34d4681 | ||
|
|
a0b79d1d3b | ||
|
|
75ec92aff4 | ||
|
|
ab38a369da | ||
|
|
460a99d95d | ||
|
|
a66e14f3eb | ||
|
|
dd1ade2f99 | ||
|
|
177c98efb7 | ||
|
|
8830ad3c27 | ||
|
|
903384d02a | ||
|
|
4a8c020c3d | ||
|
|
4aaf25a384 | ||
|
|
a2f0c7f548 | ||
|
|
75bd97d0ae | ||
|
|
f072b98fd7 | ||
|
|
d3d2845f10 | ||
|
|
6831349f2f | ||
|
|
5b0fc7982b | ||
|
|
10a0dc7f8a | ||
|
|
f92e13270d | ||
|
|
9dfb4d1a1b | ||
|
|
91bfb60613 | ||
|
|
191254d920 | ||
|
|
6dc82815b6 | ||
|
|
eb6e81f81e | ||
|
|
19b3bca86a | ||
|
|
80df2eefed | ||
|
|
884a124c7e | ||
|
|
a284c1b9c6 | ||
|
|
ccf428eff6 | ||
|
|
96faac5aad | ||
|
|
7060aa3ed0 | ||
|
|
45a749e4bf | ||
|
|
3321448dd2 | ||
|
|
893b88246e | ||
|
|
8065b942bd | ||
|
|
a1a2118b35 | ||
|
|
17e59c5ae8 | ||
|
|
39d84e33bb | ||
|
|
87e8948d9a | ||
|
|
8317a06b6f | ||
|
|
1ab93adc22 | ||
|
|
af100fa0b4 | ||
|
|
66443d029b | ||
|
|
6fe713107e | ||
|
|
800c0b0131 | ||
|
|
e1a3975a07 | ||
|
|
33bf305ab5 | ||
|
|
14ff6f03bf | ||
|
|
68e91e405a | ||
|
|
7b22b96968 | ||
|
|
26107ff5e9 | ||
|
|
18931fb743 | ||
|
|
f68f32f942 | ||
|
|
2892fa6fca | ||
|
|
27286c7ffe | ||
|
|
d9b0fbe928 | ||
|
|
b15f203bc1 | ||
|
|
5d266cca20 | ||
|
|
904b070306 | ||
|
|
37d4efd7ba | ||
|
|
ece8998ba6 | ||
|
|
3f5000f278 | ||
|
|
68f4752c66 | ||
|
|
904a50be1f | ||
|
|
35f6ecad59 | ||
|
|
2a328ca120 | ||
|
|
1e83dbfbe3 | ||
|
|
e703c08634 | ||
|
|
f8a574375a | ||
|
|
cfcafe5b2c | ||
|
|
3ea7b8194d | ||
|
|
1a7ae33fa8 | ||
|
|
2bbe126c8d | ||
|
|
f2e6efe2b7 | ||
|
|
404e6cd862 | ||
|
|
4ab04477f8 | ||
|
|
611e8f1a6d | ||
|
|
c8fd810bd3 | ||
|
|
c619a9000c | ||
|
|
3d70614797 | ||
|
|
c29b4b8e5f | ||
|
|
4286b64b41 | ||
|
|
e8c50cbf54 | ||
|
|
407807d871 | ||
|
|
202af650de | ||
|
|
e1e42ed500 | ||
|
|
1767cc74a9 | ||
|
|
4028018605 | ||
|
|
33f8b6d6d8 | ||
|
|
c738436ddc | ||
|
|
467174ebf8 | ||
|
|
f9fed8a04f | ||
|
|
00194c2f0d | ||
|
|
37be1e8c46 | ||
|
|
357f8f6f94 | ||
|
|
ba4a594a41 | ||
|
|
928a87363d | ||
|
|
1708c48b0c | ||
|
|
17adbf3603 | ||
|
|
57ff5c63b0 | ||
|
|
4f4cab6a6c | ||
|
|
e848b58a60 | ||
|
|
5c8f0ad5ce | ||
|
|
173245d900 | ||
|
|
dd2e590475 | ||
|
|
5bc4aab8d8 | ||
|
|
94308a6098 | ||
|
|
c7861dfac4 | ||
|
|
071df0b6d3 | ||
|
|
ac0fbc7435 | ||
|
|
275051ed30 | ||
|
|
3c4cf03684 | ||
|
|
2f1c221a7c | ||
|
|
6748abbf70 | ||
|
|
e04a35afed | ||
|
|
f574a1dae5 | ||
|
|
f67ff43eb3 | ||
|
|
55a4b1937f | ||
|
|
b8b41f840a | ||
|
|
9f5a71020d | ||
|
|
e8c9e73985 | ||
|
|
e0dba87286 | ||
|
|
3c308788d8 | ||
|
|
3d28715713 | ||
|
|
61d2030934 | ||
|
|
56c67b9ec6 | ||
|
|
77ed529c26 | ||
|
|
7eddf4a12a | ||
|
|
ff69a4168b | ||
|
|
cec204b7ae | ||
|
|
188ef3762d | ||
|
|
64c9acb4b2 | ||
|
|
c25018bb64 | ||
|
|
c1a5b855df | ||
|
|
0da031fae4 | ||
|
|
72dff060d2 | ||
|
|
d6f4ffafa1 | ||
|
|
4b36217699 | ||
|
|
9a109e6fc1 | ||
|
|
227aac5b76 | ||
|
|
82ea72d2d1 | ||
|
|
f1637c3d20 | ||
|
|
07be841246 | ||
|
|
3ce5d92556 | ||
|
|
8f11396e00 | ||
|
|
2259523387 | ||
|
|
d67711b123 | ||
|
|
c89ca51a1b | ||
|
|
c5c951dd59 | ||
|
|
9f68f0227e | ||
|
|
7faed2ddd3 | ||
|
|
7dceb701b2 | ||
|
|
e454ccc326 | ||
|
|
bac50668e8 | ||
|
|
ff9e1556d5 | ||
|
|
dffb49b071 | ||
|
|
7387d9060f | ||
|
|
52e1842174 | ||
|
|
d738e5cbaf | ||
|
|
386b6db5ff | ||
|
|
47d57e5a44 | ||
|
|
92068ca64f | ||
|
|
349510e900 | ||
|
|
948d0f4bea | ||
|
|
b9a5586167 | ||
|
|
54edddbd2d | ||
|
|
b9c83426dc | ||
|
|
40dae875b8 | ||
|
|
e762ad5e18 | ||
|
|
9b1bd2127f | ||
|
|
88701a97c7 | ||
|
|
600c927594 | ||
|
|
1387a0f62b | ||
|
|
e3df94338f | ||
|
|
ee466aa1e4 | ||
|
|
9704d61a7a | ||
|
|
69d7e82b71 | ||
|
|
288d80d163 | ||
|
|
b1edf4e934 | ||
|
|
af9b0b444c | ||
|
|
a3865e68bd | ||
|
|
ed70585e90 | ||
|
|
f707fd007b | ||
|
|
d92e30dba3 | ||
|
|
4afa5c1789 | ||
|
|
e89e8e5d01 | ||
|
|
52f600485b | ||
|
|
b5a0d65f3c | ||
|
|
fc4433e7ae | ||
|
|
f2b6a9bedb | ||
|
|
35f0b1291f | ||
|
|
045699ecd9 | ||
|
|
74a2b46508 | ||
|
|
feaa594e52 | ||
|
|
d966541ea7 | ||
|
|
ecfc287871 | ||
|
|
c10461126a | ||
|
|
20903cefc8 | ||
|
|
e1caf40c3a | ||
|
|
6e70034758 | ||
|
|
ee30ff28f6 | ||
|
|
03654b5ebd | ||
|
|
7071174aed | ||
|
|
2c27531056 | ||
|
|
57d5556728 | ||
|
|
f4da2ae6be | ||
|
|
57dfe3af8c | ||
|
|
618d14a965 | ||
|
|
ee95942289 | ||
|
|
c6c15c3ec7 | ||
|
|
ed4c51b664 | ||
|
|
b3f39434ee | ||
|
|
30ded17581 | ||
|
|
19557bf358 | ||
|
|
bdc2cbbb99 | ||
|
|
82e2d15aaf | ||
|
|
6aff31886e | ||
|
|
7cc089f0ee | ||
|
|
6c025f7274 | ||
|
|
d5220324c4 | ||
|
|
a0308bd3a1 | ||
|
|
856b4ca2c1 | ||
|
|
8abfd10f0c | ||
|
|
1bc4d33bb7 | ||
|
|
35c67f692a | ||
|
|
a319902cf7 | ||
|
|
2bc8d26b4f | ||
|
|
402da58391 | ||
|
|
b533afe27f | ||
|
|
6a4afb45dc | ||
|
|
73a718e6c7 | ||
|
|
140f6e3318 | ||
|
|
073add9e71 | ||
|
|
35ecf1f7a7 | ||
|
|
71db62e5a5 | ||
|
|
ff82b52a5b | ||
|
|
18e81de26d | ||
|
|
27f37fc158 | ||
|
|
6638ae1d7b | ||
|
|
42094c2a70 | ||
|
|
932abdd412 | ||
|
|
b4bb6f2b15 | ||
|
|
4c7b523de1 | ||
|
|
59608e9acc | ||
|
|
77afedb2d0 | ||
|
|
ccb2c626a7 | ||
|
|
9b6eec5fdd | ||
|
|
02786fb7d3 | ||
|
|
384594ba5c | ||
|
|
dab02f2cbd | ||
|
|
978f13fbf3 | ||
|
|
831386a656 | ||
|
|
8b7406e25a | ||
|
|
9e7fed6646 | ||
|
|
89d88326e8 | ||
|
|
f18dd02161 | ||
|
|
e00db19485 | ||
|
|
a706b71d7c | ||
|
|
a83b26cc6f | ||
|
|
dc73843447 | ||
|
|
3e6aa2b943 | ||
|
|
909d3f4fbe | ||
|
|
fb0748f4a7 | ||
|
|
1ec4823122 | ||
|
|
cce5e0bf21 | ||
|
|
0006595786 | ||
|
|
6b884f645e | ||
|
|
946723a80e | ||
|
|
791debbaca | ||
|
|
3bb5a4e50d | ||
|
|
3efa3a68fe | ||
|
|
dc314a94ff | ||
|
|
11b062c41f | ||
|
|
faeb2a3894 | ||
|
|
b1f45a346d | ||
|
|
0b16de6a85 | ||
|
|
66d4b75373 | ||
|
|
3b376a615c | ||
|
|
c8da570c89 | ||
|
|
38f4a5b7fc | ||
|
|
b03ff36cf3 | ||
|
|
d145938652 | ||
|
|
1993388d9f | ||
|
|
15e7540b26 | ||
|
|
c2beaf5f80 | ||
|
|
5d395419ff | ||
|
|
f672a51470 | ||
|
|
f741239e23 | ||
|
|
3442194b01 | ||
|
|
0b025d7679 | ||
|
|
227de74317 | ||
|
|
0b9fced8bc | ||
|
|
274e4f7498 | ||
|
|
f6496b5e7e | ||
|
|
ee5ff1845f | ||
|
|
c84e835ce5 | ||
|
|
cdb5be2fe9 | ||
|
|
2d0c356aa5 | ||
|
|
0a92c253b8 | ||
|
|
92347863bc | ||
|
|
a146ec5e1a | ||
|
|
83be43e94e | ||
|
|
d726b3b5b5 | ||
|
|
c845aac8e5 | ||
|
|
1cdf114fff | ||
|
|
4d1a99d076 | ||
|
|
b1debd9879 | ||
|
|
3843917bd1 | ||
|
|
2fa4281b6f | ||
|
|
c42944e15b | ||
|
|
9f9f158709 | ||
|
|
3a753f0d5d | ||
|
|
13c94112d4 | ||
|
|
dd46f12a05 | ||
|
|
9a648d7e84 | ||
|
|
614dbab1fd | ||
|
|
3d80ee1984 | ||
|
|
536fb49bc3 | ||
|
|
edd93141bc | ||
|
|
20c2d19a9c | ||
|
|
17b2fe9cd8 | ||
|
|
134e7cee0c | ||
|
|
108ddffcc7 | ||
|
|
20e76daff8 | ||
|
|
6876a7f0d2 | ||
|
|
87dbf86129 | ||
|
|
175d1ae33b | ||
|
|
bf9d318209 | ||
|
|
7bee44c5ad | ||
|
|
a32e90aed0 | ||
|
|
310db1008e | ||
|
|
21560d098e | ||
|
|
33f7c5c82e | ||
|
|
52f17e9f46 | ||
|
|
1e7ffa365b | ||
|
|
e00fc83e5c | ||
|
|
a2fcb3539e | ||
|
|
cb69d056b8 | ||
|
|
e7f950cd32 | ||
|
|
3b2686ffeb | ||
|
|
2387a609df | ||
|
|
6457a84f4b | ||
|
|
dee231d43a | ||
|
|
8c4af4ac85 | ||
|
|
58cf0d9f5d | ||
|
|
de5c0d4681 | ||
|
|
780832d785 | ||
|
|
b8808a17b5 | ||
|
|
5001e58c2b | ||
|
|
95514b66ea | ||
|
|
90c6d6c8f8 | ||
|
|
88d2ce3ad0 | ||
|
|
484f3a6842 | ||
|
|
866caf8bc5 | ||
|
|
be9e3c3bf7 | ||
|
|
49a3cb1978 | ||
|
|
c3ddc4f7ad | ||
|
|
ff4d704bde | ||
|
|
a773be12c4 | ||
|
|
7ff6f16df0 | ||
|
|
d9053f580f | ||
|
|
fee2764338 | ||
|
|
61cb4fdd74 | ||
|
|
d89869ebff | ||
|
|
8518819dfa | ||
|
|
0a8923be6b | ||
|
|
cda821e5af | ||
|
|
be2402702f | ||
|
|
d723a8ab5c | ||
|
|
5c882c81c8 | ||
|
|
24d2709564 | ||
|
|
ce15bc46a7 | ||
|
|
de4bc30730 | ||
|
|
64f6811c00 | ||
|
|
e05ab104b9 | ||
|
|
b4f1c4d13e | ||
|
|
36a3c4123c | ||
|
|
939eb1bd8d | ||
|
|
854469cc7d | ||
|
|
338ae41baa | ||
|
|
6cae89d276 | ||
|
|
15c1e25122 | ||
|
|
c5716416b6 | ||
|
|
65e4a766ab | ||
|
|
c2a4c9bbb3 | ||
|
|
b042110154 | ||
|
|
da97b5593b | ||
|
|
44b4b0cdb9 | ||
|
|
190b7fe819 | ||
|
|
8eb89f0fe9 | ||
|
|
3085353729 | ||
|
|
1465ce112c | ||
|
|
5c797858ba | ||
|
|
8c51c4be0c | ||
|
|
4e8ecff67b | ||
|
|
6303573dd9 | ||
|
|
83f8c51aca | ||
|
|
e675348461 | ||
|
|
9026f5bf07 | ||
|
|
a7aa3705ef | ||
|
|
a64c941f36 | ||
|
|
02f18b5b35 | ||
|
|
db740f4747 | ||
|
|
6a3206b938 | ||
|
|
23414b0bc6 | ||
|
|
0234cfca30 | ||
|
|
f707714611 | ||
|
|
7b90230c45 | ||
|
|
d3468f3eba | ||
|
|
fc9500b45c | ||
|
|
df0b5fcd19 | ||
|
|
59cde5699e | ||
|
|
bd836a9580 | ||
|
|
90aade338c | ||
|
|
40e2dba490 | ||
|
|
d13e8bacec | ||
|
|
d3fa05a4df | ||
|
|
734dd2e119 | ||
|
|
a9ea9ea2ae | ||
|
|
1b521bc140 | ||
|
|
364e3c5f5f | ||
|
|
647cda3f90 | ||
|
|
bcca4d4bcc | ||
|
|
0b038dca17 | ||
|
|
ad6f63df56 | ||
|
|
b5b8b62a16 | ||
|
|
8d3066b377 | ||
|
|
18933b4326 | ||
|
|
672841c0ed | ||
|
|
872c7cb257 | ||
|
|
6d691c4741 | ||
|
|
bbba27752d | ||
|
|
d9cf090cc9 | ||
|
|
46effa572c | ||
|
|
f76ab01c11 | ||
|
|
bfae5871c8 | ||
|
|
451c5d1648 | ||
|
|
3423626c60 | ||
|
|
8982f02a4a | ||
|
|
f06b5fdacf | ||
|
|
8ea8cf2985 | ||
|
|
2771d34951 | ||
|
|
669b33a3e1 | ||
|
|
c934a1cdfe | ||
|
|
0ccd41f5e8 | ||
|
|
1af3bb7dfc | ||
|
|
f0fcf1ea8a | ||
|
|
5325542c81 | ||
|
|
3d9675841b | ||
|
|
0d475fba0e | ||
|
|
05714de945 | ||
|
|
89c1b53c95 | ||
|
|
d1cb699027 | ||
|
|
9ee1250f3a | ||
|
|
7a68cf867e | ||
|
|
1c9041f12b | ||
|
|
d3de5e0a42 | ||
|
|
b1bd21533d | ||
|
|
51f997a34a | ||
|
|
d5607faadd | ||
|
|
1a9c3ccb79 | ||
|
|
bc8fa00608 | ||
|
|
329d806ffd | ||
|
|
e040d0f971 | ||
|
|
053de4d06f | ||
|
|
4016458540 | ||
|
|
8b98efaad7 | ||
|
|
217ed90d9a | ||
|
|
b82aefbd5d | ||
|
|
63b954d38d | ||
|
|
76cdea0087 | ||
|
|
da1a3a8f85 | ||
|
|
d11e92e166 | ||
|
|
61efd237c2 | ||
|
|
6099948605 | ||
|
|
45b743e7c8 | ||
|
|
108c6cbeb4 | ||
|
|
da02891c6a | ||
|
|
a03e9ea946 | ||
|
|
dd4a6f8da0 | ||
|
|
c01033cbb0 | ||
|
|
1fe93395c5 | ||
|
|
cf960fe59d | ||
|
|
2416d41131 | ||
|
|
0699095d77 | ||
|
|
6ed3e84d95 | ||
|
|
6c196cfeb7 | ||
|
|
987784fb89 | ||
|
|
d60ac5d73b | ||
|
|
c2361c034c | ||
|
|
199514e7f3 | ||
|
|
a1364d19c2 | ||
|
|
e73428b156 | ||
|
|
a684a222cb | ||
|
|
1646cbbd68 | ||
|
|
035a48a0cd | ||
|
|
71be8ce239 | ||
|
|
1ccf9bf467 | ||
|
|
42a0cd0b1b | ||
|
|
39547b1d11 | ||
|
|
c223c1372b | ||
|
|
a450dcbac1 | ||
|
|
ee5fbce2ce | ||
|
|
559b88be18 | ||
|
|
dfbc5fa556 | ||
|
|
c69969947b | ||
|
|
e1738a25a1 | ||
|
|
6c54082a12 | ||
|
|
bc5500731d | ||
|
|
0665c552aa | ||
|
|
1ab8463578 | ||
|
|
e016e68e42 | ||
|
|
d116159ed9 | ||
|
|
3c24c011e3 | ||
|
|
536bdfe43a | ||
|
|
0b41110938 | ||
|
|
da1d4018cf | ||
|
|
b569c67d3b | ||
|
|
50a5a68b71 | ||
|
|
23a4d80ddb | ||
|
|
6a64ebcc5b | ||
|
|
8e622bfbb1 | ||
|
|
d189dce7f0 | ||
|
|
43ac1bf555 | ||
|
|
d2d26e4e57 | ||
|
|
de1f13d64c | ||
|
|
2be04bfbe2 | ||
|
|
57c33ae06a | ||
|
|
99e545f0b8 | ||
|
|
accb115a20 | ||
|
|
9de1cec160 | ||
|
|
f7774700db | ||
|
|
ffb81c6246 | ||
|
|
b7c9b14b1f | ||
|
|
53b0db61ea | ||
|
|
6c09e47e4f | ||
|
|
9d9ad63b4d | ||
|
|
7ef46a2662 | ||
|
|
a7d8c87c47 | ||
|
|
1bb2459277 | ||
|
|
6b39562925 | ||
|
|
3d34e1fc4e | ||
|
|
469e1241d5 | ||
|
|
86c0ca975b | ||
|
|
53eeea3225 | ||
|
|
e9ff9d3b37 | ||
|
|
e2c011ec60 | ||
|
|
5328740bff | ||
|
|
017df9845d | ||
|
|
ed70658be9 | ||
|
|
dc74c3014f | ||
|
|
53d5c5f354 | ||
|
|
df1a017eb3 | ||
|
|
63ab2a9fbe | ||
|
|
b2b7799a0f | ||
|
|
e817f94045 | ||
|
|
46571a6029 | ||
|
|
4f4d689f5a | ||
|
|
a0308938de | ||
|
|
30391160da | ||
|
|
3273aa32a5 | ||
|
|
1e358d31d5 | ||
|
|
b57dd98059 | ||
|
|
5b596d2a56 | ||
|
|
62db68c7b2 | ||
|
|
b3bb85cd53 | ||
|
|
fe6fb8470d | ||
|
|
ee97eac00c | ||
|
|
48e3ccc165 | ||
|
|
52c0503b6d | ||
|
|
c052399a16 | ||
|
|
76faf7ae35 | ||
|
|
36ea33a919 | ||
|
|
8aad4d8030 | ||
|
|
a23c8c5c04 | ||
|
|
0434ef6ec6 | ||
|
|
4d85a2f6fb | ||
|
|
e9b235f748 | ||
|
|
b10e875941 | ||
|
|
632400f52c | ||
|
|
30320805da | ||
|
|
b63479c45f | ||
|
|
7ea6495f28 | ||
|
|
8e913ef974 | ||
|
|
eb191406e6 | ||
|
|
ca89b70150 | ||
|
|
6bcd6950ef | ||
|
|
dfa9c77e3e | ||
|
|
af143f2ea1 | ||
|
|
52761b3901 | ||
|
|
f027d23fcf | ||
|
|
2d30f3e9d2 | ||
|
|
a63b383425 | ||
|
|
e04eb733e7 | ||
|
|
0a49298ec2 | ||
|
|
1121cfcaf9 | ||
|
|
66b5fad6c2 | ||
|
|
5d352f1b6c | ||
|
|
9018175f54 | ||
|
|
98c55661e8 | ||
|
|
521407f879 | ||
|
|
fa4257ad7d | ||
|
|
d73dec8f0b | ||
|
|
c6124376df | ||
|
|
87c19f4232 | ||
|
|
a1f116909f | ||
|
|
ca9951f9b8 | ||
|
|
2bedd79e6f | ||
|
|
a9af2d8982 | ||
|
|
655a563f61 | ||
|
|
9777c709a1 | ||
|
|
453e63025d | ||
|
|
251f4854fc | ||
|
|
17ecc98e1e | ||
|
|
43c32f0b0e | ||
|
|
f9272f5cfc | ||
|
|
ec3b07147c | ||
|
|
348e07a307 | ||
|
|
2009f5bd7b | ||
|
|
0e6a2c339e | ||
|
|
844f7cd478 | ||
|
|
a2edc9f3b2 | ||
|
|
35d09aee77 | ||
|
|
a79d1d5bd6 | ||
|
|
ba9315fcd1 | ||
|
|
f5c71b4f90 | ||
|
|
5a40b39952 | ||
|
|
2ac3b42d4d | ||
|
|
7cfad373f2 | ||
|
|
5d061a79e2 | ||
|
|
eb59a9d505 | ||
|
|
784f867a8e | ||
|
|
954a9d66fb | ||
|
|
dfdced3e8c | ||
|
|
0af141c5b7 | ||
|
|
70c4c69bbf | ||
|
|
5597189ea5 | ||
|
|
8884adcefb | ||
|
|
92be03ca51 | ||
|
|
b4f9103b62 | ||
|
|
63f3a6cdf9 | ||
|
|
d8ce3b9d37 | ||
|
|
2ac5742b0e | ||
|
|
58dd69091e | ||
|
|
7a93f9956f | ||
|
|
8dbded0185 | ||
|
|
41b93e3860 | ||
|
|
2f741779fa | ||
|
|
7ea92bc068 | ||
|
|
3b07b5d8dd | ||
|
|
f06e79cfd3 | ||
|
|
918273d825 | ||
|
|
eef534617c | ||
|
|
4e89b1494d | ||
|
|
be1ae35a60 | ||
|
|
47395465bb | ||
|
|
c49f89f711 | ||
|
|
6721c83266 | ||
|
|
ce27be2053 | ||
|
|
fee2a0e4f8 | ||
|
|
52fcfebe38 | ||
|
|
0e1a856555 | ||
|
|
255b48d126 | ||
|
|
584ceb1f0f | ||
|
|
8053119d42 | ||
|
|
b8e97525eb | ||
|
|
99adbf6691 | ||
|
|
a96760d1a4 | ||
|
|
dd9e51e31c | ||
|
|
a5b607b30b | ||
|
|
f7e9898637 | ||
|
|
1ad1d233cd | ||
|
|
52687a48df | ||
|
|
dca4f827fe | ||
|
|
2726011126 | ||
|
|
77bd646b1d | ||
|
|
d664d9ff19 | ||
|
|
5d24950d7f | ||
|
|
3a8a50006d | ||
|
|
decd6ef627 | ||
|
|
6ab061a5a2 | ||
|
|
d934e28b3a | ||
|
|
57588fd6ac | ||
|
|
66b1e4b15d | ||
|
|
921d52cb8c | ||
|
|
4a2d673cce | ||
|
|
e6f6f64512 | ||
|
|
d2eea9f0ae | ||
|
|
012fdc8cc6 | ||
|
|
74054aab5a | ||
|
|
11908b9e0c | ||
|
|
570cccfc62 | ||
|
|
de9d6877e1 | ||
|
|
c31fa116ca | ||
|
|
10b15226a1 | ||
|
|
7a46376bc1 | ||
|
|
b761aaf970 | ||
|
|
3a08db2dfb | ||
|
|
41fd2e087c | ||
|
|
ba476bcdca | ||
|
|
c7a3a74d5a | ||
|
|
eed2f6abaf | ||
|
|
80f0b14c02 | ||
|
|
b12b81282c | ||
|
|
07d661ea00 | ||
|
|
a15263eeb9 | ||
|
|
6f19bccc54 | ||
|
|
547d567431 | ||
|
|
ece4e9074a | ||
|
|
0737c6ddc3 | ||
|
|
2aa9458aa1 | ||
|
|
cb3b898e29 | ||
|
|
ceac61eb19 | ||
|
|
bc9f5e5328 | ||
|
|
a293800897 | ||
|
|
3be34d0d40 | ||
|
|
eec87f017a | ||
|
|
799037502f | ||
|
|
f2b727c587 | ||
|
|
b255d1827e | ||
|
|
d9e01da7e2 | ||
|
|
99a98439cd | ||
|
|
724e7e2378 | ||
|
|
0619c9ffac | ||
|
|
cb7b51e8d1 | ||
|
|
c8172265dd | ||
|
|
cce9d424bc | ||
|
|
45227b75dc | ||
|
|
6eaba7956c | ||
|
|
cc4b94f738 | ||
|
|
423628a104 | ||
|
|
d262b48f31 | ||
|
|
f97a0468cc | ||
|
|
b4d87686ac | ||
|
|
8cbf07c917 | ||
|
|
30fc4c35f4 | ||
|
|
7912d492e5 | ||
|
|
d921eb6138 | ||
|
|
6875fdc4d0 | ||
|
|
0bc701f5c9 | ||
|
|
5de0808b1e | ||
|
|
9d680c9f82 | ||
|
|
63fc4c570d | ||
|
|
ab2fff0194 | ||
|
|
c644ba9a3e | ||
|
|
2791873048 | ||
|
|
89266bc76f | ||
|
|
9573b4abec | ||
|
|
ddcff8fa15 | ||
|
|
d36f6f4a7e | ||
|
|
c2b72f1e45 | ||
|
|
28e6471ade | ||
|
|
c3b8eb5087 | ||
|
|
75779e7ddc | ||
|
|
118bc7b73c | ||
|
|
e6a0fdedd9 | ||
|
|
28098fafd7 | ||
|
|
0c0e87af21 | ||
|
|
8abfd7149b | ||
|
|
674896cea4 | ||
|
|
ec514651fc | ||
|
|
8071d48911 | ||
|
|
69daa3835b | ||
|
|
57dac276b2 | ||
|
|
3a84595aac | ||
|
|
7b591c84f8 | ||
|
|
e8c8167451 | ||
|
|
ec93fc53b2 | ||
|
|
ba2902b725 | ||
|
|
e7defa14b9 | ||
|
|
4fbc3f25b3 | ||
|
|
4ae88d5cc8 | ||
|
|
1aea0720df | ||
|
|
846f340f84 | ||
|
|
cb137205c0 | ||
|
|
f910709e1b | ||
|
|
1d78dd6b19 | ||
|
|
3197be8a75 | ||
|
|
91ed4128fe | ||
|
|
f415490fcd | ||
|
|
4bdb5a28d0 | ||
|
|
58efe0b3c0 | ||
|
|
da3cecd324 | ||
|
|
09b1451dc1 | ||
|
|
9ae31a3429 | ||
|
|
b5258f89c1 | ||
|
|
fcc67fc4f2 | ||
|
|
579465ac6c | ||
|
|
6cbee81e72 | ||
|
|
ad683ed444 | ||
|
|
c53b1571a3 | ||
|
|
2c19b2ffef | ||
|
|
3694d1b0f5 | ||
|
|
e7a4746d22 | ||
|
|
7961f6f065 | ||
|
|
7c1c500990 | ||
|
|
5f85d8a97b | ||
|
|
eb77db0469 | ||
|
|
a62445a52f | ||
|
|
553a1fe673 | ||
|
|
a50b0b7902 | ||
|
|
23029a2022 | ||
|
|
bd7156082b | ||
|
|
bdd2a97ee3 | ||
|
|
a2acec7900 | ||
|
|
c4a9a2d557 | ||
|
|
24481459a7 | ||
|
|
14d2d7439e | ||
|
|
dae8877983 | ||
|
|
81f2e72f0d | ||
|
|
a993e4b378 | ||
|
|
de14d4e0f1 | ||
|
|
fd58f063ac | ||
|
|
4593ce582d | ||
|
|
8d8fb8f5dc | ||
|
|
3e4a4f7442 | ||
|
|
d5ad6b983a | ||
|
|
30d31b35ab | ||
|
|
18839b8853 | ||
|
|
e46a77f5ef | ||
|
|
2c8c0a8073 | ||
|
|
570bf1c2e7 | ||
|
|
1d1dc757ee | ||
|
|
50f0085118 | ||
|
|
1edfc6bfbd | ||
|
|
5596c54107 | ||
|
|
ffe1d737fb | ||
|
|
9b36b81721 | ||
|
|
800c1e1a44 | ||
|
|
a011ef128a | ||
|
|
2e8760abe5 | ||
|
|
6acfaa97f3 | ||
|
|
4426ccada7 | ||
|
|
da02f36663 | ||
|
|
e240cd04d4 | ||
|
|
16a7fdffcf | ||
|
|
2fdb4e47b0 | ||
|
|
a316351ba7 | ||
|
|
492bc76957 | ||
|
|
e8170398e3 | ||
|
|
ff233c699b | ||
|
|
0d1cbce5de | ||
|
|
3e7bbc03c1 | ||
|
|
5ba091de0d | ||
|
|
1098c31787 | ||
|
|
c20277a754 | ||
|
|
a5e40d5ac9 | ||
|
|
aab42bb341 | ||
|
|
748349244e | ||
|
|
9e9c8befe0 | ||
|
|
b9fb11bdf3 | ||
|
|
cd6f9576cc | ||
|
|
b919b165d6 | ||
|
|
a2d9a3f770 | ||
|
|
39fccd5442 | ||
|
|
cfeaccb26e | ||
|
|
08a2a2b428 | ||
|
|
297bcf2762 | ||
|
|
092d333378 | ||
|
|
eb58e9ea39 | ||
|
|
63c498da87 | ||
|
|
b40dcb4bd8 | ||
|
|
49fe1d0743 | ||
|
|
25c40b89d7 | ||
|
|
bfe1e78e57 | ||
|
|
fa79ddc573 | ||
|
|
653e8200ca | ||
|
|
8806ca6534 | ||
|
|
897306e16e | ||
|
|
1e25688f9d | ||
|
|
2b6abcbfb9 | ||
|
|
ce33190177 | ||
|
|
d68b14532d | ||
|
|
e83cfe4f07 | ||
|
|
d3e9cc0788 | ||
|
|
87990026ea | ||
|
|
7923f5d854 | ||
|
|
69eea8824a | ||
|
|
a1517a67f5 | ||
|
|
480ae479af | ||
|
|
94213ebf83 | ||
|
|
eedce96453 | ||
|
|
d6886f51f7 | ||
|
|
0497f1e625 | ||
|
|
95ac046a09 | ||
|
|
3e6938bf51 | ||
|
|
786b8436f8 | ||
|
|
b055e520c2 | ||
|
|
9d75fb7074 | ||
|
|
5434f0fef7 | ||
|
|
ed68ca5f26 | ||
|
|
4eea36678e | ||
|
|
15ce0b3e05 | ||
|
|
eb73da1993 | ||
|
|
01ba21d303 | ||
|
|
442eecf916 | ||
|
|
231d02ac0d | ||
|
|
43b011d3da | ||
|
|
8a12cc3cce | ||
|
|
7e03870c0b | ||
|
|
a6f56416e3 | ||
|
|
efe7772135 | ||
|
|
15c96ce007 | ||
|
|
78d2a712c5 | ||
|
|
2664292993 | ||
|
|
b9813c2c8f | ||
|
|
0c00e6512f | ||
|
|
cf31bfe5ea | ||
|
|
5cdceb5194 | ||
|
|
2c82e6c447 | ||
|
|
f56f3b6105 | ||
|
|
9420ddc5c7 | ||
|
|
ade1d83817 | ||
|
|
9a517380a0 | ||
|
|
6fbb59ddac | ||
|
|
fe4a4b6fad | ||
|
|
8dd5705f02 | ||
|
|
6723225885 | ||
|
|
fb19454259 | ||
|
|
c6f63a13d3 | ||
|
|
77057fcac5 | ||
|
|
676d5e9b88 | ||
|
|
d6dda0053a | ||
|
|
d33e62367e | ||
|
|
47d7a53c1c | ||
|
|
6fcf472638 | ||
|
|
e2f5a1a0e4 | ||
|
|
8ea25e735d | ||
|
|
a9d05070ed | ||
|
|
d78ec6b02a | ||
|
|
8971b4e190 | ||
|
|
dea0adaea4 | ||
|
|
5de4fbcbe7 | ||
|
|
2330e086ea | ||
|
|
11d51b3854 | ||
|
|
008469adbc | ||
|
|
bbe26c7a30 | ||
|
|
c2b9bb413d | ||
|
|
5c1c4f8db2 | ||
|
|
b46bb7ecc5 | ||
|
|
2fed65db8d | ||
|
|
66cc4b5526 | ||
|
|
91488f0350 | ||
|
|
f2429e011e | ||
|
|
1374f36b1f | ||
|
|
d185e65b3f | ||
|
|
64bf17b1a0 | ||
|
|
93b50468fd | ||
|
|
65ab756c2b | ||
|
|
e298285145 | ||
|
|
d730eeb9a1 | ||
|
|
0d63441611 | ||
|
|
f0ecf382e0 | ||
|
|
d0dee84031 | ||
|
|
ad59b691fd | ||
|
|
a256e34878 | ||
|
|
6ad00c112a | ||
|
|
0010a19c24 | ||
|
|
d78c7a91cb | ||
|
|
4a2dced02f | ||
|
|
2d1a63be57 | ||
|
|
9e207d0f24 | ||
|
|
efad94ddfd | ||
|
|
d49fde755e | ||
|
|
0224af18c0 | ||
|
|
53a4dfb702 | ||
|
|
4e4e798133 | ||
|
|
e8bd4a12ac | ||
|
|
dffed6e78f | ||
|
|
46a4f360f7 | ||
|
|
64a5071345 | ||
|
|
1fba93b4f0 | ||
|
|
caa67b03a8 | ||
|
|
5f1b2f9eb8 | ||
|
|
2968cb12aa | ||
|
|
8261b38de9 | ||
|
|
2c4489a0d0 | ||
|
|
52dcb73fd7 | ||
|
|
91a2f8ecfa | ||
|
|
6905e1b47f | ||
|
|
640b0d3174 | ||
|
|
a86cd8772f | ||
|
|
83be195a43 | ||
|
|
14bf73e2f1 | ||
|
|
efb7b06e4e | ||
|
|
8b9f37d678 | ||
|
|
e83f16f5c5 | ||
|
|
e648970b17 | ||
|
|
44badf8c86 | ||
|
|
516ff17eed | ||
|
|
3b1533eb82 | ||
|
|
2e7a955ada | ||
|
|
4aa961437b | ||
|
|
0d9399a1a4 | ||
|
|
9b62cf563a | ||
|
|
7c525e2f2f | ||
|
|
63888d55fd | ||
|
|
7287ead851 | ||
|
|
6fc829f4f1 | ||
|
|
d12b0ce9be | ||
|
|
393714eb42 | ||
|
|
fe363b8a2d | ||
|
|
064ce7e6e6 | ||
|
|
af72846998 | ||
|
|
899b4f7c90 | ||
|
|
2e5b41b711 | ||
|
|
809f048b44 | ||
|
|
51c0c44a2e | ||
|
|
811d6d8c67 | ||
|
|
c1b9bf42ad | ||
|
|
cafe742cd8 | ||
|
|
ba50ec052b | ||
|
|
386f97abf8 | ||
|
|
7678b1b100 | ||
|
|
9df82a78e7 | ||
|
|
c87466ada4 | ||
|
|
135eb6763a | ||
|
|
24a0be9c3a | ||
|
|
f090bc82ae | ||
|
|
3254479ee5 | ||
|
|
6cc4266435 | ||
|
|
3e48b293b0 | ||
|
|
492b9494ec | ||
|
|
b31d2d49d6 | ||
|
|
245bd4a44d | ||
|
|
6d4a887d00 | ||
|
|
f95023f59d | ||
|
|
e69c1cf3a9 | ||
|
|
b5229f5fe1 | ||
|
|
e2ef7cfd70 | ||
|
|
6e89e69fb6 | ||
|
|
1e6ee67932 | ||
|
|
0fead83279 | ||
|
|
66952c9212 | ||
|
|
38bb9f457d | ||
|
|
3e615bb152 | ||
|
|
f83a19260f | ||
|
|
69c3c41196 | ||
|
|
6c1a4f3971 | ||
|
|
035ba899ee | ||
|
|
33cc261b62 | ||
|
|
fee6f19d5e | ||
|
|
68e8a13f73 | ||
|
|
a639c3f36d | ||
|
|
a5f10fef60 | ||
|
|
d334c04c47 | ||
|
|
7c3d9aad1d | ||
|
|
ceb81aa2bf |
48
.github/workflows/create-release.yml
vendored
48
.github/workflows/create-release.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ github.ref }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: true
|
||||
|
||||
# Google Play APK
|
||||
- name: Upload Play APK Universal Asset
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
@@ -112,6 +112,28 @@ jobs:
|
||||
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Play APK arm64-v8a Asset
|
||||
id: upload-release-asset-play-arm64-v8a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Play APK armeabi-v7a Asset
|
||||
id: upload-release-asset-play-armeabi-v7a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
# F-Droid APK
|
||||
- name: Upload F-Droid APK Universal Asset
|
||||
id: upload-release-asset-fdroid-universal-apk
|
||||
@@ -146,6 +168,28 @@ jobs:
|
||||
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload F-Droid APK arm64-v8a Asset
|
||||
id: upload-release-asset-fdroid-arm64-v8a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload F-Droid APK armeabi-v7a Asset
|
||||
id: upload-release-asset-fdroid-armeabi-v7a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
|
||||
|
||||
# Google Play AAB
|
||||
|
||||
30
.github/workflows/crowdin.yml
vendored
Normal file
30
.github/workflows/crowdin.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Crowdin Action
|
||||
|
||||
on:
|
||||
push:
|
||||
# paths: ["app/src/main/res/**/strings.xml"] // removes filter to allow downloads at any moment.
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v1
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: 'New Crowdin Translations'
|
||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||
pull_request_base_branch_name: 'main'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
1
.idea/.name
generated
1
.idea/.name
generated
@@ -1 +0,0 @@
|
||||
Amethyst
|
||||
8
.idea/inspectionProfiles/Project_Default.xml
generated
8
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -3,38 +3,30 @@
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.8.21" />
|
||||
<option name="version" value="1.9.10" />
|
||||
</component>
|
||||
</project>
|
||||
25
README.md
25
README.md
@@ -41,7 +41,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Created_at Limits (NIP-22)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
- [x] Nostr Address (NIP-05)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
@@ -63,7 +63,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Zap Forwarding (NIP-57)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
|
||||
- [x] Audio Tracks (zapstr.live) (Kind:31337)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
- [x] Generic Tags (NIP-12)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
@@ -75,14 +75,21 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Relay Pages (NIP-11)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [x] Moderated Communities (NIP-172)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [x] Emoji Packs (Kind:30030)
|
||||
- [x] Personal Emoji Lists (Kind:10030)
|
||||
- [x] Classifieds (Kind:30403)
|
||||
- [x] Private Messages and Small Groups (NIP-24)
|
||||
- [x] Gift Wraps & Seals (NIP-59)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44)
|
||||
- [x] Expiration Support (NIP-40)
|
||||
- [x] Status Event (NIP-38)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Workspaces
|
||||
- [ ] Expiration Support (NIP-40)
|
||||
- [ ] Infinity Scroll
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [ ] Signing Requests (NIP-46)
|
||||
@@ -99,7 +106,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
|
||||
This is a native Android app made with Kotlin and Jetpack Compose.
|
||||
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
|
||||
The overall architecture consists in the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
|
||||
The overall architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
|
||||
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
|
||||
|
||||
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to
|
||||
@@ -165,7 +172,7 @@ For the Play build:
|
||||
keytool -genkey -v -keystore <my-release-key.keystore> -alias <alias_name> -keyalg RSA -keysize 2048 -validity 10000
|
||||
openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.jks.base64.txt
|
||||
```
|
||||
2. Create 4 Secret Key variables on your GitHub repository and fill in with the signing key information
|
||||
2. Create four Secret Key variables on your GitHub repository and fill in the signing key information
|
||||
- `KEY_ALIAS` <- `<alias_name>`
|
||||
- `KEY_PASSWORD` <- `<your password>`
|
||||
- `KEY_STORE_PASSWORD` <- `<your key store password>`
|
||||
@@ -185,15 +192,15 @@ The relay also learns which public keys you are requesting, meaning your public
|
||||
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs.
|
||||
|
||||
# DM Privacy #
|
||||
While the content of direct messages (DMs) is only visible to you, and your DM nostr counterparty, everyone can see that and when you and your counterparty are DM-ing each other.
|
||||
While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other.
|
||||
|
||||
# Visibility & Permanence of Your Content on nostr
|
||||
## Information Visibility ##
|
||||
Content that you share can be shared to other relays.
|
||||
Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
|
||||
Information that you share publicly is visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
|
||||
|
||||
## Information Permanence ##
|
||||
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
|
||||
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
|
||||
|
||||
# Screenshots
|
||||
|
||||
|
||||
107
app/build.gradle
107
app/build.gradle
@@ -1,33 +1,33 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
|
||||
id 'org.jlleitschuh.gradle.ktlint' version "11.6.1"
|
||||
id 'com.google.gms.google-services'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.vitorpamplona.amethyst'
|
||||
compileSdk 33
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 245
|
||||
versionName "0.66.7"
|
||||
targetSdk 34
|
||||
versionCode 330
|
||||
versionName "0.80.7"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW" )
|
||||
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'in', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Make sure all of JSON parsers work when activating these.
|
||||
//minifyEnabled true
|
||||
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
//signingConfig signingConfigs.debug
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
|
||||
minifyEnabled true
|
||||
resValue "string", "app_name", "@string/app_name_release"
|
||||
}
|
||||
debug {
|
||||
@@ -37,7 +37,7 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "channel"
|
||||
flavorDimensions = ["channel"]
|
||||
|
||||
productFlavors {
|
||||
play {
|
||||
@@ -54,7 +54,7 @@ android {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include "x86", "x86_64"
|
||||
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ android {
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion "1.4.7"
|
||||
kotlinCompilerExtensionVersion "1.5.3"
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
@@ -81,14 +81,20 @@ android {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation project(path: ':quartz')
|
||||
implementation "androidx.core:core-ktx:$core_ktx_version"
|
||||
implementation 'androidx.activity:activity-compose:1.8.0'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
@@ -96,52 +102,45 @@ dependencies {
|
||||
implementation "androidx.fragment:fragment-ktx:$fragment_version"
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:$nav_version")
|
||||
implementation "androidx.navigation:navigation-compose:$nav_version"
|
||||
|
||||
// Observe Live data as State
|
||||
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
|
||||
|
||||
implementation "androidx.compose.material:material:$compose_ui_version"
|
||||
// Material 3 Design
|
||||
implementation "androidx.compose.material3:material3:${material3_version}"
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
|
||||
|
||||
// Adaptive Layout / Two Pane
|
||||
implementation "androidx.compose.material3:material3-window-size-class:${material3_version}"
|
||||
implementation "com.google.accompanist:accompanist-adaptive:0.32.0"
|
||||
|
||||
|
||||
// Lifecycle
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
implementation 'net.engawapg.lib:zoomable:1.4.2'
|
||||
// Zoomable images
|
||||
implementation 'net.engawapg.lib:zoomable:1.5.1'
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
exclude group:'fr.acinq.secp256k1'
|
||||
exclude module: 'guava'
|
||||
exclude module: 'guava-testlib'
|
||||
}
|
||||
|
||||
// Websockets API
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// link preview
|
||||
implementation 'org.jsoup:jsoup:1.16.1'
|
||||
// HTML Parsing for Link Preview
|
||||
implementation 'org.jsoup:jsoup:1.16.2'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
|
||||
// view videos
|
||||
implementation "com.google.android.exoplayer:exoplayer:${exoplayer_version}"
|
||||
implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
|
||||
// important for proxy / tor
|
||||
implementation "com.google.android.exoplayer:extension-okhttp:${exoplayer_version}"
|
||||
implementation "androidx.media3:media3-datasource-okhttp:${exoplayer_version}"
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
@@ -156,11 +155,8 @@ dependencies {
|
||||
// Permission to upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
|
||||
// Parses URLs from Text:
|
||||
implementation "io.github.url-detector:url-detector:0.1.23"
|
||||
|
||||
// For QR generation
|
||||
implementation 'com.google.zxing:core:3.5.1'
|
||||
implementation 'com.google.zxing:core:3.5.2'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
|
||||
// Markdown
|
||||
@@ -170,9 +166,15 @@ dependencies {
|
||||
|
||||
// Markdown (With fix for full-image bleeds)
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:a0954aba63')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:a0954aba63')
|
||||
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
|
||||
implementation "androidx.media3:media3-ui:$media3_version"
|
||||
implementation "androidx.media3:media3-session:$media3_version"
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation 'com.google.mlkit:language-id:17.0.4'
|
||||
|
||||
@@ -180,28 +182,31 @@ dependencies {
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.1.1')
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.4.1')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
//PushNotifications(FDroid)
|
||||
fdroidImplementation 'com.github.UnifiedPush:android-connector:2.2.0'
|
||||
|
||||
// Charts
|
||||
implementation "com.patrykandpatrick.vico:core:${vico_version}"
|
||||
implementation "com.patrykandpatrick.vico:compose:${vico_version}"
|
||||
implementation "com.patrykandpatrick.vico:views:${vico_version}"
|
||||
implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}"
|
||||
|
||||
// immutable collections to avoid recomposition
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
|
||||
// GeoHash
|
||||
implementation 'com.github.drfonfon:android-kotlin-geohash:1.0'
|
||||
|
||||
// Waveform visualizer
|
||||
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
|
||||
|
||||
// Video compression lib
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.2.3'
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
|
||||
// Image compression lib
|
||||
implementation 'id.zelory:compressor:3.0.1'
|
||||
|
||||
// Automatic memory leak detection
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.13.5'
|
||||
testImplementation 'io.mockk:mockk:1.13.8'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||
@@ -209,10 +214,6 @@ dependencies {
|
||||
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
|
||||
}
|
||||
|
||||
ktlint {
|
||||
disabledRules.set(["no-wildcard-imports"])
|
||||
}
|
||||
|
||||
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
|
||||
// NB: Android Studio can't find the imports; this does not affect the
|
||||
// actual build since Gradle can find them just fine.
|
||||
|
||||
73
app/proguard-rules.pro
vendored
73
app/proguard-rules.pro
vendored
@@ -5,33 +5,56 @@
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
# For the Secp256k1 library
|
||||
# preserve the line number information for debugging stack traces.
|
||||
-dontobfuscate
|
||||
-keepattributes LocalVariableTable
|
||||
-keepattributes LocalVariableTypeTable
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile
|
||||
-keepattributes LineNumberTable
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
-keepattributes MethodParameters
|
||||
-keepparameternames
|
||||
|
||||
-keep class fr.acinq.secp256k1.jni.** { *; }
|
||||
# For the NostrPostr library
|
||||
-keep class nostr.postr.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
|
||||
# Json parsing
|
||||
-keep class com.google.gson.reflect.** { *; }
|
||||
-keep class * extends com.google.gson.reflect.TypeToken
|
||||
-keep public class * implements java.lang.reflect.Type
|
||||
-keepdirectories libs
|
||||
|
||||
-keep class com.vitorpamplona.amethyst.lnurl.** { *; }
|
||||
# Keep all names
|
||||
-keepnames class ** { *; }
|
||||
|
||||
# Keep All enums
|
||||
-keep enum ** { *; }
|
||||
|
||||
# preserve access to native classses
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
|
||||
# JNA For Libsodium
|
||||
-keep class com.goterl.lazysodium.** { *; }
|
||||
|
||||
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
|
||||
-keep class com.sun.jna.ToNativeConverter { *; }
|
||||
-keep class com.sun.jna.NativeMapped { *; }
|
||||
-keep class com.sun.jna.CallbackReference { *; }
|
||||
-keep class com.sun.jna.ptr.IntByReference { *; }
|
||||
-keep class com.sun.jna.NativeLong { *; }
|
||||
-keep class com.sun.jna.Structure { *; }
|
||||
-keep class com.sun.jna.Structure$* { *; }
|
||||
-keep class com.sun.jna.Native$ffi_callback { *; }
|
||||
-keep class * implements com.sun.jna.Structure$* { *; }
|
||||
-keep class * implements com.sun.jna.Native$* { *; }
|
||||
-keep class com.sun.jna.Native {
|
||||
private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object);
|
||||
private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object);
|
||||
private static java.lang.Class nativeType(java.lang.Class);
|
||||
private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object);
|
||||
private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method);
|
||||
}
|
||||
|
||||
# JSON parsing
|
||||
-keep class com.vitorpamplona.quartz.crypto.** { *; }
|
||||
-keep class com.vitorpamplona.quartz.encoders.** { *; }
|
||||
-keep class com.vitorpamplona.quartz.events.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.model.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.ui.** { *; }
|
||||
|
||||
@@ -1,113 +1,78 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.FileServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.delay
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ImageUploadTesting {
|
||||
|
||||
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
|
||||
|
||||
@Test()
|
||||
fun testImgurUpload() = runBlocking {
|
||||
private fun testBase(server: FileServer) {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var url: String? = null
|
||||
var error: String? = null
|
||||
|
||||
ImageUploader.account = Account(
|
||||
KeyPair()
|
||||
)
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
ImgurServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
server,
|
||||
onSuccess = { newUrl, contentType ->
|
||||
println("Uploaded $contentType to $url")
|
||||
url = newUrl
|
||||
countDownLatch.countDown()
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
error = it.message
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
)
|
||||
|
||||
delay(5000)
|
||||
countDownLatch.await()
|
||||
|
||||
Assert.assertNull(error)
|
||||
Assert.assertTrue(url?.startsWith("http") == true)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testImgurUpload() = runBlocking {
|
||||
testBase(ImgurServer())
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrBuildUpload() = runBlocking {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrBuildServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
testBase(NostrBuildServer())
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrImgUpload() = runBlocking {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrImgServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
testBase(NostrImgServer())
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrFilesDevUpload() = runBlocking {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrFilesDevServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(5000)
|
||||
testBase(NostrFilesDevServer())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.RichTextParser
|
||||
import com.vitorpamplona.amethyst.service.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -666,9 +666,9 @@ class RichTextParserTest {
|
||||
|
||||
@Test
|
||||
fun testTextToParse() {
|
||||
val state = RichTextParser().parseText(textToParse, ImmutableListOfLists())
|
||||
val state = RichTextParser().parseText(textToParse, EmptyTagList)
|
||||
Assert.assertEquals(
|
||||
"relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, ⚡41.7M, Nostr-Check.com, MR.Rabbit, Ancap.su, ⚡️satscoinsv@getalby.com, miceliomad@miceliomad.github.io/nostr/, zapper.lol, smies.me, baller.hodl",
|
||||
"relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl",
|
||||
state.urlSet.joinToString(", ")
|
||||
)
|
||||
|
||||
@@ -806,134 +806,101 @@ class RichTextParserTest {
|
||||
"HashTag(#bitcoin,)",
|
||||
"RegularText(1.7K,)",
|
||||
"RegularText(109)",
|
||||
"RegularText()",
|
||||
"HashTag(#concussion,)",
|
||||
"RegularText(1.1K,)",
|
||||
"RegularText(25)",
|
||||
"RegularText()",
|
||||
"HashTag(#press,)",
|
||||
"RegularText(0.9K,)",
|
||||
"RegularText(65)",
|
||||
"RegularText()",
|
||||
"HashTag(#france,)",
|
||||
"RegularText(492,)",
|
||||
"RegularText(46)",
|
||||
"RegularText()",
|
||||
"HashTag(#presse,)",
|
||||
"RegularText(480,)",
|
||||
"RegularText(42)",
|
||||
"RegularText()",
|
||||
"HashTag(#covid19,)",
|
||||
"RegularText(465,)",
|
||||
"RegularText(65)",
|
||||
"RegularText()",
|
||||
"HashTag(#nostr,)",
|
||||
"RegularText(414,)",
|
||||
"RegularText(109)",
|
||||
"RegularText()",
|
||||
"HashTag(#zapathon,)",
|
||||
"RegularText(386,)",
|
||||
"RegularText(76)",
|
||||
"RegularText()",
|
||||
"HashTag(#rssfeed,)",
|
||||
"RegularText(309,)",
|
||||
"RegularText(53)",
|
||||
"RegularText()",
|
||||
"HashTag(#btc,)",
|
||||
"RegularText(299,)",
|
||||
"RegularText(109)",
|
||||
"RegularText()",
|
||||
"HashTag(#news,)",
|
||||
"RegularText(294,)",
|
||||
"RegularText(91)",
|
||||
"RegularText()",
|
||||
"HashTag(#zap,)",
|
||||
"RegularText(283,)",
|
||||
"RegularText(109)",
|
||||
"RegularText()",
|
||||
"HashTag(#linux,)",
|
||||
"RegularText(253,)",
|
||||
"RegularText(88)",
|
||||
"RegularText()",
|
||||
"HashTag(#respond,)",
|
||||
"RegularText(246,)",
|
||||
"RegularText(90)",
|
||||
"RegularText()",
|
||||
"HashTag(#kompost,)",
|
||||
"RegularText(240,)",
|
||||
"RegularText(31)",
|
||||
"RegularText()",
|
||||
"HashTag(#plebchain,)",
|
||||
"RegularText(236,)",
|
||||
"RegularText(109)",
|
||||
"RegularText()",
|
||||
"HashTag(#gardenaward,)",
|
||||
"RegularText(236,)",
|
||||
"RegularText(31)",
|
||||
"RegularText()",
|
||||
"HashTag(#start,)",
|
||||
"RegularText(236,)",
|
||||
"RegularText(31)",
|
||||
"RegularText()",
|
||||
"HashTag(#unicef,)",
|
||||
"RegularText(233,)",
|
||||
"RegularText(32)",
|
||||
"RegularText()",
|
||||
"HashTag(#coronavirus,)",
|
||||
"RegularText(233,)",
|
||||
"RegularText(33)",
|
||||
"RegularText()",
|
||||
"HashTag(#bew,)",
|
||||
"RegularText(229,)",
|
||||
"RegularText(31)",
|
||||
"RegularText()",
|
||||
"HashTag(#balkon,)",
|
||||
"RegularText(229,)",
|
||||
"RegularText(31)",
|
||||
"RegularText()",
|
||||
"HashTag(#terrasse,)",
|
||||
"RegularText(229,)",
|
||||
"RegularText(31)",
|
||||
"RegularText()",
|
||||
"HashTag(#braininjuryawareness,)",
|
||||
"RegularText(229,)",
|
||||
"RegularText(24)",
|
||||
"RegularText()",
|
||||
"HashTag(#garten,)",
|
||||
"RegularText(220,)",
|
||||
"RegularText(21)",
|
||||
"RegularText()",
|
||||
"HashTag(#smart,)",
|
||||
"RegularText(220,)",
|
||||
"RegularText(21)",
|
||||
"RegularText()",
|
||||
"HashTag(#nsfw,)",
|
||||
"RegularText(211,)",
|
||||
"RegularText(85)",
|
||||
"RegularText()",
|
||||
"HashTag(#protoncalendar,)",
|
||||
"RegularText(206,)",
|
||||
"RegularText(31)",
|
||||
"RegularText()",
|
||||
"HashTag(#stacksats,)",
|
||||
"RegularText(195,)",
|
||||
"RegularText(99)",
|
||||
"RegularText()",
|
||||
"HashTag(#nokyc,)",
|
||||
"RegularText(179,)",
|
||||
"RegularText(98)",
|
||||
"RegularText()",
|
||||
"RegularText()",
|
||||
"RegularText(Emoji sentiment today)",
|
||||
"RegularText()",
|
||||
"RegularText(⚡ (1.6K) 👉 (1.4K) 🇪🇺 (1.2K) 🫂 (1.2K) 🇺🇸 (1.1K) 💜 (875) 🧠 (858) 😂 (830) 🔥 (690) 🤣 (566) 🤙 (525) ☕ (444) 👇 (443) 🙌🏻 (425) ☀ (307) 😎 (305) 🥳 (301) 🤔 (276) 🌻 (270) 🧡 (270) 🥇 (269) 🗓 (269) 🙏 (268) 🏆 (267) 🌱 (264) 📰 (230) 🏉 (221) 😭 (220) 💰 (219) 🔗 (209) 👀 (201) 😅 (199) ✨ (193) 🇷🇺 (182) 💪 (167) ✅ (164) 💤 (163) 🐶 (151) 🇨🇭 (141) 📝 (137) 😁 (136) 🌞 (136) 🐾 (136) ❤ (132) 💻 (126) 🚀 (125) 👍 (125) 🇧🇷 (125) 😊 (121) 📚 (120) ➡ (120) 👏 (118) 🎉 (117) 🎮 (115) 🤷 (113) 👋 (112) 💃 (108) 🕺🏻 (106) 💡 (104) 🚨 (99) 😆 (97) 💯 (95) ⚠ (92) 📢 (92) 🤗 (89) 😴 (87) 🔁 (83) 🐰 (81) 😀 (79) 🎟 (78) ⛏ (78) 🐦 (76) 💸 (76) ✌🏻 (75) 🤍 (73) 🇬🇧 (73) 🌽 (70) 🤡 (69) 🤮 (69) ❗ (66) 🤝 (65) 😉 (65) 🙇 (65) 🍻 (64) 🌍 (64) 💕 (63) 🌸 (62) 💬 (61) ☺ (61) 🇦🇷 (59) 🇮🇩 (57) 😳 (57) 😄 (57) 🎶 (57) 🥷🏻 (56) 🎵 (56) 😃 (56) 🔍 (55) 💥 (55) 🎲 (54) ✍ (54) 🕒 (53) ⬇ (53) 💙 (51) 🔒 (50) 📈 (50) 🪙 (50) 🌧 (50) 🥰 (50) 🕸 (50) 🌐 (50) 💭 (49) 🌙 (49) 😍 (49) 📱 (48) 🌟 (48) 🤩 (48) 💔 (47) 🔌 (47) 😋 (47) 🎖 (47) 🍣 (46) 📷 (46) 💼 (45) ⭐ (45) 🥔 (45) 🥺 (45) 👌 (44) 👷🏼 (43) 😱 (43) 📅 (43) 🤖 (43) 📸 (42) 📊 (42) 🦑 (40) 💵 (40) 🤦 (39) ❣ (38) 💎 (38) 🖤 (38) 📺 (37) 🇵🇱 (37) 🇯🇵 (36) 🔧 (36) 🤘 (36) 💖 (36) ‼ (35) 😢 (35) 😺 (34) 🔊 (34) 😏 (34) 🇸🇰 (34) 🏃 (34) 👩👧 (34) ⏰ (33) 👨💻 (33) 👑 (33) 👥 (32) 🖥 (32) 💨 (32) 💗 (31) 🇲🇽 (31) 📖 (31) 🚫 (31) 👊🏻 (31) 😡 (31) 🌎 (31) 👁 (30) 🗞 (30) 🍀 (30) 🍽 (29) 🐸 (29) 🥚 (29) 💩 (29) ✊🏾 (29) 😮 (29) 🌡 (29) 🙃 (28) 🔔 (28) 🇻🇪 (28) 💦 (28) 🎯 (28) 🎨 (28) 🍛 (28) 🖼 (27) ☝🏻 (27) 🛑 (27) 🙄 (27) 🧑🏻🤝🧑🏽 (27) 🌈 (27) 🥂 (26) 🇫🇮 (26) 🎥 (26) 😬 (26) 🥲 (25) 🦾 (24) 🤜 (24) 🙂 (24) 🖕 (24) 😩 (24) )",
|
||||
"RegularText()",
|
||||
"RegularText(Zap economy)",
|
||||
"RegularText()",
|
||||
"Link(⚡41.7M)",
|
||||
"RegularText(sats)",
|
||||
"RegularText((₿0.417))",
|
||||
"RegularText()",
|
||||
"RegularText(⚡41.7M sats (₿0.417) )",
|
||||
"RegularText(1,816 zappers & 920 zapped (unique pubkeys))",
|
||||
"RegularText(🌩️ 33,248 zaps, 1,253 sats per zap (avg))",
|
||||
"RegularText()",
|
||||
@@ -2248,7 +2215,7 @@ class RichTextParserTest {
|
||||
"HashTag(#214)",
|
||||
"RegularText(2%)",
|
||||
"RegularText(Satscoinsv,)",
|
||||
"Link(⚡️satscoinsv@getalby.com)",
|
||||
"RegularText(⚡️satscoinsv@getalby.com)",
|
||||
"RegularText(-)",
|
||||
"RegularText(80db64657ea0358c5332c5cca01565eeddd4b8799688b1c46d3cb2d7c966671f)",
|
||||
"HashTag(#215)",
|
||||
@@ -2458,7 +2425,7 @@ class RichTextParserTest {
|
||||
"HashTag(#249)",
|
||||
"RegularText(2%)",
|
||||
"RegularText(micmad,)",
|
||||
"Link(miceliomad@miceliomad.github.io/nostr/)",
|
||||
"RegularText(miceliomad@miceliomad.github.io/nostr/)",
|
||||
"RegularText(-)",
|
||||
"RegularText(cd806edcf8ff40ea94fa574ea9cd97da16e5beb2b85aac6e1d648b8388504343)",
|
||||
"HashTag(#250)",
|
||||
@@ -3805,7 +3772,7 @@ class RichTextParserTest {
|
||||
"HashTag(#471)",
|
||||
"RegularText(1%)",
|
||||
"RegularText(leonwankum,)",
|
||||
"SchemelessUrl(@leonawankum@BitcoinNostr.com)",
|
||||
"RegularText(@leonawankum@BitcoinNostr.com)",
|
||||
"RegularText()",
|
||||
"RegularText(-)",
|
||||
"RegularText(652d58acafa105af8475c0fe8029a52e7ddbc337b2bd9c98bb17a111dc4cde60)",
|
||||
@@ -4007,7 +3974,7 @@ class RichTextParserTest {
|
||||
"Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)"
|
||||
)
|
||||
|
||||
state.paragraphs.flatten().forEachIndexed { index, seg ->
|
||||
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
|
||||
Assert.assertEquals(
|
||||
expectedResult[index],
|
||||
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
|
||||
@@ -4022,24 +3989,24 @@ class RichTextParserTest {
|
||||
|
||||
@Test
|
||||
fun testShortTextToParse() {
|
||||
val state = RichTextParser().parseText("Hi, how are you doing? ", ImmutableListOfLists())
|
||||
val state = RichTextParser().parseText("Hi, how are you doing? ", EmptyTagList)
|
||||
Assert.assertTrue(state.urlSet.isEmpty())
|
||||
Assert.assertTrue(state.imagesForPager.isEmpty())
|
||||
Assert.assertTrue(state.imageList.isEmpty())
|
||||
Assert.assertTrue(state.customEmoji.isEmpty())
|
||||
Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.firstOrNull()?.segmentText)
|
||||
Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.words?.firstOrNull()?.segmentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShortNewLinesTextToParse() {
|
||||
val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", ImmutableListOfLists())
|
||||
val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList)
|
||||
Assert.assertTrue(state.urlSet.isEmpty())
|
||||
Assert.assertTrue(state.imagesForPager.isEmpty())
|
||||
Assert.assertTrue(state.imageList.isEmpty())
|
||||
Assert.assertTrue(state.customEmoji.isEmpty())
|
||||
Assert.assertEquals(
|
||||
"\nHi, \nhow\n\n\n are you doing? \n",
|
||||
state.paragraphs.joinToString("\n") { it.joinToString(" ") { it.segmentText } }
|
||||
state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4052,7 +4019,7 @@ See how it can be done here: https://lnshort.it/live-stream-embeds/
|
||||
https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg
|
||||
""".trimIndent()
|
||||
|
||||
val state = RichTextParser().parseText(text, ImmutableListOfLists())
|
||||
val state = RichTextParser().parseText(text, EmptyTagList)
|
||||
Assert.assertEquals("https://lnshort.it/live-stream-embeds/", state.urlSet.firstOrNull())
|
||||
Assert.assertEquals("https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imagesForPager.keys.firstOrNull())
|
||||
Assert.assertEquals("https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imageList.firstOrNull()?.url)
|
||||
@@ -4098,11 +4065,61 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
|
||||
"RegularText(here:)",
|
||||
"Link(https://lnshort.it/live-stream-embeds/)",
|
||||
"RegularText()",
|
||||
"RegularText()",
|
||||
"Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)"
|
||||
)
|
||||
|
||||
state.paragraphs.flatten().forEachIndexed { index, seg ->
|
||||
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
|
||||
Assert.assertEquals(
|
||||
expectedResult[index],
|
||||
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNewLineAfterImage() {
|
||||
val text = "That’s it ! That’s the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg "
|
||||
|
||||
val state = RichTextParser().parseText(text, EmptyTagList)
|
||||
|
||||
printStateForDebug(state)
|
||||
|
||||
val expectedResult = listOf<String>(
|
||||
"RegularText(That’s)",
|
||||
"RegularText(it)",
|
||||
"RegularText(!)",
|
||||
"RegularText(That’s)",
|
||||
"RegularText(the)",
|
||||
"HashTag(#note)",
|
||||
"Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)"
|
||||
)
|
||||
|
||||
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
|
||||
Assert.assertEquals(
|
||||
expectedResult[index],
|
||||
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSapceAfterImage() {
|
||||
val text = "That’s it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg That’s the #note"
|
||||
|
||||
val state = RichTextParser().parseText(text, EmptyTagList)
|
||||
|
||||
printStateForDebug(state)
|
||||
|
||||
val expectedResult = listOf<String>(
|
||||
"RegularText(That’s)",
|
||||
"RegularText(it!)",
|
||||
"Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)",
|
||||
"RegularText(That’s)",
|
||||
"RegularText(the)",
|
||||
"HashTag(#note)"
|
||||
)
|
||||
|
||||
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
|
||||
Assert.assertEquals(
|
||||
expectedResult[index],
|
||||
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
|
||||
@@ -4111,8 +4128,8 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
|
||||
}
|
||||
|
||||
private fun printStateForDebug(state: RichTextViewerState) {
|
||||
state.paragraphs.forEachIndexed { index, paragraph ->
|
||||
paragraph.forEach { seg ->
|
||||
state.paragraphs.forEach { paragraph ->
|
||||
paragraph.words.forEach { seg ->
|
||||
println(
|
||||
"\"${
|
||||
seg.javaClass.simpleName.replace(
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,10 +5,10 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -29,7 +29,10 @@ class UrlUserTagTransformationTest {
|
||||
|
||||
@Test
|
||||
fun transformationText() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
|
||||
val user = LocalCache.getOrCreateUser(
|
||||
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
|
||||
.toHexKey()
|
||||
)
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
@@ -63,7 +66,10 @@ class UrlUserTagTransformationTest {
|
||||
|
||||
@Test
|
||||
fun transformationTextTwoKeys() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
|
||||
val user = LocalCache.getOrCreateUser(
|
||||
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
|
||||
.toHexKey()
|
||||
)
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
|
||||
@@ -99,4 +99,13 @@ class TranslationsTest {
|
||||
"en"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEmoji() {
|
||||
assertTranslateContains(
|
||||
"https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg",
|
||||
"\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ",
|
||||
"pt"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
23
app/src/fdroid/AndroidManifest.xml
Normal file
23
app/src/fdroid/AndroidManifest.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:name=".Amethyst">
|
||||
|
||||
<receiver
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".service.notifications.PushMessageReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
|
||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.service.lang
|
||||
|
||||
object LanguageTranslatorService {
|
||||
fun clear() {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
interface PushDistributorActions {
|
||||
fun getSavedDistributor(): String
|
||||
fun getInstalledDistributors(): List<String>
|
||||
fun saveDistributor(distributor: String)
|
||||
fun removeSavedDistributor()
|
||||
}
|
||||
object PushDistributorHandler : PushDistributorActions {
|
||||
private val appContext = Amethyst.instance.applicationContext
|
||||
private val unifiedPush: UnifiedPush = UnifiedPush
|
||||
|
||||
private var endpointInternal = ""
|
||||
val endpoint = endpointInternal
|
||||
|
||||
fun getSavedEndpoint() = endpoint
|
||||
fun setEndpoint(newEndpoint: String) {
|
||||
endpointInternal = newEndpoint
|
||||
Log.d("PushHandler", "New endpoint saved : $endpointInternal")
|
||||
}
|
||||
|
||||
fun removeEndpoint() {
|
||||
endpointInternal = ""
|
||||
}
|
||||
|
||||
override fun getSavedDistributor(): String {
|
||||
return unifiedPush.getDistributor(appContext)
|
||||
}
|
||||
|
||||
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
|
||||
|
||||
override fun getInstalledDistributors(): List<String> {
|
||||
return unifiedPush.getDistributors(appContext)
|
||||
}
|
||||
|
||||
fun formattedDistributorNames(): List<String> {
|
||||
val distributorsArray = getInstalledDistributors().toTypedArray()
|
||||
val distributorsNameArray = distributorsArray.map {
|
||||
try {
|
||||
val ai = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appContext.packageManager.getApplicationInfo(
|
||||
it,
|
||||
PackageManager.ApplicationInfoFlags.of(
|
||||
PackageManager.GET_META_DATA.toLong()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
appContext.packageManager.getApplicationInfo(it, 0)
|
||||
}
|
||||
appContext.packageManager.getApplicationLabel(ai)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
it
|
||||
} as String
|
||||
}.toTypedArray()
|
||||
return distributorsNameArray.toList()
|
||||
}
|
||||
|
||||
override fun saveDistributor(distributor: String) {
|
||||
unifiedPush.saveDistributor(appContext, distributor)
|
||||
unifiedPush.registerApp(appContext)
|
||||
}
|
||||
|
||||
override fun removeSavedDistributor() {
|
||||
unifiedPush.safeRemoveDistributor(appContext)
|
||||
}
|
||||
fun forceRemoveDistributor(context: Context) {
|
||||
unifiedPush.forceRemoveDistributor(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
|
||||
class PushMessageReceiver : MessagingReceiver() {
|
||||
private val TAG = "Amethyst-OSSPushReceiver"
|
||||
private val appContext = Amethyst.instance.applicationContext
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val eventCache = LruCache<String, String>(100)
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
override fun onMessage(context: Context, message: ByteArray, instance: String) {
|
||||
val messageStr = String(message)
|
||||
Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance")
|
||||
scope.launch {
|
||||
try {
|
||||
parseMessage(messageStr)?.let {
|
||||
receiveIfNew(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Message could not be parsed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun parseMessage(message: String): GiftWrapEvent? {
|
||||
(Event.fromJson(message) as? GiftWrapEvent)?.let {
|
||||
return it
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun receiveIfNew(event: GiftWrapEvent) {
|
||||
if (eventCache.get(event.id) == null) {
|
||||
eventCache.put(event.id, event.id)
|
||||
EventNotificationConsumer(appContext).consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance")
|
||||
val sanitizedEndpoint = endpoint.dropLast(5)
|
||||
pushHandler.setEndpoint(sanitizedEndpoint)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
|
||||
notificationManager().getOrCreateZapChannel(appContext)
|
||||
notificationManager().getOrCreateDMChannel(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val intentData = intent.dataString
|
||||
val intentAction = intent.action.toString()
|
||||
Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction")
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(context: Context, instance: String) {
|
||||
Log.d(TAG, "Registration failed for Instance: $instance")
|
||||
scope.cancel()
|
||||
pushHandler.forceRemoveDistributor(context)
|
||||
}
|
||||
|
||||
override fun onUnregistered(context: Context, instance: String) {
|
||||
val removedEndpoint = pushHandler.endpoint
|
||||
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
|
||||
Log.d(TAG, "App is unregistered. ")
|
||||
pushHandler.forceRemoveDistributor(context)
|
||||
pushHandler.removeEndpoint()
|
||||
}
|
||||
|
||||
fun notificationManager(): NotificationManager {
|
||||
return ContextCompat.getSystemService(appContext, NotificationManager::class.java) as NotificationManager
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
|
||||
class PushNotificationUtils {
|
||||
fun init(accounts: List<AccountInfo>) {
|
||||
object PushNotificationUtils {
|
||||
var hasInit: Boolean = false
|
||||
private val pushHandler = PushDistributorHandler
|
||||
suspend fun init(accounts: List<AccountInfo>) {
|
||||
if (hasInit || pushHandler.savedDistributorExists()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (pushHandler.savedDistributorExists()) {
|
||||
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material3.Material3RichText
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckifItNeedsToRequestNotificationPermission
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
|
||||
val notificationPermissionState = CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
|
||||
|
||||
if (notificationPermissionState.status.isGranted) {
|
||||
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
|
||||
val context = LocalContext.current
|
||||
var distributorPresent by remember {
|
||||
mutableStateOf(PushDistributorHandler.savedDistributorExists())
|
||||
}
|
||||
if (!distributorPresent) {
|
||||
LoadDistributors() { currentDistributor, list, readableListWithExplainer ->
|
||||
if (readableListWithExplainer.size > 1) {
|
||||
SpinnerSelectionDialog(
|
||||
title = stringResource(id = R.string.select_push_server),
|
||||
options = readableListWithExplainer,
|
||||
onSelect = { index ->
|
||||
if (list[index] == "None") {
|
||||
PushDistributorHandler.forceRemoveDistributor(context)
|
||||
sharedPreferencesViewModel.dontAskForNotificationPermissions()
|
||||
sharedPreferencesViewModel.dontShowPushNotificationSelector()
|
||||
} else {
|
||||
val fullDistributorName = list[index]
|
||||
PushDistributorHandler.saveDistributor(fullDistributorName)
|
||||
}
|
||||
distributorPresent = true
|
||||
Log.d("Amethyst", "NotificationScreen: Distributor registered.")
|
||||
},
|
||||
onDismiss = {
|
||||
distributorPresent = true
|
||||
Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
distributorPresent = true
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.push_server_install_app))
|
||||
},
|
||||
text = {
|
||||
Material3RichText(
|
||||
style = RichTextStyle().resolveDefaults()
|
||||
) {
|
||||
Markdown(
|
||||
content = stringResource(R.string.push_server_install_app_description)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
distributorPresent = true
|
||||
sharedPreferencesViewModel.dontShowPushNotificationSelector()
|
||||
}) {
|
||||
Text(stringResource(R.string.quick_action_dont_show_again_button))
|
||||
}
|
||||
Button(onClick = {
|
||||
distributorPresent = true
|
||||
}, contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.error_dialog_button_ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor()
|
||||
PushDistributorHandler.saveDistributor(currentDistributor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadDistributors(
|
||||
onInner: @Composable (String, ImmutableList<String>, ImmutableList<TitleExplainer>) -> Unit
|
||||
) {
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None"
|
||||
|
||||
val list = remember {
|
||||
PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList()
|
||||
}
|
||||
|
||||
val readableListWithExplainer =
|
||||
PushDistributorHandler.formattedDistributorNames()
|
||||
.mapIndexed { index, name ->
|
||||
TitleExplainer(
|
||||
name,
|
||||
stringResource(id = R.string.push_server_uses_app_explainer, list[index])
|
||||
)
|
||||
}
|
||||
.plus(
|
||||
TitleExplainer(
|
||||
stringResource(id = R.string.push_server_none),
|
||||
stringResource(id = R.string.push_server_none_explainer)
|
||||
)
|
||||
)
|
||||
.toImmutableList()
|
||||
|
||||
onInner(
|
||||
currentDistributor,
|
||||
list,
|
||||
readableListWithExplainer
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
LoadDistributors() { currentDistributor, list, readableListWithExplainer ->
|
||||
SettingsRow(
|
||||
R.string.push_server_title,
|
||||
R.string.push_server_explainer,
|
||||
selectedItens = readableListWithExplainer,
|
||||
selectedIndex = list.indexOf(currentDistributor)
|
||||
) { index ->
|
||||
if (list[index] == "None") {
|
||||
sharedPreferencesViewModel.dontAskForNotificationPermissions()
|
||||
sharedPreferencesViewModel.dontShowPushNotificationSelector()
|
||||
PushDistributorHandler.forceRemoveDistributor(context)
|
||||
} else {
|
||||
PushDistributorHandler.saveDistributor(list[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
@Composable
|
||||
fun TranslatableRichTextViewer(
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<queries>
|
||||
<package android:name="org.torproject.android"/>
|
||||
<package android:name="com.greenart7c3.nostrsigner"/>
|
||||
<package android:name="com.greenart7c3.nostrsigner.debug"/>
|
||||
</queries>
|
||||
|
||||
<!-- Doesn't require a camera -->
|
||||
@@ -27,6 +29,19 @@
|
||||
<!-- To know receive notifications when the app connects/disconnects from the web -->
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<!-- Audio/Video Playback -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<!-- Keeps screen on while playing videos -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- This notification permission is needed for some phones -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- Adds Geohash to posts if active -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Old permission to access media -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -47,10 +62,11 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
tools:targetApi="33">
|
||||
tools:targetApi="34">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
@@ -91,6 +107,17 @@
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<service
|
||||
android:name=".service.playback.PlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:stopWithTask="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.material3.pullrefresh
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.platform.inspectable
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
|
||||
/**
|
||||
* A nested scroll modifier that provides scroll events to [state].
|
||||
*
|
||||
* Note that this modifier must be added above a scrolling container, such as a lazy column, in
|
||||
* order to receive scroll events. For example:
|
||||
*
|
||||
* @sample androidx.compose.material.samples.PullRefreshSample
|
||||
*
|
||||
* @param state The [PullRefreshState] associated with this pull-to-refresh component.
|
||||
* The state will be updated by this modifier.
|
||||
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored.
|
||||
*/
|
||||
// TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple.
|
||||
fun Modifier.pullRefresh(
|
||||
state: PullRefreshState,
|
||||
enabled: Boolean = true
|
||||
) = inspectable(
|
||||
inspectorInfo = debugInspectorInfo {
|
||||
name = "pullRefresh"
|
||||
properties["state"] = state
|
||||
properties["enabled"] = enabled
|
||||
}
|
||||
) {
|
||||
Modifier.pullRefresh(state::onPull, state::onRelease, enabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom
|
||||
* pull refresh components.
|
||||
*
|
||||
* Note that this modifier must be added above a scrolling container, such as a lazy column, in
|
||||
* order to receive scroll events. For example:
|
||||
*
|
||||
* @sample androidx.compose.material.samples.CustomPullRefreshSample
|
||||
*
|
||||
* @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument.
|
||||
* Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling
|
||||
* down despite being at the top of a scrollable component), whereas negative delta (swiping up) is
|
||||
* dispatched first (in case it is needed to push the indicator back up), and then the unconsumed
|
||||
* delta is passed on to the child. The callback returns how much delta was consumed.
|
||||
* @param onRelease Callback for when drag is released, takes float flingVelocity as argument.
|
||||
* The callback returns how much velocity was consumed - in most cases this should only consume
|
||||
* velocity if pull refresh has been dragged already and the velocity is positive (the fling is
|
||||
* downwards), as an upwards fling should typically still scroll a scrollable component beneath the
|
||||
* pullRefresh. This is invoked before any remaining velocity is passed to the child.
|
||||
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither
|
||||
* [onPull] nor [onRelease] will be invoked.
|
||||
*/
|
||||
fun Modifier.pullRefresh(
|
||||
onPull: (pullDelta: Float) -> Float,
|
||||
onRelease: suspend (flingVelocity: Float) -> Float,
|
||||
enabled: Boolean = true
|
||||
) = inspectable(
|
||||
inspectorInfo = debugInspectorInfo {
|
||||
name = "pullRefresh"
|
||||
properties["onPull"] = onPull
|
||||
properties["onRelease"] = onRelease
|
||||
properties["enabled"] = enabled
|
||||
}
|
||||
) {
|
||||
Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled))
|
||||
}
|
||||
|
||||
private class PullRefreshNestedScrollConnection(
|
||||
private val onPull: (pullDelta: Float) -> Float,
|
||||
private val onRelease: suspend (flingVelocity: Float) -> Float,
|
||||
private val enabled: Boolean
|
||||
) : NestedScrollConnection {
|
||||
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset = when {
|
||||
!enabled -> Offset.Zero
|
||||
source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset = when {
|
||||
!enabled -> Offset.Zero
|
||||
source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
return Velocity(0f, onRelease(available.y))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.material3.pullrefresh
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.center
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout.
|
||||
*
|
||||
* @sample androidx.compose.material.samples.PullRefreshSample
|
||||
*
|
||||
* @param refreshing A boolean representing whether a refresh is occurring.
|
||||
* @param state The [PullRefreshState] which controls where and how the indicator will be drawn.
|
||||
* @param modifier Modifiers for the indicator.
|
||||
* @param backgroundColor The color of the indicator's background.
|
||||
* @param contentColor The color of the indicator's arc and arrow.
|
||||
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
|
||||
*/
|
||||
// TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to
|
||||
// enable people to use this indicator with custom pull-to-refresh components.
|
||||
@Composable
|
||||
fun PullRefreshIndicator(
|
||||
refreshing: Boolean,
|
||||
state: PullRefreshState,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color = MaterialTheme.colorScheme.surface,
|
||||
contentColor: Color = contentColorFor(backgroundColor),
|
||||
scale: Boolean = false
|
||||
) {
|
||||
val showElevation by remember(refreshing, state) {
|
||||
derivedStateOf { refreshing || state.position > 0.5f }
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.size(IndicatorSize)
|
||||
.pullRefreshIndicatorTransform(state, scale),
|
||||
shape = SpinnerShape,
|
||||
color = backgroundColor,
|
||||
shadowElevation = if (showElevation) Elevation else 0.dp
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = refreshing,
|
||||
animationSpec = tween(durationMillis = CrossfadeDurationMs)
|
||||
) { refreshing ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val spinnerSize = (ArcRadius + StrokeWidth).times(2)
|
||||
|
||||
if (refreshing) {
|
||||
CircularProgressIndicator(
|
||||
color = contentColor,
|
||||
strokeWidth = StrokeWidth,
|
||||
modifier = Modifier.size(spinnerSize)
|
||||
)
|
||||
} else {
|
||||
CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifier.size MUST be specified.
|
||||
*/
|
||||
@Composable
|
||||
private fun CircularArrowIndicator(
|
||||
state: PullRefreshState,
|
||||
color: Color,
|
||||
modifier: Modifier
|
||||
) {
|
||||
val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }
|
||||
|
||||
val targetAlpha by remember(state) {
|
||||
derivedStateOf {
|
||||
if (state.progress >= 1f) MaxAlpha else MinAlpha
|
||||
}
|
||||
}
|
||||
|
||||
val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween)
|
||||
|
||||
// Empty semantics for tests
|
||||
Canvas(modifier.semantics {}) {
|
||||
val values = ArrowValues(state.progress)
|
||||
val alpha = alphaState.value
|
||||
|
||||
rotate(degrees = values.rotation) {
|
||||
val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f
|
||||
val arcBounds = Rect(
|
||||
size.center.x - arcRadius,
|
||||
size.center.y - arcRadius,
|
||||
size.center.x + arcRadius,
|
||||
size.center.y + arcRadius
|
||||
)
|
||||
drawArc(
|
||||
color = color,
|
||||
alpha = alpha,
|
||||
startAngle = values.startAngle,
|
||||
sweepAngle = values.endAngle - values.startAngle,
|
||||
useCenter = false,
|
||||
topLeft = arcBounds.topLeft,
|
||||
size = arcBounds.size,
|
||||
style = Stroke(
|
||||
width = StrokeWidth.toPx(),
|
||||
cap = StrokeCap.Square
|
||||
)
|
||||
)
|
||||
drawArrow(path, arcBounds, color, alpha, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
private class ArrowValues(
|
||||
val rotation: Float,
|
||||
val startAngle: Float,
|
||||
val endAngle: Float,
|
||||
val scale: Float
|
||||
)
|
||||
|
||||
private fun ArrowValues(progress: Float): ArrowValues {
|
||||
// Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%.
|
||||
val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3
|
||||
// How far beyond the threshold pull has gone, as a percentage of the threshold.
|
||||
val overshootPercent = abs(progress) - 1.0f
|
||||
// Limit the overshoot to 200%. Linear between 0 and 200.
|
||||
val linearTension = overshootPercent.coerceIn(0f, 2f)
|
||||
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
|
||||
val tensionPercent = linearTension - linearTension.pow(2) / 4
|
||||
|
||||
// Calculations based on SwipeRefreshLayout specification.
|
||||
val endTrim = adjustedPercent * MaxProgressArc
|
||||
val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f
|
||||
val startAngle = rotation * 360
|
||||
val endAngle = (rotation + endTrim) * 360
|
||||
val scale = min(1f, adjustedPercent)
|
||||
|
||||
return ArrowValues(rotation, startAngle, endAngle, scale)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawArrow(
|
||||
arrow: Path,
|
||||
bounds: Rect,
|
||||
color: Color,
|
||||
alpha: Float,
|
||||
values: ArrowValues
|
||||
) {
|
||||
arrow.reset()
|
||||
arrow.moveTo(0f, 0f) // Move to left corner
|
||||
arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner
|
||||
|
||||
// Line to tip of arrow
|
||||
arrow.lineTo(
|
||||
x = ArrowWidth.toPx() * values.scale / 2,
|
||||
y = ArrowHeight.toPx() * values.scale
|
||||
)
|
||||
|
||||
val radius = min(bounds.width, bounds.height) / 2f
|
||||
val inset = ArrowWidth.toPx() * values.scale / 2f
|
||||
arrow.translate(
|
||||
Offset(
|
||||
x = radius + bounds.center.x - inset,
|
||||
y = bounds.center.y + StrokeWidth.toPx() / 2f
|
||||
)
|
||||
)
|
||||
arrow.close()
|
||||
rotate(degrees = values.endAngle) {
|
||||
drawPath(path = arrow, color = color, alpha = alpha)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CrossfadeDurationMs = 100
|
||||
private const val MaxProgressArc = 0.8f
|
||||
|
||||
private val IndicatorSize = 40.dp
|
||||
private val SpinnerShape = CircleShape
|
||||
private val ArcRadius = 7.5.dp
|
||||
private val StrokeWidth = 2.5.dp
|
||||
private val ArrowWidth = 10.dp
|
||||
private val ArrowHeight = 5.dp
|
||||
private val Elevation = 6.dp
|
||||
|
||||
// Values taken from SwipeRefreshLayout
|
||||
private const val MinAlpha = 0.3f
|
||||
private const val MaxAlpha = 1f
|
||||
private val AlphaTween = tween<Float>(300, easing = LinearEasing)
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.material3.pullrefresh
|
||||
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.platform.inspectable
|
||||
|
||||
/**
|
||||
* A modifier for translating the position and scaling the size of a pull-to-refresh indicator
|
||||
* based on the given [PullRefreshState].
|
||||
*
|
||||
* @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample
|
||||
*
|
||||
* @param state The [PullRefreshState] which determines the position of the indicator.
|
||||
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
|
||||
*/
|
||||
// TODO: Consider whether the state parameter should be replaced with lambdas.
|
||||
fun Modifier.pullRefreshIndicatorTransform(
|
||||
state: PullRefreshState,
|
||||
scale: Boolean = false
|
||||
) = inspectable(
|
||||
inspectorInfo = debugInspectorInfo {
|
||||
name = "pullRefreshIndicatorTransform"
|
||||
properties["state"] = state
|
||||
properties["scale"] = scale
|
||||
}
|
||||
) {
|
||||
Modifier
|
||||
// Essentially we only want to clip the at the top, so the indicator will not appear when
|
||||
// the position is 0. It is preferable to clip the indicator as opposed to the layout that
|
||||
// contains the indicator, as this would also end up clipping shadows drawn by items in a
|
||||
// list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE
|
||||
// for the other dimensions to allow for more room for elevation / arbitrary indicators - we
|
||||
// only ever really want to clip at the top edge.
|
||||
.drawWithContent {
|
||||
clipRect(
|
||||
top = 0f,
|
||||
left = -Float.MAX_VALUE,
|
||||
right = Float.MAX_VALUE,
|
||||
bottom = Float.MAX_VALUE
|
||||
) {
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
translationY = state.position - size.height
|
||||
|
||||
if (scale && !state.refreshing) {
|
||||
val scaleFraction = LinearOutSlowInEasing
|
||||
.transform(state.position / state.threshold)
|
||||
.coerceIn(0f, 1f)
|
||||
scaleX = scaleFraction
|
||||
scaleY = scaleFraction
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.material3.pullrefresh
|
||||
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Creates a [PullRefreshState] that is remembered across compositions.
|
||||
*
|
||||
* Changes to [refreshing] will result in [PullRefreshState] being updated.
|
||||
*
|
||||
* @sample androidx.compose.material.samples.PullRefreshSample
|
||||
*
|
||||
* @param refreshing A boolean representing whether a refresh is currently occurring.
|
||||
* @param onRefresh The function to be called to trigger a refresh.
|
||||
* @param refreshThreshold The threshold below which, if a release
|
||||
* occurs, [onRefresh] will be called.
|
||||
* @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This
|
||||
* offset corresponds to the position of the bottom of the indicator.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberPullRefreshState(
|
||||
refreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
|
||||
refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset
|
||||
): PullRefreshState {
|
||||
require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val onRefreshState = rememberUpdatedState(onRefresh)
|
||||
val thresholdPx: Float
|
||||
val refreshingOffsetPx: Float
|
||||
|
||||
with(LocalDensity.current) {
|
||||
thresholdPx = refreshThreshold.toPx()
|
||||
refreshingOffsetPx = refreshingOffset.toPx()
|
||||
}
|
||||
|
||||
val state = remember(scope) {
|
||||
PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx)
|
||||
}
|
||||
|
||||
SideEffect {
|
||||
state.setRefreshing(refreshing)
|
||||
state.setThreshold(thresholdPx)
|
||||
state.setRefreshingOffset(refreshingOffsetPx)
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh
|
||||
* behaviour to a scroll component. Based on Android's SwipeRefreshLayout.
|
||||
*
|
||||
* Provides [progress], a float representing how far the user has pulled as a percentage of the
|
||||
* refreshThreshold. Values of one or less indicate that the user has not yet pulled past the
|
||||
* threshold. Values greater than one indicate how far past the threshold the user has pulled.
|
||||
*
|
||||
* Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like
|
||||
* pull-to-refresh behaviour with a custom indicator.
|
||||
*
|
||||
* Should be created using [rememberPullRefreshState].
|
||||
*/
|
||||
class PullRefreshState internal constructor(
|
||||
private val animationScope: CoroutineScope,
|
||||
private val onRefreshState: State<() -> Unit>,
|
||||
refreshingOffset: Float,
|
||||
threshold: Float
|
||||
) {
|
||||
/**
|
||||
* A float representing how far the user has pulled as a percentage of the refreshThreshold.
|
||||
*
|
||||
* If the component has not been pulled at all, progress is zero. If the pull has reached
|
||||
* halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has
|
||||
* gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to
|
||||
* two times the refreshThreshold.
|
||||
*/
|
||||
val progress get() = adjustedDistancePulled / threshold
|
||||
|
||||
internal val refreshing get() = _refreshing
|
||||
internal val position get() = _position
|
||||
internal val threshold get() = _threshold
|
||||
|
||||
private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier }
|
||||
|
||||
private var _refreshing by mutableStateOf(false)
|
||||
private var _position by mutableStateOf(0f)
|
||||
private var distancePulled by mutableStateOf(0f)
|
||||
private var _threshold by mutableStateOf(threshold)
|
||||
private var _refreshingOffset by mutableStateOf(refreshingOffset)
|
||||
|
||||
internal fun onPull(pullDelta: Float): Float {
|
||||
if (_refreshing) return 0f // Already refreshing, do nothing.
|
||||
|
||||
val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)
|
||||
val dragConsumed = newOffset - distancePulled
|
||||
distancePulled = newOffset
|
||||
_position = calculateIndicatorPosition()
|
||||
return dragConsumed
|
||||
}
|
||||
|
||||
internal fun onRelease(velocity: Float): Float {
|
||||
if (refreshing) return 0f // Already refreshing, do nothing
|
||||
|
||||
if (adjustedDistancePulled > threshold) {
|
||||
onRefreshState.value()
|
||||
}
|
||||
animateIndicatorTo(0f)
|
||||
val consumed = when {
|
||||
// We are flinging without having dragged the pull refresh (for example a fling inside
|
||||
// a list) - don't consume
|
||||
distancePulled == 0f -> 0f
|
||||
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
|
||||
// the list from scrolling
|
||||
velocity < 0f -> 0f
|
||||
// We are showing the indicator, and the fling is downwards - consume everything
|
||||
else -> velocity
|
||||
}
|
||||
distancePulled = 0f
|
||||
return consumed
|
||||
}
|
||||
|
||||
internal fun setRefreshing(refreshing: Boolean) {
|
||||
if (_refreshing != refreshing) {
|
||||
_refreshing = refreshing
|
||||
distancePulled = 0f
|
||||
animateIndicatorTo(if (refreshing) _refreshingOffset else 0f)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun setThreshold(threshold: Float) {
|
||||
_threshold = threshold
|
||||
}
|
||||
|
||||
internal fun setRefreshingOffset(refreshingOffset: Float) {
|
||||
if (_refreshingOffset != refreshingOffset) {
|
||||
_refreshingOffset = refreshingOffset
|
||||
if (refreshing) animateIndicatorTo(refreshingOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure to cancel any existing animations when we launch a new one. We use this instead of
|
||||
// Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
|
||||
// overhead of running through the animation pipeline instead of directly mutating the state.
|
||||
private val mutatorMutex = MutatorMutex()
|
||||
|
||||
private fun animateIndicatorTo(offset: Float) = animationScope.launch {
|
||||
mutatorMutex.mutate {
|
||||
animate(initialValue = _position, targetValue = offset) { value, _ ->
|
||||
_position = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateIndicatorPosition(): Float = when {
|
||||
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
|
||||
adjustedDistancePulled <= threshold -> adjustedDistancePulled
|
||||
else -> {
|
||||
// How far beyond the threshold pull has gone, as a percentage of the threshold.
|
||||
val overshootPercent = abs(progress) - 1.0f
|
||||
// Limit the overshoot to 200%. Linear between 0 and 200.
|
||||
val linearTension = overshootPercent.coerceIn(0f, 2f)
|
||||
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
|
||||
val tensionPercent = linearTension - linearTension.pow(2) / 4
|
||||
// The additional offset beyond the threshold.
|
||||
val extraOffset = threshold * tensionPercent
|
||||
threshold + extraOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default parameter values for [rememberPullRefreshState].
|
||||
*/
|
||||
object PullRefreshDefaults {
|
||||
/**
|
||||
* If the indicator is below this threshold offset when it is released, a refresh
|
||||
* will be triggered.
|
||||
*/
|
||||
val RefreshThreshold = 80.dp
|
||||
|
||||
/**
|
||||
* The offset at which the indicator should be rendered whilst a refresh is occurring.
|
||||
*/
|
||||
val RefreshingOffset = 56.dp
|
||||
}
|
||||
|
||||
/**
|
||||
* The distance pulled is multiplied by this value to give us the adjusted distance pulled, which
|
||||
* is used in calculating the indicator position (when the adjusted distance pulled is less than
|
||||
* the refresh threshold, it is the indicator position, otherwise the indicator position is
|
||||
* derived from the progress).
|
||||
*/
|
||||
private const val DragMultiplier = 0.5f
|
||||
@@ -1,11 +1,56 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import coil.ImageLoader
|
||||
import com.vitorpamplona.amethyst.service.playback.VideoCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class Amethyst : Application() {
|
||||
val videoCache: VideoCache by lazy {
|
||||
val newCache = VideoCache()
|
||||
newCache.initFileCache(instance)
|
||||
newCache
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
// initializes the video cache in a thread
|
||||
videoCache
|
||||
}
|
||||
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
fun imageLoaderBuilder(): ImageLoader.Builder {
|
||||
return ImageLoader.Builder(applicationContext).diskCache {
|
||||
SingletonDiskCache.get(applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.security.crypto.MasterKey
|
||||
object EncryptedStorage {
|
||||
private const val PREFERENCES_NAME = "secret_keeper"
|
||||
|
||||
// returns the preferences for each account or a global file if null.
|
||||
fun prefsFileName(npub: String? = null): String {
|
||||
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
|
||||
}
|
||||
|
||||
@@ -3,25 +3,34 @@ package com.vitorpamplona.amethyst
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.BooleanType
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.DefaultReactions
|
||||
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.model.parseBooleanType
|
||||
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.parseThemeType
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47URI
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.toHex
|
||||
import nostr.postr.toNpub
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
@@ -34,7 +43,8 @@ private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
|
||||
@Immutable
|
||||
data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean = false
|
||||
val hasPrivKey: Boolean,
|
||||
val loggedInWithExternalSigner: Boolean
|
||||
)
|
||||
|
||||
private object PrefKeys {
|
||||
@@ -61,30 +71,40 @@ private object PrefKeys {
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog"
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
|
||||
const val WARN_ABOUT_REPORTS = "warn_about_reports"
|
||||
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
|
||||
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images"
|
||||
const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback"
|
||||
const val THEME = "theme"
|
||||
const val PREFERRED_LANGUAGE = "preferred_Language"
|
||||
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
|
||||
const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars"
|
||||
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
|
||||
const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture"
|
||||
|
||||
private val gson = GsonBuilder().create()
|
||||
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
|
||||
const val SHARED_SETTINGS = "shared_settings"
|
||||
}
|
||||
|
||||
object LocalPreferences {
|
||||
private const val comma = ","
|
||||
|
||||
private var _currentAccount: String? = null
|
||||
private var _savedAccounts: List<AccountInfo>? = null
|
||||
|
||||
private fun currentAccount(): String? {
|
||||
suspend fun currentAccount(): String? {
|
||||
if (_currentAccount == null) {
|
||||
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
}
|
||||
return _currentAccount
|
||||
}
|
||||
|
||||
private fun updateCurrentAccount(npub: String) {
|
||||
private suspend fun updateCurrentAccount(npub: String) {
|
||||
if (_currentAccount != npub) {
|
||||
_currentAccount = npub
|
||||
|
||||
@@ -94,22 +114,39 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
private var _savedAccounts: List<String>? = null
|
||||
|
||||
private fun savedAccounts(): List<String> {
|
||||
private fun savedAccounts(): List<AccountInfo> {
|
||||
if (_savedAccounts == null) {
|
||||
_savedAccounts = encryptedPreferences()
|
||||
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
|
||||
with(encryptedPreferences()) {
|
||||
val newSystemOfAccounts = getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
|
||||
Event.mapper.readValue<List<AccountInfo>>(it)
|
||||
}
|
||||
|
||||
if (newSystemOfAccounts != null && newSystemOfAccounts.isNotEmpty()) {
|
||||
_savedAccounts = newSystemOfAccounts
|
||||
} else {
|
||||
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
|
||||
|
||||
val migrated = oldAccounts.map { npub ->
|
||||
AccountInfo(
|
||||
npub,
|
||||
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
|
||||
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank()
|
||||
)
|
||||
}
|
||||
|
||||
_savedAccounts = migrated
|
||||
}
|
||||
}
|
||||
}
|
||||
return _savedAccounts!!
|
||||
}
|
||||
|
||||
private fun updateSavedAccounts(accounts: List<String>) {
|
||||
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) = withContext(Dispatchers.IO) {
|
||||
if (_savedAccounts != accounts) {
|
||||
_savedAccounts = accounts
|
||||
|
||||
encryptedPreferences().edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts))
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
@@ -117,38 +154,40 @@ object LocalPreferences {
|
||||
private val prefsDirPath: String
|
||||
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
|
||||
|
||||
private fun addAccount(npub: String) {
|
||||
val accounts = savedAccounts().toMutableList()
|
||||
if (npub !in accounts) {
|
||||
accounts.add(npub)
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
private suspend fun addAccount(accInfo: AccountInfo) {
|
||||
val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo)
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
|
||||
private fun setCurrentAccount(account: Account) {
|
||||
private suspend fun setCurrentAccount(account: Account) = withContext(Dispatchers.IO) {
|
||||
val npub = account.userProfile().pubkeyNpub()
|
||||
val accInfo = AccountInfo(
|
||||
npub,
|
||||
account.isWriteable(),
|
||||
account.loginWithExternalSigner
|
||||
)
|
||||
updateCurrentAccount(npub)
|
||||
addAccount(npub)
|
||||
addAccount(accInfo)
|
||||
}
|
||||
|
||||
fun switchToAccount(npub: String) {
|
||||
updateCurrentAccount(npub)
|
||||
suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) {
|
||||
updateCurrentAccount(accountInfo.npub)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the account from the app level shared preferences
|
||||
*/
|
||||
private fun removeAccount(npub: String) {
|
||||
val accounts = savedAccounts().toMutableList()
|
||||
if (accounts.remove(npub)) {
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
private suspend fun removeAccount(accountInfo: AccountInfo) {
|
||||
val accounts = savedAccounts().filter { it.npub != accountInfo.npub }
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the npub-specific shared preference file
|
||||
*/
|
||||
private fun deleteUserPreferenceFile(npub: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
@@ -158,6 +197,8 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
|
||||
checkNotInMainThread()
|
||||
|
||||
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
|
||||
val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
|
||||
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
|
||||
@@ -175,60 +216,65 @@ object LocalPreferences {
|
||||
* condition and the file will probably not be deleted
|
||||
*/
|
||||
@SuppressLint("ApplySharedPref")
|
||||
fun updatePrefsForLogout(npub: String) {
|
||||
val userPrefs = encryptedPreferences(npub)
|
||||
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) = withContext(Dispatchers.IO) {
|
||||
val userPrefs = encryptedPreferences(accountInfo.npub)
|
||||
userPrefs.edit().clear().commit()
|
||||
removeAccount(npub)
|
||||
deleteUserPreferenceFile(npub)
|
||||
removeAccount(accountInfo)
|
||||
deleteUserPreferenceFile(accountInfo.npub)
|
||||
|
||||
if (savedAccounts().isEmpty()) {
|
||||
val appPrefs = encryptedPreferences()
|
||||
appPrefs.edit().clear().apply()
|
||||
} else if (currentAccount() == npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0))
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
} else if (currentAccount() == accountInfo.npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0).npub)
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrefsForLogin(account: Account) {
|
||||
suspend fun updatePrefsForLogin(account: Account) {
|
||||
setCurrentAccount(account)
|
||||
saveToEncryptedStorage(account)
|
||||
}
|
||||
|
||||
fun allSavedAccounts(): List<AccountInfo> {
|
||||
return savedAccounts().map { npub ->
|
||||
AccountInfo(npub = npub)
|
||||
}
|
||||
return savedAccounts()
|
||||
}
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
suspend fun saveToEncryptedStorage(account: Account) = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
|
||||
prefs.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.loginWithExternalSigner)
|
||||
if (account.loginWithExternalSigner) {
|
||||
remove(PrefKeys.NOSTR_PRIVKEY)
|
||||
} else {
|
||||
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
|
||||
}
|
||||
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
|
||||
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
|
||||
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
|
||||
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
|
||||
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
|
||||
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
||||
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
|
||||
putString(PrefKeys.LANGUAGE_PREFS, Event.mapper.writeValueAsString(account.languagePreferences))
|
||||
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
||||
putString(PrefKeys.REACTION_CHOICES, gson.toJson(account.reactionChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
|
||||
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
|
||||
putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name)
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
|
||||
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, Event.mapper.writeValueAsString(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.mapper.writeValueAsString(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
|
||||
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
|
||||
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
|
||||
putString(PrefKeys.LAST_READ_PER_ROUTE, gson.toJson(account.lastReadPerRoute))
|
||||
putString(PrefKeys.LAST_READ_PER_ROUTE, Event.mapper.writeValueAsString(account.lastReadPerRoute))
|
||||
|
||||
if (account.showSensitiveContent == null) {
|
||||
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
|
||||
@@ -238,23 +284,111 @@ object LocalPreferences {
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
val acc = loadFromEncryptedStorage(currentAccount())
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? {
|
||||
val acc = loadCurrentAccountFromEncryptedStorage(currentAccount())
|
||||
acc?.registerObservers()
|
||||
return acc
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(npub: String?): Account? {
|
||||
encryptedPreferences(npub).apply {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
suspend fun migrateOldSharedSettings(): Settings? {
|
||||
val prefs = encryptedPreferences()
|
||||
loadOldSharedSettings(prefs)?.let {
|
||||
saveSharedSettings(it, prefs)
|
||||
return it
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun saveSharedSettings(sharedSettings: Settings, prefs: SharedPreferences = encryptedPreferences()) {
|
||||
with(prefs.edit()) {
|
||||
putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings))
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
|
||||
with(prefs) {
|
||||
return try {
|
||||
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let {
|
||||
Event.mapper.readValue<Settings>(it)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", e)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Turned into a single JSON object")
|
||||
suspend fun loadOldSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
|
||||
with(prefs) {
|
||||
if (!contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
val automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyHideNavigationBars = if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
|
||||
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
|
||||
} else {
|
||||
BooleanType.ALWAYS
|
||||
}
|
||||
|
||||
val automaticallyShowProfilePictures = if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
val themeType = if (contains(PrefKeys.THEME)) {
|
||||
parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode))
|
||||
} else {
|
||||
ThemeType.SYSTEM
|
||||
}
|
||||
|
||||
return Settings(
|
||||
themeType,
|
||||
getString(PrefKeys.PREFERRED_LANGUAGE, null)?.ifBlank { null },
|
||||
automaticallyShowImages,
|
||||
automaticallyStartPlayback,
|
||||
automaticallyShowUrlPreview,
|
||||
automaticallyHideNavigationBars,
|
||||
automaticallyShowProfilePictures,
|
||||
false,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
return@withContext with(encryptedPreferences(npub)) {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null
|
||||
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
|
||||
val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
|
||||
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||
val localRelays = gson.fromJson(
|
||||
getString(PrefKeys.RELAYS, "[]"),
|
||||
object : TypeToken<Set<RelaySetupInfo>>() {}.type
|
||||
) ?: setOf<RelaySetupInfo>()
|
||||
val localRelays = getString(PrefKeys.RELAYS, "[]")?.let {
|
||||
println("LocalRelays: $it")
|
||||
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
|
||||
} ?: setOf<RelaySetupInfo>()
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
@@ -263,59 +397,59 @@ object LocalPreferences {
|
||||
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let {
|
||||
Event.mapper.readValue<List<Long>?>(it)
|
||||
}?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
|
||||
|
||||
val reactionChoices = gson.fromJson<List<String>>(
|
||||
getString(PrefKeys.REACTION_CHOICES, "[]"),
|
||||
object : TypeToken<List<String>>() {}.type
|
||||
).ifEmpty { listOf("+") } ?: listOf("+")
|
||||
val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let {
|
||||
Event.mapper.readValue<List<String>?>(it)
|
||||
}?.ifEmpty { DefaultReactions } ?: DefaultReactions
|
||||
|
||||
val defaultZapType = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
|
||||
object : TypeToken<LnZapEvent.ZapType>() {}.type
|
||||
) ?: LnZapEvent.ZapType.PUBLIC
|
||||
val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
|
||||
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
|
||||
} ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "NOSTR_BUILD"),
|
||||
object : TypeToken<ServersAvailable>() {}.type
|
||||
) ?: ServersAvailable.NOSTR_BUILD
|
||||
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
|
||||
ServersAvailable.values().firstOrNull() { it.name == serverName }
|
||||
} ?: ServersAvailable.NOSTR_BUILD
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
gson.fromJson(it, Nip47URI::class.java)
|
||||
Event.mapper.readValue<Nip47URI?>(it)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", e)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val latestContactList = try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
Event.gson.fromJson(it, Event::class.java)
|
||||
.getRefinedEvent(true) as ContactListEvent
|
||||
println("Decoding Contact List: " + it)
|
||||
if (it != null) {
|
||||
Event.fromJson(it) as ContactListEvent?
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
Log.w("LocalPreferences", "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", e)
|
||||
null
|
||||
}
|
||||
|
||||
val languagePreferences = try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
gson.fromJson(
|
||||
it,
|
||||
object : TypeToken<Map<String, String>>() {}.type
|
||||
) as Map<String, String>
|
||||
Event.mapper.readValue<Map<String, String>?>(it)
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", e)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
@@ -330,18 +464,16 @@ object LocalPreferences {
|
||||
|
||||
val lastReadPerRoute = try {
|
||||
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
|
||||
gson.fromJson(
|
||||
it,
|
||||
object : TypeToken<Map<String, Long>>() {}.type
|
||||
) as Map<String, Long>
|
||||
Event.mapper.readValue<Map<String, Long>?>(it)
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", e)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val a = Account(
|
||||
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
return@with Account(
|
||||
keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels = followingChannels,
|
||||
followingCommunities = followingCommunities,
|
||||
hiddenUsers = hiddenUsers,
|
||||
@@ -360,57 +492,16 @@ object LocalPreferences {
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP24WarningDialog = hideNIP24WarningDialog,
|
||||
backupContactList = latestContactList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
showSensitiveContent = showSensitiveContent,
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
loginWithExternalSigner = loginWithExternalSigner
|
||||
)
|
||||
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
fun migrateSingleUserPrefs() {
|
||||
if (currentAccount() != null) return
|
||||
|
||||
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
|
||||
val npub = Hex.decode(pubkey).toNpub()
|
||||
|
||||
val stringPrefs = listOf(
|
||||
PrefKeys.NOSTR_PRIVKEY,
|
||||
PrefKeys.NOSTR_PUBKEY,
|
||||
PrefKeys.RELAYS,
|
||||
PrefKeys.LANGUAGE_PREFS,
|
||||
PrefKeys.TRANSLATE_TO,
|
||||
PrefKeys.ZAP_AMOUNTS,
|
||||
PrefKeys.LATEST_CONTACT_LIST
|
||||
)
|
||||
|
||||
val stringSetPrefs = listOf(
|
||||
PrefKeys.FOLLOWING_CHANNELS,
|
||||
PrefKeys.HIDDEN_USERS,
|
||||
PrefKeys.DONT_TRANSLATE_FROM
|
||||
)
|
||||
|
||||
encryptedPreferences().apply {
|
||||
val appPrefs = this
|
||||
encryptedPreferences(npub).edit().apply {
|
||||
val userPrefs = this
|
||||
|
||||
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
|
||||
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
|
||||
userPrefs.putBoolean(
|
||||
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
|
||||
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
addAccount(npub)
|
||||
updateCurrentAccount(npub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.util.DebugLogger
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
@@ -17,6 +19,7 @@ import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
@@ -28,17 +31,29 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
object ServiceManager {
|
||||
var shouldPauseService: Boolean = true // to not open amber in a loop trying to use auth relays and registering for notifications
|
||||
private var isStarted: Boolean = false // to not open amber in a loop trying to use auth relays and registering for notifications
|
||||
private var account: Account? = null
|
||||
|
||||
fun start(account: Account, context: Context) {
|
||||
private fun start(account: Account) {
|
||||
this.account = account
|
||||
start(context)
|
||||
ExternalSignerUtils.account = account
|
||||
start()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun start(context: Context) {
|
||||
private fun start() {
|
||||
Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account")
|
||||
if (isStarted && account != null) {
|
||||
return
|
||||
}
|
||||
Log.d("ServiceManager", "Starting Relay Services")
|
||||
|
||||
val myAccount = account
|
||||
@@ -47,22 +62,19 @@ object ServiceManager {
|
||||
HttpClient.start(account)
|
||||
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(context).components {
|
||||
Amethyst.instance.imageLoaderBuilder().components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
} // .logger(DebugLogger())
|
||||
}.logger(DebugLogger())
|
||||
.okHttpClient { HttpClient.getHttpClient() }
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(context.applicationContext)
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
@@ -77,17 +89,22 @@ object ServiceManager {
|
||||
// Notification Elements
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
NostrDiscoveryDataSource.start()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(3000)
|
||||
NostrChatroomListDataSource.start()
|
||||
NostrDiscoveryDataSource.start()
|
||||
NostrVideoDataSource.start()
|
||||
}
|
||||
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleChannelDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
isStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
private fun pause() {
|
||||
Log.d("ServiceManager", "Pausing Relay Services")
|
||||
|
||||
NostrAccountDataSource.stop()
|
||||
@@ -99,6 +116,7 @@ object ServiceManager {
|
||||
|
||||
NostrCommunityDataSource.stop()
|
||||
NostrHashtagDataSource.stop()
|
||||
NostrGeohashDataSource.stop()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
@@ -108,16 +126,91 @@ object ServiceManager {
|
||||
NostrVideoDataSource.stop()
|
||||
|
||||
Client.disconnect()
|
||||
isStarted = false
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
fun cleanObservers() {
|
||||
LocalCache.cleanObservers()
|
||||
}
|
||||
|
||||
fun trimMemory() {
|
||||
LocalCache.cleanObservers()
|
||||
|
||||
val accounts = LocalPreferences.allSavedAccounts().mapNotNull {
|
||||
decodePublicKeyAsHexOrNull(it.npub)
|
||||
}.toSet()
|
||||
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
NostrChatroomDataSource.clearEOSEs(it)
|
||||
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(it)
|
||||
// LocalCache.pruneNonFollows(it)
|
||||
LocalCache.pruneContactLists(accounts)
|
||||
LocalCache.pruneRepliesAndReactions(accounts)
|
||||
LocalCache.prunePastVersionsOfReplaceables()
|
||||
LocalCache.pruneExpiredEvents()
|
||||
}
|
||||
}
|
||||
|
||||
// This method keeps the pause/start in a Syncronized block to
|
||||
// avoid concurrent pauses and starts.
|
||||
@Synchronized
|
||||
fun forceRestart(account: Account? = null, start: Boolean = true, pause: Boolean = true) {
|
||||
if (pause) {
|
||||
pause()
|
||||
}
|
||||
|
||||
if (start) {
|
||||
if (account != null) {
|
||||
start(account)
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restartIfDifferentAccount(account: Account) {
|
||||
if (this.account != account) {
|
||||
forceRestart(account, true, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun forceRestartIfItShould() {
|
||||
if (shouldPauseService) {
|
||||
forceRestart(null, true, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun justStart() {
|
||||
forceRestart(null, true, false)
|
||||
}
|
||||
|
||||
fun pauseForGood() {
|
||||
forceRestart(null, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
object SingletonDiskCache {
|
||||
|
||||
private const val DIRECTORY = "image_cache"
|
||||
private var instance: DiskCache? = null
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context): DiskCache {
|
||||
return instance ?: run {
|
||||
// Create the singleton disk cache instance.
|
||||
DiskCache.Builder()
|
||||
.directory(context.safeCacheDir.resolve(DIRECTORY))
|
||||
.maxSizePercent(0.2)
|
||||
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.safeCacheDir: File
|
||||
get() {
|
||||
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
|
||||
return cacheDir.apply { mkdirs() }
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
object VideoCache {
|
||||
|
||||
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
|
||||
|
||||
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
|
||||
|
||||
lateinit var exoDatabaseProvider: StandaloneDatabaseProvider
|
||||
lateinit var simpleCache: SimpleCache
|
||||
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
@Synchronized
|
||||
fun init(context: Context) {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
} else {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
fun get(): CacheDataSource.Factory {
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,11 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.OptOutFromFilters
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
|
||||
|
||||
@@ -4,12 +4,14 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -95,6 +97,10 @@ abstract class Channel(val idHex: String) {
|
||||
return null
|
||||
}
|
||||
|
||||
open fun creatorName(): String? {
|
||||
return creator?.toBestDisplayName()
|
||||
}
|
||||
|
||||
open fun profilePicture(): String? {
|
||||
return creator?.profilePicture()
|
||||
}
|
||||
@@ -158,20 +164,12 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
}
|
||||
NostrSingleChannelDataSource.add(channel)
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
}
|
||||
NostrSingleChannelDataSource.remove(channel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Stable
|
||||
class Chatroom() {
|
||||
var roomMessages: Set<Note> = setOf()
|
||||
var subject: String? = null
|
||||
var subjectCreatedAt: Long? = null
|
||||
|
||||
@Synchronized
|
||||
fun addMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
val newSubject = msg.event?.subject()
|
||||
|
||||
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
|
||||
subject = newSubject
|
||||
subjectCreatedAt = msg.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
|
||||
subject = it.event?.subject()
|
||||
subjectCreatedAt = it.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun senderIntersects(keySet: Set<HexKey>): Boolean {
|
||||
return roomMessages.any { it.author?.pubkeyHex in keySet }
|
||||
}
|
||||
|
||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
||||
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
|
||||
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
|
||||
// Recent messages, keep last 100
|
||||
sorted.take(100).toSet()
|
||||
} else {
|
||||
// Old messages, keep the last one.
|
||||
sorted.take(1).toSet()
|
||||
} + sorted.filter { it.liveSet?.isInUse() ?: false }
|
||||
|
||||
val toRemove = roomMessages.minus(toKeep)
|
||||
roomMessages = toKeep
|
||||
return toRemove
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import com.vitorpamplona.amethyst.R
|
||||
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches", "thenostr" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toHex
|
||||
import nostr.postr.toNpub
|
||||
|
||||
/** Makes the distinction between String and Hex **/
|
||||
typealias HexKey = String
|
||||
|
||||
typealias NPubKey = String
|
||||
typealias NoteId = String
|
||||
|
||||
fun NPubKey.toDisplayKey(): String {
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun NoteId.toDisplayId(): String {
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
|
||||
|
||||
fun ByteArray.toHexKey(): HexKey {
|
||||
return toHex()
|
||||
}
|
||||
|
||||
fun HexKey.hexToByteArray(): ByteArray {
|
||||
return Hex.decode(this)
|
||||
}
|
||||
|
||||
fun HexKey.toDisplayHexKey(): String {
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.hexToByteArray()
|
||||
|
||||
return if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
} else if (pubKeyParsed != null) {
|
||||
pubKeyParsed
|
||||
} else {
|
||||
Hex.decode(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
|
||||
return try {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex
|
||||
|
||||
if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey.toHexKey()
|
||||
} else if (pubKeyParsed != null) {
|
||||
pubKeyParsed
|
||||
} else {
|
||||
Hex.decode(key).toHexKey()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
|
||||
|
||||
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
var key = mightBeAKey
|
||||
if (key.startsWith("nostr:", true)) {
|
||||
key = key.substring("nostr:".length)
|
||||
}
|
||||
|
||||
key = key.removePrefix("@")
|
||||
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
|
||||
if (key.startsWith("nsec1", true)) {
|
||||
// Converts to npub
|
||||
val pubkey = Nip19.uriToRoute(Persona(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
} else if (key.startsWith("npub1", true)) {
|
||||
val pubkey = Nip19.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
} else if (key.startsWith("note1", true)) {
|
||||
val noteId = Nip19.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteId, restOfWord)
|
||||
} else if (key.startsWith("nprofile", true)) {
|
||||
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
|
||||
} else if (key.startsWith("nevent1", true)) {
|
||||
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
|
||||
} else if (key.startsWith("naddr1", true)) {
|
||||
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
|
||||
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
|
||||
@@ -3,25 +3,49 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.combineWith
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.WrappedEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
|
||||
@Stable
|
||||
class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
@@ -37,6 +61,10 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
|
||||
return minOf(publishedAt, lastCreatedAt)
|
||||
}
|
||||
|
||||
fun dTag(): String? {
|
||||
return (event as? AddressableEvent)?.dTag()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -48,20 +76,20 @@ open class Note(val idHex: String) {
|
||||
var replyTo: List<Note>? = null
|
||||
|
||||
// These fields are updated every time an event related to this note is received.
|
||||
var replies = setOf<Note>()
|
||||
var replies = listOf<Note>()
|
||||
private set
|
||||
var reactions = mapOf<String, Set<Note>>()
|
||||
var reactions = mapOf<String, List<Note>>()
|
||||
private set
|
||||
var boosts = setOf<Note>()
|
||||
var boosts = listOf<Note>()
|
||||
private set
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
var reports = mapOf<User, List<Note>>()
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = setOf<String>()
|
||||
var relays = listOf<String>()
|
||||
private set
|
||||
|
||||
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
|
||||
@@ -70,7 +98,26 @@ open class Note(val idHex: String) {
|
||||
open fun idNote() = id().toNote()
|
||||
|
||||
open fun toNEvent(): String {
|
||||
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
val myEvent = event
|
||||
return if (myEvent is WrappedEvent) {
|
||||
val host = myEvent.host
|
||||
if (host != null) {
|
||||
Nip19.createNEvent(
|
||||
host.id,
|
||||
host.pubKey,
|
||||
host.kind(),
|
||||
relays.firstOrNull()
|
||||
)
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
} else {
|
||||
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
fun toNostrUri(): String {
|
||||
return "nostr:${toNEvent()}"
|
||||
}
|
||||
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
@@ -97,11 +144,13 @@ open class Note(val idHex: String) {
|
||||
open fun createdAt() = event?.createdAt()
|
||||
|
||||
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
|
||||
this.event = event
|
||||
this.author = author
|
||||
this.replyTo = replyTo
|
||||
if (this.event?.id() != event.id()) {
|
||||
this.event = event
|
||||
this.author = author
|
||||
this.replyTo = replyTo
|
||||
|
||||
liveSet?.metadata?.invalidateData()
|
||||
liveSet?.innerMetadata?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
@@ -109,20 +158,63 @@ open class Note(val idHex: String) {
|
||||
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
|
||||
}
|
||||
|
||||
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
|
||||
|
||||
/**
|
||||
* This method caches signatures during each execution to avoid recalculation in longer threads
|
||||
*/
|
||||
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
|
||||
fun replyLevelSignature(
|
||||
eventsToConsider: Set<HexKey>,
|
||||
cachedSignatures: MutableMap<Note, LevelSignature>,
|
||||
account: User,
|
||||
accountFollowingSet: Set<String>,
|
||||
now: Long
|
||||
): LevelSignature {
|
||||
val replyTo = replyTo
|
||||
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
|
||||
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
return LevelSignature(
|
||||
signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";",
|
||||
createdAt = createdAt(),
|
||||
author = author
|
||||
)
|
||||
}
|
||||
|
||||
return replyTo
|
||||
.map {
|
||||
cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) }
|
||||
}
|
||||
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
val parent = (
|
||||
replyTo
|
||||
.filter { it.idHex in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
|
||||
.map {
|
||||
cachedSignatures[it] ?: it.replyLevelSignature(
|
||||
eventsToConsider,
|
||||
cachedSignatures,
|
||||
account,
|
||||
accountFollowingSet,
|
||||
now
|
||||
).apply { cachedSignatures.put(it, this) }
|
||||
}
|
||||
.maxByOrNull { it.signature.length }
|
||||
)
|
||||
|
||||
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
|
||||
|
||||
val threadOrder = if (parent?.author == author && createdAt() != null) {
|
||||
// author of the thread first, in **ascending** order
|
||||
"9" + formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) + idHex.substring(0, 8)
|
||||
} else if (author?.pubkeyHex == account.pubkeyHex) {
|
||||
"8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
|
||||
} else if (author?.pubkeyHex in accountFollowingSet) {
|
||||
"7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
|
||||
} else {
|
||||
"0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
|
||||
}
|
||||
|
||||
val mySignature = LevelSignature(
|
||||
signature = parentSignature + "/" + threadOrder + ";",
|
||||
createdAt = createdAt(),
|
||||
author = author
|
||||
)
|
||||
|
||||
cachedSignatures[this] = mySignature
|
||||
return mySignature
|
||||
}
|
||||
|
||||
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
|
||||
@@ -139,31 +231,68 @@ open class Note(val idHex: String) {
|
||||
fun addReply(note: Note) {
|
||||
if (note !in replies) {
|
||||
replies = replies + note
|
||||
liveSet?.replies?.invalidateData()
|
||||
liveSet?.innerReplies?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReply(note: Note) {
|
||||
replies = replies - note
|
||||
liveSet?.replies?.invalidateData()
|
||||
if (note in replies) {
|
||||
replies = replies - note
|
||||
liveSet?.innerReplies?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeBoost(note: Note) {
|
||||
boosts = boosts - note
|
||||
liveSet?.boosts?.invalidateData()
|
||||
if (note in boosts) {
|
||||
boosts = boosts - note
|
||||
liveSet?.innerBoosts?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllChildNotes(): List<Note> {
|
||||
val toBeRemoved = replies +
|
||||
reactions.values.flatten() +
|
||||
boosts +
|
||||
reports.values.flatten() +
|
||||
zaps.keys +
|
||||
zaps.values.filterNotNull() +
|
||||
zapPayments.keys +
|
||||
zapPayments.values.filterNotNull()
|
||||
|
||||
replies = listOf<Note>()
|
||||
reactions = mapOf<String, List<Note>>()
|
||||
boosts = listOf<Note>()
|
||||
reports = mapOf<User, List<Note>>()
|
||||
zaps = mapOf<Note, Note?>()
|
||||
zapPayments = mapOf<Note, Note?>()
|
||||
relays = listOf<String>()
|
||||
lastReactionsDownloadTime = emptyMap()
|
||||
|
||||
liveSet?.innerReplies?.invalidateData()
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
liveSet?.innerBoosts?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
|
||||
return toBeRemoved
|
||||
}
|
||||
|
||||
fun removeReaction(note: Note) {
|
||||
val reaction = note.event?.content() ?: "+"
|
||||
val tags = note.event?.tags() ?: emptyList()
|
||||
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
|
||||
reactions[reaction]?.let {
|
||||
val newList = it.minus(note)
|
||||
if (newList.isEmpty()) {
|
||||
reactions = reactions.minus(reaction)
|
||||
} else {
|
||||
reactions = reactions + Pair(reaction, newList)
|
||||
}
|
||||
if (note in it) {
|
||||
val newList = it.minus(note)
|
||||
if (newList.isEmpty()) {
|
||||
reactions = reactions.minus(reaction)
|
||||
} else {
|
||||
reactions = reactions + Pair(reaction, newList)
|
||||
}
|
||||
|
||||
liveSet?.reactions?.invalidateData()
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +303,7 @@ open class Note(val idHex: String) {
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,65 +311,97 @@ open class Note(val idHex: String) {
|
||||
fun removeZap(note: Note) {
|
||||
if (zaps[note] != null) {
|
||||
zaps = zaps.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zaps.containsValue(note)) {
|
||||
val toRemove = zaps.filterValues { it == note }
|
||||
zaps = zaps.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
zaps = zaps.filterValues { it != note }
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZapPayment(note: Note) {
|
||||
if (zapPayments[note] != null) {
|
||||
zapPayments = zapPayments.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zapPayments.containsValue(note)) {
|
||||
val toRemove = zapPayments.filterValues { it == note }
|
||||
zapPayments = zapPayments.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (note !in boosts) {
|
||||
boosts = boosts + note
|
||||
liveSet?.boosts?.invalidateData()
|
||||
liveSet?.innerBoosts?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun innerAddZap(zapRequest: Note, zap: Note?): Boolean {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
return true
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun innerAddZapPayment(zapPaymentRequest: Note, zapPayment: Note?): Boolean {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
return true
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
val reaction = note.event?.content() ?: "+"
|
||||
val tags = note.event?.tags() ?: emptyList()
|
||||
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
if (reaction !in reactions.keys) {
|
||||
reactions = reactions + Pair(reaction, setOf(note))
|
||||
liveSet?.reactions?.invalidateData()
|
||||
reactions = reactions + Pair(reaction, listOf(note))
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
} else if (reactions[reaction]?.contains(note) == false) {
|
||||
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
|
||||
liveSet?.reactions?.invalidateData()
|
||||
liveSet?.innerReactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,18 +409,25 @@ open class Note(val idHex: String) {
|
||||
val author = note.author ?: return
|
||||
|
||||
if (author !in reports.keys) {
|
||||
reports = reports + Pair(author, setOf(note))
|
||||
liveSet?.reports?.invalidateData()
|
||||
reports = reports + Pair(author, listOf(note))
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
} else if (reports[author]?.contains(note) == false) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addRelaySync(url: String) {
|
||||
if (url !in relays) {
|
||||
relays = relays + url
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: Relay) {
|
||||
if (relay.url !in relays) {
|
||||
relays = relays + relay.url
|
||||
liveSet?.relays?.invalidateData()
|
||||
addRelaySync(relay.url)
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,55 +446,6 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun publicZapAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.mapNotNull {
|
||||
it.key.author
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun publicZapAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.mapNotNull {
|
||||
it.key.author?.pubkeyHex
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun reactionAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return reactions.values.map {
|
||||
it.mapNotNull { it.author }
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
fun reactionAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return reactions.values.map {
|
||||
it.mapNotNull { it.author?.pubkeyHex }
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
fun replyAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return replies.mapNotNull {
|
||||
it.author?.pubkeyHex
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun replyAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return replies.mapNotNull {
|
||||
it.author
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun boostAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return boosts.mapNotNull {
|
||||
it.author
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun getReactionBy(user: User): String? {
|
||||
return reactions.firstNotNullOfOrNull {
|
||||
if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) {
|
||||
@@ -341,8 +460,8 @@ open class Note(val idHex: String) {
|
||||
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): Set<Note> {
|
||||
return reports[user] ?: emptySet()
|
||||
fun hasReportsBy(user: User): Boolean {
|
||||
return reports[user]?.isNotEmpty() ?: false
|
||||
}
|
||||
|
||||
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
|
||||
@@ -359,10 +478,6 @@ open class Note(val idHex: String) {
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun countReactions(): Int {
|
||||
return reactions.values.sumOf { it.size }
|
||||
}
|
||||
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
@@ -462,7 +577,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun reactedBy(loggedIn: User, content: String): List<Note> {
|
||||
return reactions[content]?.filter { it.author == loggedIn && it.event?.content() == content } ?: emptyList()
|
||||
return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
|
||||
}
|
||||
|
||||
fun reactedBy(loggedIn: User): List<String> {
|
||||
@@ -506,13 +621,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
replyTo = null
|
||||
replies = emptySet()
|
||||
replies = emptyList()
|
||||
reactions = emptyMap()
|
||||
boosts = emptySet()
|
||||
boosts = emptyList()
|
||||
reports = emptyMap()
|
||||
zaps = emptyMap()
|
||||
}
|
||||
|
||||
fun clearEOSE() {
|
||||
lastReactionsDownloadTime = emptyMap()
|
||||
}
|
||||
|
||||
var liveSet: NoteLiveSet? = null
|
||||
|
||||
fun live(): NoteLiveSet {
|
||||
@@ -524,21 +643,94 @@ open class Note(val idHex: String) {
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
|
||||
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
|
||||
val thisEvent = event ?: return false
|
||||
|
||||
val isBoostedNoteHidden = if (thisEvent is GenericRepostEvent || thisEvent is RepostEvent || thisEvent is CommunityPostApprovalEvent) {
|
||||
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val isHiddenByWord = if (thisEvent is BaseTextNoteEvent) {
|
||||
accountChoices.hiddenWords.any {
|
||||
thisEvent.content.contains(it, true)
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val isSensitive = thisEvent.isSensitive()
|
||||
return isBoostedNoteHidden || isHiddenByWord ||
|
||||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
|
||||
accountChoices.spammers.contains(author?.pubkeyHex) ||
|
||||
(isSensitive && accountChoices.showSensitiveContent == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NoteLiveSet(u: Note) {
|
||||
// Observers line up here.
|
||||
val metadata: NoteLiveData = NoteLiveData(u)
|
||||
val innerMetadata = NoteBundledRefresherLiveData(u)
|
||||
val innerReactions = NoteBundledRefresherLiveData(u)
|
||||
val innerBoosts = NoteBundledRefresherLiveData(u)
|
||||
val innerReplies = NoteBundledRefresherLiveData(u)
|
||||
val innerReports = NoteBundledRefresherLiveData(u)
|
||||
val innerRelays = NoteBundledRefresherLiveData(u)
|
||||
val innerZaps = NoteBundledRefresherLiveData(u)
|
||||
|
||||
val reactions: NoteLiveData = NoteLiveData(u)
|
||||
val boosts: NoteLiveData = NoteLiveData(u)
|
||||
val replies: NoteLiveData = NoteLiveData(u)
|
||||
val reports: NoteLiveData = NoteLiveData(u)
|
||||
val relays: NoteLiveData = NoteLiveData(u)
|
||||
val zaps: NoteLiveData = NoteLiveData(u)
|
||||
val metadata = innerMetadata.map { it }
|
||||
val reactions = innerReactions.map { it }
|
||||
val boosts = innerBoosts.map { it }
|
||||
val replies = innerReplies.map { it }
|
||||
val reports = innerReports.map { it }
|
||||
val relays = innerRelays.map { it }
|
||||
val zaps = innerZaps.map { it }
|
||||
|
||||
val authorChanges = innerMetadata.map {
|
||||
it.note.author
|
||||
}
|
||||
|
||||
val hasEvent = innerMetadata.map {
|
||||
it.note.event != null
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val hasReactions = innerZaps.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
|
||||
zapState?.note?.zaps?.isNotEmpty() ?: false ||
|
||||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
|
||||
reactionState?.note?.reactions?.isNotEmpty() ?: false
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val replyCount = innerReplies.map {
|
||||
it.note.replies.size
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val reactionCount = innerReactions.map {
|
||||
it.note.reactions.values.sumOf { it.size }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val boostCount = innerBoosts.map {
|
||||
it.note.boosts.size
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val boostList = innerBoosts.map {
|
||||
it.note.boosts.toImmutableList()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val relayInfo = innerRelays.map {
|
||||
it.note.relays.map {
|
||||
RelayBriefInfo(it)
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
val content = innerMetadata.map {
|
||||
it.note.event?.content() ?: ""
|
||||
}
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return metadata.hasObservers() ||
|
||||
@@ -547,26 +739,58 @@ class NoteLiveSet(u: Note) {
|
||||
replies.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
zaps.hasObservers()
|
||||
zaps.hasObservers() ||
|
||||
authorChanges.hasObservers() ||
|
||||
hasEvent.hasObservers() ||
|
||||
hasReactions.hasObservers() ||
|
||||
replyCount.hasObservers() ||
|
||||
reactionCount.hasObservers() ||
|
||||
boostCount.hasObservers() ||
|
||||
boostList.hasObservers()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
innerMetadata.destroy()
|
||||
innerReactions.destroy()
|
||||
innerBoosts.destroy()
|
||||
innerReplies.destroy()
|
||||
innerReports.destroy()
|
||||
innerRelays.destroy()
|
||||
innerZaps.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
@Stable
|
||||
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
}
|
||||
|
||||
fun <Y> map(
|
||||
transform: (NoteState) -> Y
|
||||
): NoteLoadingLiveData<Y> {
|
||||
val initialValue = this.value?.let { transform(it) }
|
||||
val result = NoteLoadingLiveData(note, initialValue)
|
||||
result.addSource(this) { x -> result.value = transform(x) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
if (note is AddressableNote) {
|
||||
@@ -588,3 +812,10 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
|
||||
@Immutable
|
||||
class NoteState(val note: Note)
|
||||
|
||||
@Immutable
|
||||
data class RelayBriefInfo(
|
||||
val url: String,
|
||||
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
|
||||
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
|
||||
class ParticipantListBuilder {
|
||||
private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set<HexKey>?, set: MutableSet<User>) {
|
||||
baseNote.author?.let { author ->
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import java.lang.reflect.Type
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
|
||||
@Stable
|
||||
class RelayInformation(
|
||||
val id: String?,
|
||||
val name: String?,
|
||||
val description: String?,
|
||||
val pubkey: String?,
|
||||
@@ -27,113 +24,26 @@ class RelayInformation(
|
||||
val fees: RelayInformationFees?
|
||||
) {
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(RelayInformation::class.java, RelayInformationSerializer())
|
||||
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
|
||||
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
|
||||
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
|
||||
.create()
|
||||
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
fun fromJson(json: String): RelayInformation = gson.fromJson(json, RelayInformation::class.java)
|
||||
fun fromJson(json: String): RelayInformation = mapper.readValue(json, RelayInformation::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class RelayInformationFee(
|
||||
val amount: Int?,
|
||||
val unit: String?,
|
||||
val period: Int?,
|
||||
val kinds: List<Int>?
|
||||
) {
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String): RelayInformationFee = gson.fromJson(json, RelayInformationFee::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private class RelayInformationFeeSerializer : JsonSerializer<RelayInformationFee> {
|
||||
override fun serialize(
|
||||
src: RelayInformationFee,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
addProperty("amount", src.amount)
|
||||
addProperty("unit", src.unit)
|
||||
addProperty("period", src.period)
|
||||
add(
|
||||
"kinds",
|
||||
JsonArray().also { kinds ->
|
||||
src.kinds?.forEach { kind ->
|
||||
kinds.add(
|
||||
kind
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
class RelayInformationFees(
|
||||
val admission: List<RelayInformationFee>?,
|
||||
val subscription: List<RelayInformationFee>?,
|
||||
val publication: List<RelayInformationFee>?
|
||||
) {
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String): RelayInformationFees = gson.fromJson(json, RelayInformationFees::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private class RelayInformationFeesSerializer : JsonSerializer<RelayInformationFees> {
|
||||
override fun serialize(
|
||||
src: RelayInformationFees,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
add(
|
||||
"admission",
|
||||
JsonArray().also { admissions ->
|
||||
src.admission?.forEach { admission ->
|
||||
admissions.add(
|
||||
admission.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
add(
|
||||
"publication",
|
||||
JsonArray().also { publications ->
|
||||
src.publication?.forEach { publication ->
|
||||
publications.add(
|
||||
publication.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
add(
|
||||
"subscription",
|
||||
JsonArray().also { subscriptions ->
|
||||
src.subscription?.forEach { subscription ->
|
||||
subscriptions.add(
|
||||
subscription.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val publication: List<RelayInformationFee>?,
|
||||
val retention: List<RelayInformationFee>?
|
||||
)
|
||||
|
||||
class RelayInformationLimitation(
|
||||
val max_message_length: Int?,
|
||||
@@ -147,104 +57,4 @@ class RelayInformationLimitation(
|
||||
val min_pow_difficulty: Int?,
|
||||
val auth_required: Boolean?,
|
||||
val payment_required: Boolean?
|
||||
) {
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String): RelayInformationLimitation = gson.fromJson(json, RelayInformationLimitation::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private class RelayInformationLimitationSerializer : JsonSerializer<RelayInformationLimitation> {
|
||||
override fun serialize(
|
||||
src: RelayInformationLimitation,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
addProperty("max_message_length", src.max_message_length)
|
||||
addProperty("max_subscriptions", src.max_subscriptions)
|
||||
addProperty("max_filters", src.max_filters)
|
||||
addProperty("max_limit", src.max_limit)
|
||||
addProperty("max_subid_length", src.max_subid_length)
|
||||
addProperty("min_prefix", src.min_prefix)
|
||||
addProperty("max_event_tags", src.max_event_tags)
|
||||
addProperty("max_content_length", src.max_content_length)
|
||||
addProperty("min_pow_difficulty", src.min_pow_difficulty)
|
||||
addProperty("auth_required", src.auth_required)
|
||||
addProperty("payment_required", src.payment_required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class RelayInformationSerializer : JsonSerializer<RelayInformation> {
|
||||
override fun serialize(
|
||||
src: RelayInformation,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
addProperty("name", src.name)
|
||||
addProperty("description", src.description)
|
||||
addProperty("pubkey", src.pubkey)
|
||||
addProperty("contact", src.contact)
|
||||
add(
|
||||
"supported_nip_extensions",
|
||||
JsonArray().also { supported_nip_extensions ->
|
||||
src.supported_nip_extensions?.forEach { nip ->
|
||||
supported_nip_extensions.add(
|
||||
nip
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
add(
|
||||
"supported_nips",
|
||||
JsonArray().also { supported_nips ->
|
||||
src.supported_nips?.forEach { nip ->
|
||||
supported_nips.add(
|
||||
nip
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
addProperty("software", src.software)
|
||||
addProperty("version", src.version)
|
||||
add(
|
||||
"relay_countries",
|
||||
JsonArray().also { relay_countries ->
|
||||
src.relay_countries?.forEach { country ->
|
||||
relay_countries.add(
|
||||
country
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
add(
|
||||
"language_tags",
|
||||
JsonArray().also { language_tags ->
|
||||
src.language_tags?.forEach { language_tag ->
|
||||
language_tags.add(
|
||||
language_tag
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
add(
|
||||
"tags",
|
||||
JsonArray().also { tags ->
|
||||
src.tags?.forEach { tag ->
|
||||
tags.add(
|
||||
tag
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
addProperty("posting_policy", src.posting_policy)
|
||||
addProperty("payments_url", src.payments_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,4 +14,6 @@ data class RelaySetupInfo(
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>,
|
||||
val paidRelay: Boolean = false
|
||||
)
|
||||
) {
|
||||
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
enum class ServersAvailable {
|
||||
// IMGUR,
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
NOSTRFILES_DEV,
|
||||
NOSTRCHECK_ME,
|
||||
|
||||
// IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NOSTR_BUILD_NIP_94,
|
||||
NOSTRFILES_DEV_NIP_94,
|
||||
NOSTRCHECK_ME_NIP_94,
|
||||
NIP95
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@Stable
|
||||
data class Settings(
|
||||
val theme: ThemeType = ThemeType.SYSTEM,
|
||||
val preferredLanguage: String? = null,
|
||||
val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS,
|
||||
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val dontShowPushNotificationSelector: Boolean = false,
|
||||
val dontAskForNotificationPermissions: Boolean = false
|
||||
)
|
||||
|
||||
enum class ThemeType(val screenCode: Int, val resourceId: Int) {
|
||||
SYSTEM(0, R.string.system),
|
||||
LIGHT(1, R.string.light),
|
||||
DARK(2, R.string.dark)
|
||||
}
|
||||
|
||||
fun parseThemeType(code: Int?): ThemeType {
|
||||
return when (code) {
|
||||
ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM
|
||||
ThemeType.LIGHT.screenCode -> ThemeType.LIGHT
|
||||
ThemeType.DARK.screenCode -> ThemeType.DARK
|
||||
else -> {
|
||||
ThemeType.SYSTEM
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val resourceId: Int) {
|
||||
ALWAYS(null, 0, R.string.connectivity_type_always),
|
||||
WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only),
|
||||
NEVER(false, 2, R.string.connectivity_type_never)
|
||||
}
|
||||
|
||||
fun parseConnectivityType(code: Boolean?): ConnectivityType {
|
||||
return when (code) {
|
||||
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
|
||||
ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY
|
||||
ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER
|
||||
else -> {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseConnectivityType(screenCode: Int): ConnectivityType {
|
||||
return when (screenCode) {
|
||||
ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS
|
||||
ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY
|
||||
ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER
|
||||
else -> {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
|
||||
ALWAYS(null, 0, R.string.connectivity_type_always),
|
||||
NEVER(false, 1, R.string.connectivity_type_never)
|
||||
}
|
||||
|
||||
fun parseBooleanType(code: Boolean?): BooleanType {
|
||||
return when (code) {
|
||||
BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS
|
||||
BooleanType.NEVER.prefCode -> BooleanType.NEVER
|
||||
else -> {
|
||||
BooleanType.ALWAYS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseBooleanType(screenCode: Int): BooleanType {
|
||||
return when (screenCode) {
|
||||
BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS
|
||||
BooleanType.NEVER.screenCode -> BooleanType.NEVER
|
||||
else -> {
|
||||
BooleanType.ALWAYS
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import kotlin.time.ExperimentalTime
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class ThreadAssembler {
|
||||
@@ -16,9 +15,14 @@ class ThreadAssembler {
|
||||
testedNotes.add(note)
|
||||
|
||||
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
|
||||
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
if (markedAsRoot != null) {
|
||||
// Check to ssee if there is an error in the tag and the root has replies
|
||||
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) {
|
||||
return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
}
|
||||
}
|
||||
|
||||
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
|
||||
// recursive
|
||||
@@ -37,7 +41,6 @@ class ThreadAssembler {
|
||||
return null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun findThreadFor(noteId: String): Set<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
object TimeUtils {
|
||||
const val fiveMinutes = 60 * 5
|
||||
const val oneHour = 60 * 60
|
||||
const val oneDay = 24 * 60 * 60
|
||||
|
||||
fun now() = System.currentTimeMillis() / 1000
|
||||
fun fiveMinutesAgo() = now() - fiveMinutes
|
||||
fun oneHourAgo() = now() - oneHour
|
||||
fun oneDayAgo() = now() - oneDay
|
||||
fun eightHoursAgo() = now() - (oneHour * 8)
|
||||
}
|
||||
@@ -2,33 +2,35 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
|
||||
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Stable
|
||||
object UrlCachedPreviewer {
|
||||
var cache = LruCache<String, UrlPreviewState>(100)
|
||||
private set
|
||||
|
||||
fun previewInfo(url: String, onReady: (UrlPreviewState) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
suspend fun previewInfo(
|
||||
url: String,
|
||||
onReady: suspend (UrlPreviewState) -> Unit
|
||||
) = withContext(Dispatchers.IO) {
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
|
||||
BahaUrlPreview(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
override suspend fun onComplete(urlInfo: UrlInfoItem) = withContext(Dispatchers.IO) {
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +44,10 @@ object UrlCachedPreviewer {
|
||||
onReady(state)
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
override suspend fun onFailed(throwable: Throwable) = withContext(Dispatchers.IO) {
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
|
||||
@@ -3,27 +3,29 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.toNpub
|
||||
import java.math.BigDecimal
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
|
||||
|
||||
@Stable
|
||||
class User(val pubkeyHex: String) {
|
||||
@@ -32,9 +34,6 @@ class User(val pubkeyHex: String) {
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestBookmarkList: BookmarkListEvent? = null
|
||||
|
||||
var notes = setOf<Note>()
|
||||
private set
|
||||
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
|
||||
@@ -46,18 +45,33 @@ class User(val pubkeyHex: String) {
|
||||
var relaysBeingUsed = mapOf<String, RelayInfo>()
|
||||
private set
|
||||
|
||||
var privateChatrooms = mapOf<User, Chatroom>()
|
||||
var privateChatrooms = mapOf<ChatroomKey, Chatroom>()
|
||||
private set
|
||||
|
||||
var acceptedBadges: AddressableNote? = null
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
fun toNostrUri() = "nostr:${pubkeyNpub()}"
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
|
||||
fun toBestShortFirstName(): String {
|
||||
val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex()
|
||||
|
||||
val names = fullName.split(' ')
|
||||
|
||||
val firstName = if (names[0].length <= 3) {
|
||||
// too short. Remove Dr.
|
||||
"${names[0]} ${names.getOrNull(1) ?: ""}"
|
||||
} else {
|
||||
names[0]
|
||||
}
|
||||
|
||||
return firstName
|
||||
}
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
|
||||
}
|
||||
@@ -83,7 +97,11 @@ class User(val pubkeyHex: String) {
|
||||
if (event.id == latestBookmarkList?.id) return
|
||||
|
||||
latestBookmarkList = event
|
||||
liveSet?.bookmarks?.invalidateData()
|
||||
liveSet?.innerBookmarks?.invalidateData()
|
||||
}
|
||||
|
||||
fun clearEOSE() {
|
||||
latestEOSEs = emptyMap()
|
||||
}
|
||||
|
||||
fun updateContactList(event: ContactListEvent) {
|
||||
@@ -93,33 +111,18 @@ class User(val pubkeyHex: String) {
|
||||
latestContactList = event
|
||||
|
||||
// Update following of the current user
|
||||
liveSet?.follows?.invalidateData()
|
||||
liveSet?.innerFollows?.invalidateData()
|
||||
|
||||
// Update Followers of the past user list
|
||||
// Update Followers of the new contact list
|
||||
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
|
||||
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
|
||||
}
|
||||
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
|
||||
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
|
||||
}
|
||||
|
||||
liveSet?.relays?.invalidateData()
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
if (note !in notes) {
|
||||
notes = notes + note
|
||||
// No need for Listener yet
|
||||
}
|
||||
}
|
||||
|
||||
fun removeNote(note: Note) {
|
||||
notes = notes - note
|
||||
}
|
||||
|
||||
fun clearNotes() {
|
||||
notes = setOf<Note>()
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
@@ -127,10 +130,10 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
if (author !in reports.keys) {
|
||||
reports = reports + Pair(author, setOf(note))
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
} else if (reports[author]?.contains(note) == false) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,25 +143,28 @@ class User(val pubkeyHex: String) {
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.reports?.invalidateData()
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAcceptedBadges(note: AddressableNote) {
|
||||
if (acceptedBadges?.idHex != note.idHex) {
|
||||
acceptedBadges = note
|
||||
liveSet?.badges?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZap(zapRequestOrZapEvent: Note) {
|
||||
if (zapRequestOrZapEvent in zaps.keys) {
|
||||
zaps = zaps.minus(zapRequestOrZapEvent)
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zapRequestOrZapEvent in zaps.values) {
|
||||
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,35 +195,52 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
|
||||
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
|
||||
checkNotInMainThread()
|
||||
|
||||
return privateChatrooms[user] ?: run {
|
||||
return privateChatrooms[key] ?: run {
|
||||
val privateChatroom = Chatroom()
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
|
||||
privateChatroom
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
|
||||
val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
|
||||
return getOrCreatePrivateChatroom(key)
|
||||
}
|
||||
|
||||
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom {
|
||||
return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
|
||||
}
|
||||
|
||||
fun addMessage(room: ChatroomKey, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(room)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun createChatroom(withKey: ChatroomKey) {
|
||||
getOrCreatePrivateChatroom(withKey)
|
||||
}
|
||||
|
||||
fun removeMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.removeMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +255,7 @@ class User(val pubkeyHex: String) {
|
||||
here.counter++
|
||||
}
|
||||
|
||||
liveSet?.relayInfo?.invalidateData()
|
||||
liveSet?.innerRelayInfo?.invalidateData()
|
||||
}
|
||||
|
||||
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
|
||||
@@ -241,23 +264,15 @@ class User(val pubkeyHex: String) {
|
||||
info?.updatedMetadataAt = latestMetadata.createdAt
|
||||
info?.tags = latestMetadata.tags.toImmutableListOfLists()
|
||||
|
||||
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
|
||||
try {
|
||||
val url = String(Bech32.decodeBytes(newUserInfo.lud06!!, false).second)
|
||||
|
||||
val matcher = lnurlpPattern.matcher(url)
|
||||
while (matcher.find()) {
|
||||
val domain = matcher.group(2)
|
||||
val username = matcher.group(3)
|
||||
|
||||
info?.lud16 = "$username@$domain"
|
||||
if (newUserInfo.lud16.isNullOrBlank()) {
|
||||
info?.lud06?.let {
|
||||
if (it.lowercase().startsWith("lnurl")) {
|
||||
info?.lud16 = Lud06().toLud16(it)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
// Doesn't create errors.
|
||||
}
|
||||
}
|
||||
|
||||
liveSet?.metadata?.invalidateData()
|
||||
liveSet?.innerMetadata?.invalidateData()
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean {
|
||||
@@ -274,6 +289,12 @@ class User(val pubkeyHex: String) {
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingGeohashCached(geoTag: String): Boolean {
|
||||
return latestContactList?.verifiedFollowGeohashSet?.let {
|
||||
return geoTag.lowercase() in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingCached(user: User): Boolean {
|
||||
return latestContactList?.verifiedFollowKeySet?.let {
|
||||
return user.pubkeyHex in it
|
||||
@@ -298,10 +319,18 @@ class User(val pubkeyHex: String) {
|
||||
return latestContactList?.verifiedFollowKeySet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingTagSet(): Set<HexKey> {
|
||||
fun cachedFollowingTagSet(): Set<String> {
|
||||
return latestContactList?.verifiedFollowTagSet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingGeohashSet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowGeohashSet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingCommunitiesSet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowCommunitySet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowCount(): Int? {
|
||||
return latestContactList?.verifiedFollowKeySet?.size
|
||||
}
|
||||
@@ -310,8 +339,8 @@ class User(val pubkeyHex: String) {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(user: User?): Boolean {
|
||||
val messagesToUser = privateChatrooms[user] ?: return false
|
||||
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
|
||||
val messagesToUser = privateChatrooms[key] ?: return false
|
||||
|
||||
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
|
||||
}
|
||||
@@ -337,35 +366,78 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserLiveSet(u: User) {
|
||||
val innerMetadata = UserBundledRefresherLiveData(u)
|
||||
|
||||
// UI Observers line up here.
|
||||
val follows: UserLiveData = UserLiveData(u)
|
||||
val followers: UserLiveData = UserLiveData(u)
|
||||
val reports: UserLiveData = UserLiveData(u)
|
||||
val messages: UserLiveData = UserLiveData(u)
|
||||
val relays: UserLiveData = UserLiveData(u)
|
||||
val relayInfo: UserLiveData = UserLiveData(u)
|
||||
val metadata: UserLiveData = UserLiveData(u)
|
||||
val zaps: UserLiveData = UserLiveData(u)
|
||||
val badges: UserLiveData = UserLiveData(u)
|
||||
val bookmarks: UserLiveData = UserLiveData(u)
|
||||
val innerFollows = UserBundledRefresherLiveData(u)
|
||||
val innerFollowers = UserBundledRefresherLiveData(u)
|
||||
val innerReports = UserBundledRefresherLiveData(u)
|
||||
val innerMessages = UserBundledRefresherLiveData(u)
|
||||
val innerRelays = UserBundledRefresherLiveData(u)
|
||||
val innerRelayInfo = UserBundledRefresherLiveData(u)
|
||||
val innerZaps = UserBundledRefresherLiveData(u)
|
||||
val innerBookmarks = UserBundledRefresherLiveData(u)
|
||||
val innerStatuses = UserBundledRefresherLiveData(u)
|
||||
|
||||
// UI Observers line up here.
|
||||
val metadata = innerMetadata.map { it }
|
||||
val follows = innerFollows.map { it }
|
||||
val followers = innerFollowers.map { it }
|
||||
val reports = innerReports.map { it }
|
||||
val messages = innerMessages.map { it }
|
||||
val relays = innerRelays.map { it }
|
||||
val relayInfo = innerRelayInfo.map { it }
|
||||
val zaps = innerZaps.map { it }
|
||||
val bookmarks = innerBookmarks.map { it }
|
||||
val statuses = innerStatuses.map { it }
|
||||
|
||||
val profilePictureChanges = innerMetadata.map {
|
||||
it.user.profilePicture()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val nip05Changes = innerMetadata.map {
|
||||
it.user.nip05()
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val userMetadataInfo = innerMetadata.map {
|
||||
it.user.info
|
||||
}.distinctUntilChanged()
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return follows.hasObservers() ||
|
||||
return metadata.hasObservers() ||
|
||||
follows.hasObservers() ||
|
||||
followers.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
messages.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
relayInfo.hasObservers() ||
|
||||
metadata.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
badges.hasObservers() ||
|
||||
bookmarks.hasObservers()
|
||||
bookmarks.hasObservers() ||
|
||||
statuses.hasObservers() ||
|
||||
profilePictureChanges.hasObservers() ||
|
||||
nip05Changes.hasObservers() ||
|
||||
userMetadataInfo.hasObservers()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
innerMetadata.destroy()
|
||||
innerFollows.destroy()
|
||||
innerFollowers.destroy()
|
||||
innerReports.destroy()
|
||||
innerMessages.destroy()
|
||||
innerRelays.destroy()
|
||||
innerRelayInfo.destroy()
|
||||
innerZaps.destroy()
|
||||
innerBookmarks.destroy()
|
||||
innerStatuses.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,103 +448,35 @@ data class RelayInfo(
|
||||
var counter: Long
|
||||
)
|
||||
|
||||
class Chatroom() {
|
||||
var roomMessages: Set<Note> = setOf()
|
||||
|
||||
@Synchronized
|
||||
fun addMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserMetadata {
|
||||
var name: String? = null
|
||||
var username: String? = null
|
||||
var display_name: String? = null
|
||||
var displayName: String? = null
|
||||
var picture: String? = null
|
||||
var banner: String? = null
|
||||
var website: String? = null
|
||||
var about: String? = null
|
||||
|
||||
var nip05: String? = null
|
||||
var nip05Verified: Boolean = false
|
||||
var nip05LastVerificationTime: Long? = 0
|
||||
|
||||
var domain: String? = null
|
||||
var lud06: String? = null
|
||||
var lud16: String? = null
|
||||
|
||||
var publish: String? = null
|
||||
var iris: String? = null
|
||||
var main_relay: String? = null
|
||||
var twitter: String? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
var tags: ImmutableListOfLists<String>? = null
|
||||
|
||||
fun anyName(): String? {
|
||||
return display_name ?: displayName ?: name ?: username
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
|
||||
.any { it.contains(prefix, true) }
|
||||
}
|
||||
|
||||
fun lnAddress(): String? {
|
||||
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun bestUsername(): String? {
|
||||
return name?.ifBlank { null } ?: username?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun bestDisplayName(): String? {
|
||||
return displayName?.ifBlank { null } ?: display_name?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun nip05(): String? {
|
||||
return nip05?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
if (picture.isNullOrBlank()) picture = null
|
||||
return picture
|
||||
}
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
postValue(UserState(user))
|
||||
}
|
||||
}
|
||||
|
||||
fun <Y> map(
|
||||
transform: (UserState) -> Y
|
||||
): UserLoadingLiveData<Y> {
|
||||
val initialValue = this.value?.let { transform(it) }
|
||||
val result = UserLoadingLiveData(user, initialValue)
|
||||
result.addSource(this) { x -> result.value = transform(x) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleUserDataSource.add(user)
|
||||
|
||||
@@ -6,15 +6,14 @@ import android.util.Patterns
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.amethyst.ui.components.tagIndex
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
@@ -30,9 +29,11 @@ data class RichTextViewerState(
|
||||
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
|
||||
val imageList: ImmutableList<ZoomableUrlContent>,
|
||||
val customEmoji: ImmutableMap<String, String>,
|
||||
val paragraphs: ImmutableList<ImmutableList<Segment>>
|
||||
val paragraphs: ImmutableList<ParagraphState>
|
||||
)
|
||||
|
||||
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
|
||||
|
||||
object CachedRichTextParser {
|
||||
val richTextCache = LruCache<String, RichTextViewerState>(200)
|
||||
|
||||
@@ -52,11 +53,13 @@ object CachedRichTextParser {
|
||||
|
||||
// Android9 seems to have an issue starting this regex.
|
||||
val noProtocolUrlValidator = try {
|
||||
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]*[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
|
||||
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
|
||||
} catch (e: Exception) {
|
||||
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
}
|
||||
|
||||
val HTTPRegex = "^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
class RichTextParser() {
|
||||
fun parseText(
|
||||
content: String,
|
||||
@@ -73,8 +76,7 @@ class RichTextParser() {
|
||||
} else if (it.originalUrl.contains("。")) {
|
||||
null
|
||||
} else {
|
||||
val pattern = "^((http|https)://)?([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
|
||||
if (pattern.matches(it.originalUrl)) {
|
||||
if (HTTPRegex.matches(it.originalUrl)) {
|
||||
it.originalUrl
|
||||
} else {
|
||||
null
|
||||
@@ -108,14 +110,16 @@ class RichTextParser() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ImmutableList<Segment>> {
|
||||
var paragraphSegments = persistentListOf<ImmutableList<Segment>>()
|
||||
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
|
||||
var paragraphSegments = persistentListOf<ParagraphState>()
|
||||
|
||||
content.split('\n').forEach { paragraph ->
|
||||
var segments = persistentListOf<Segment>()
|
||||
var isDirty = false
|
||||
|
||||
val wordList = paragraph.split(' ')
|
||||
val isRTL = isArabic(paragraph)
|
||||
|
||||
val wordList = paragraph.trimEnd().split(' ')
|
||||
wordList.forEach { word ->
|
||||
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
|
||||
if (wordSegment !is RegularTextSegment) {
|
||||
@@ -125,13 +129,9 @@ class RichTextParser() {
|
||||
}
|
||||
|
||||
val newSegments = if (isDirty) {
|
||||
if (isArabic(paragraph)) {
|
||||
segments.asReversed().toImmutableList()
|
||||
} else {
|
||||
segments
|
||||
}
|
||||
ParagraphState(segments, isRTL)
|
||||
} else {
|
||||
persistentListOf<Segment>(RegularTextSegment(paragraph))
|
||||
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
|
||||
}
|
||||
|
||||
paragraphSegments = paragraphSegments.add(newSegments)
|
||||
@@ -179,11 +179,11 @@ class RichTextParser() {
|
||||
BechSegment(word)
|
||||
} else if (word.startsWith("#")) {
|
||||
parseHash(word, tags)
|
||||
} else if (schemelessMatcher.find()) {
|
||||
} else if (word.contains(".") && schemelessMatcher.find()) {
|
||||
val url = schemelessMatcher.group(1) // url
|
||||
val additionalChars = schemelessMatcher.group(4) // additional chars
|
||||
val pattern = "^([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
|
||||
if (pattern.matches(word)) {
|
||||
val pattern = """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""".toRegex(RegexOption.IGNORE_CASE)
|
||||
if (pattern.find(word) != null) {
|
||||
SchemelessUrlSegment(word, url, additionalChars)
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
@@ -289,3 +289,9 @@ class SchemelessUrlSegment(segment: String, val url: String, val extras: String?
|
||||
|
||||
@Immutable
|
||||
class RegularTextSegment(segment: String) : Segment(segment)
|
||||
|
||||
fun startsWithNIP19Scheme(word: String): Boolean {
|
||||
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
|
||||
|
||||
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -19,7 +20,7 @@ data class CashuToken(
|
||||
val totalAmount: Long,
|
||||
val fees: Int,
|
||||
val redeemInvoiceAmount: Long,
|
||||
val proofs: JsonArray
|
||||
val proofs: JsonNode
|
||||
)
|
||||
|
||||
class CashuProcessor {
|
||||
@@ -28,14 +29,14 @@ class CashuProcessor {
|
||||
|
||||
try {
|
||||
val base64token = cashuToken.replace("cashuA", "")
|
||||
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
|
||||
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
|
||||
val proofs = token["proofs"].asJsonArray
|
||||
val mint = token["mint"].asString
|
||||
val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token)))
|
||||
val token = cashu.get("token").get(0)
|
||||
val proofs = token.get("proofs")
|
||||
val mint = token.get("mint").asText()
|
||||
|
||||
var totalAmount = 0L
|
||||
for (proof in proofs) {
|
||||
totalAmount += proof.asJsonObject["amount"].asLong
|
||||
totalAmount += proof.get("amount").asLong()
|
||||
}
|
||||
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
|
||||
val redeemInvoiceAmount = totalAmount - fees
|
||||
@@ -46,7 +47,7 @@ class CashuProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
|
||||
checkNotInMainThread()
|
||||
|
||||
runCatching {
|
||||
@@ -55,23 +56,26 @@ class CashuProcessor {
|
||||
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
|
||||
message = "Redeem Cashu",
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, onSuccess, onError)
|
||||
meltInvoice(token, invoice, onSuccess, onError, context)
|
||||
},
|
||||
onProgress = {
|
||||
},
|
||||
onError = onError
|
||||
onError = onError,
|
||||
context = context
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) {
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
|
||||
val jsonObject = JsonObject()
|
||||
jsonObject.add("proofs", token.proofs)
|
||||
jsonObject.addProperty("pr", invoice)
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
val jsonObject = factory.objectNode()
|
||||
jsonObject.put("proofs", token.proofs)
|
||||
jsonObject.put("pr", invoice)
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||
@@ -80,19 +84,31 @@ class CashuProcessor {
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val body = response.body.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
client.newCall(request).execute().use {
|
||||
val body = it.body.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
|
||||
val successful = tree?.get("paid")?.asText() == "true"
|
||||
val successful = tree?.get("paid")?.asText() == "true"
|
||||
|
||||
if (successful) {
|
||||
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
|
||||
} else {
|
||||
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Cashu: Tokens already spent.")
|
||||
if (successful) {
|
||||
onSuccess(
|
||||
context.getString(R.string.cashu_sucessful_redemption),
|
||||
context.getString(R.string.cashu_sucessful_redemption_explainer, token.totalAmount.toString(), token.fees.toString())
|
||||
)
|
||||
} else {
|
||||
val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null }
|
||||
onError(
|
||||
context.getString(R.string.cashu_failed_redemption),
|
||||
if (msg != null) {
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg)
|
||||
} else {
|
||||
context.getString(R.string.cashu_failed_redemption_explainer_error_msg)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Token melt failure: " + e.message)
|
||||
onError(context.getString(R.string.cashu_sucessful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
fun String.isUTF16Char(pos: Int): Boolean {
|
||||
println("Test $pos ${Character.charCount(this.codePointAt(pos))}")
|
||||
return Character.charCount(this.codePointAt(pos)) == 2
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ fun String.firstFullCharOld(): String {
|
||||
|
||||
fun String.firstFullChar(): String {
|
||||
var isInJoin = false
|
||||
var hasHadSecondChance = false
|
||||
var start = 0
|
||||
var previousCharLength = 0
|
||||
var next: Int
|
||||
@@ -46,8 +48,14 @@ fun String.firstFullChar(): String {
|
||||
isInJoin = true
|
||||
} else {
|
||||
// stops when two chars are not joined together
|
||||
if ((previousCharLength > 0) && (!isInJoin) && Character.charCount(codePoint) == 1) {
|
||||
break
|
||||
if (previousCharLength > 0 && !isInJoin) {
|
||||
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
|
||||
break
|
||||
} else {
|
||||
hasHadSecondChance = true
|
||||
}
|
||||
} else {
|
||||
hasHadSecondChance = false
|
||||
}
|
||||
|
||||
isInJoin = false
|
||||
@@ -77,3 +85,23 @@ fun String.firstFullChar(): String {
|
||||
|
||||
return substring(start, start + previousCharLength)
|
||||
}
|
||||
|
||||
fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
|
||||
if (length <= 2) {
|
||||
return firstFullChar()
|
||||
}
|
||||
|
||||
if (this[0] == ':') {
|
||||
// makes sure an emoji exists
|
||||
val emojiParts = this.split(":", limit = 3)
|
||||
if (emojiParts.size >= 2) {
|
||||
val emojiName = emojiParts[1]
|
||||
val emojiUrl = tags.lists.firstOrNull() { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
|
||||
if (emojiUrl != null) {
|
||||
return ":$emojiName:$emojiUrl"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstFullChar()
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class SignerType {
|
||||
SIGN_EVENT,
|
||||
NIP04_ENCRYPT,
|
||||
NIP04_DECRYPT,
|
||||
NIP44_ENCRYPT,
|
||||
NIP44_DECRYPT,
|
||||
GET_PUBLIC_KEY,
|
||||
DECRYPT_ZAP_EVENT
|
||||
}
|
||||
|
||||
object ExternalSignerUtils {
|
||||
val content = LruCache<String, String>(10)
|
||||
var isActivityRunning: Boolean = false
|
||||
val cachedDecryptedContent = mutableMapOf<HexKey, String>()
|
||||
lateinit var account: Account
|
||||
private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var decryptResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var blockListResultLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun requestRejectedToast() {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
Amethyst.instance,
|
||||
Amethyst.instance.getString(R.string.sign_request_rejected),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun default() {
|
||||
isActivityRunning = false
|
||||
ServiceManager.shouldPauseService = true
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
isActivityRunning = false
|
||||
ServiceManager.shouldPauseService = true
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun start(activity: MainActivity) {
|
||||
activityResultLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode != Activity.RESULT_OK) {
|
||||
requestRejectedToast()
|
||||
} else {
|
||||
val event = it.data?.getStringExtra("signature") ?: ""
|
||||
val id = it.data?.getStringExtra("id") ?: ""
|
||||
if (id.isNotBlank()) {
|
||||
content.put(id, event)
|
||||
}
|
||||
}
|
||||
default()
|
||||
}
|
||||
|
||||
decryptResultLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode != Activity.RESULT_OK) {
|
||||
requestRejectedToast()
|
||||
} else {
|
||||
val event = it.data?.getStringExtra("signature") ?: ""
|
||||
val id = it.data?.getStringExtra("id") ?: ""
|
||||
if (id.isNotBlank()) {
|
||||
content.put(id, event)
|
||||
cachedDecryptedContent[id] = event
|
||||
}
|
||||
}
|
||||
default()
|
||||
}
|
||||
|
||||
blockListResultLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode != Activity.RESULT_OK) {
|
||||
requestRejectedToast()
|
||||
} else {
|
||||
val decryptedContent = it.data?.getStringExtra("signature") ?: ""
|
||||
val id = it.data?.getStringExtra("id") ?: ""
|
||||
if (id.isNotBlank()) {
|
||||
cachedDecryptedContent[id] = decryptedContent
|
||||
account.live.invalidateData()
|
||||
}
|
||||
}
|
||||
default()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun openSigner(
|
||||
data: String,
|
||||
type: SignerType,
|
||||
intentResult: ActivityResultLauncher<Intent>,
|
||||
pubKey: HexKey,
|
||||
id: String
|
||||
) {
|
||||
try {
|
||||
ServiceManager.shouldPauseService = false
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data"))
|
||||
val signerType = when (type) {
|
||||
SignerType.SIGN_EVENT -> "sign_event"
|
||||
SignerType.NIP04_ENCRYPT -> "nip04_encrypt"
|
||||
SignerType.NIP04_DECRYPT -> "nip04_decrypt"
|
||||
SignerType.NIP44_ENCRYPT -> "nip44_encrypt"
|
||||
SignerType.NIP44_DECRYPT -> "nip44_decrypt"
|
||||
SignerType.GET_PUBLIC_KEY -> "get_public_key"
|
||||
SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event"
|
||||
}
|
||||
intent.putExtra("type", signerType)
|
||||
intent.putExtra("pubKey", pubKey)
|
||||
intent.putExtra("id", id)
|
||||
if (type !== SignerType.GET_PUBLIC_KEY) {
|
||||
intent.putExtra("current_user", account.keyPair.pubKey.toNpub())
|
||||
}
|
||||
intent.`package` = "com.greenart7c3.nostrsigner"
|
||||
intentResult.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Signer", "Error opening Signer app", e)
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
Amethyst.instance,
|
||||
Amethyst.instance.getString(R.string.error_opening_external_signer),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openSigner(event: EventInterface, columnName: String = "signature") {
|
||||
checkNotInMainThread()
|
||||
|
||||
val result = getDataFromResolver(SignerType.SIGN_EVENT, arrayOf(event.toJson(), event.pubKey()), columnName)
|
||||
if (result == null) {
|
||||
ServiceManager.shouldPauseService = false
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
event.toJson(),
|
||||
SignerType.SIGN_EVENT,
|
||||
activityResultLauncher,
|
||||
"",
|
||||
event.id()
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
} else {
|
||||
content.put(event.id(), result)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptBlockList(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result == null) {
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
blockListResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
}
|
||||
}
|
||||
|
||||
fun getDataFromResolver(signerType: SignerType, data: Array<out String>, columnName: String = "signature"): String? {
|
||||
val localData = if (signerType !== SignerType.GET_PUBLIC_KEY) {
|
||||
data.toList().plus(account.keyPair.pubKey.toNpub()).toTypedArray()
|
||||
} else {
|
||||
data
|
||||
}
|
||||
|
||||
Amethyst.instance.contentResolver.query(
|
||||
Uri.parse("content://com.greenart7c3.nostrsigner.$signerType"),
|
||||
localData,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).use {
|
||||
if (it == null) {
|
||||
return null
|
||||
}
|
||||
if (it.moveToFirst()) {
|
||||
val index = it.getColumnIndex(columnName)
|
||||
if (index < 0) {
|
||||
Log.d("getDataFromResolver", "column '$columnName' not found")
|
||||
return null
|
||||
}
|
||||
return it.getString(index)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun decrypt(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result == null) {
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
// do nothing
|
||||
}
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptDM(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result == null) {
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptBookmark(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result == null) {
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
}
|
||||
}
|
||||
|
||||
fun encrypt(decryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_ENCRYPT) {
|
||||
content.remove(id)
|
||||
cachedDecryptedContent.remove(id)
|
||||
val result = getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey))
|
||||
if (result == null) {
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
decryptedContent,
|
||||
signerType,
|
||||
activityResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
} else {
|
||||
content.put(id, result)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptZapEvent(event: LnZapRequestEvent) {
|
||||
val result = getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey))
|
||||
if (result == null) {
|
||||
openSigner(
|
||||
event.toJson(),
|
||||
SignerType.DECRYPT_ZAP_EVENT,
|
||||
decryptResultLauncher,
|
||||
event.pubKey,
|
||||
event.id
|
||||
)
|
||||
} else {
|
||||
content.put(event.id, result)
|
||||
cachedDecryptedContent[event.id] = result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package com.vitorpamplona.amethyst.service
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FileHeader(
|
||||
@@ -16,16 +16,16 @@ class FileHeader(
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
val description: String? = null,
|
||||
val alt: String? = null,
|
||||
val sensitiveContent: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, alt: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
|
||||
if (imageData != null) {
|
||||
prepare(imageData, fileUrl, mimeType, description, sensitiveContent, onReady, onError)
|
||||
prepare(imageData, fileUrl, mimeType, alt, sensitiveContent, onReady, onError)
|
||||
} else {
|
||||
onError()
|
||||
}
|
||||
@@ -39,15 +39,13 @@ class FileHeader(
|
||||
data: ByteArray,
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
description: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
try {
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
val hash = sha256.digest(data).toHexKey()
|
||||
val hash = CryptoUtils.sha256(data).toHexKey()
|
||||
val size = data.size
|
||||
|
||||
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
|
||||
@@ -81,7 +79,7 @@ class FileHeader(
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description, sensitiveContent))
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, alt, sensitiveContent))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
object HexValidator {
|
||||
|
||||
private fun isHex2(c: Char): Boolean {
|
||||
return when (c) {
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun isHex(hex: String?): Boolean {
|
||||
if (hex == null) return false
|
||||
var isHex = true
|
||||
for (c in hex.toCharArray()) {
|
||||
if (!isHex2(c)) {
|
||||
isHex = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return isHex
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,29 @@ import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
object HttpClient {
|
||||
private var proxy: Proxy? = null
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue ->
|
||||
if (oldValue != newValue) {
|
||||
proxyChangeListeners.forEach {
|
||||
it()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start(account: Account?) {
|
||||
this.proxy = account?.proxy
|
||||
this.internalProxy = account?.proxy
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
val seconds = if (proxy != null) 20L else 10L
|
||||
val seconds = if (internalProxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.proxy(internalProxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
@@ -25,7 +35,7 @@ object HttpClient {
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
return proxy
|
||||
return internalProxy
|
||||
}
|
||||
|
||||
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.HandlerThread
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class LocationUtil(context: Context) {
|
||||
companion object {
|
||||
const val MIN_TIME: Long = 1000L
|
||||
const val MIN_DISTANCE: Float = 0.0f
|
||||
}
|
||||
|
||||
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private var locationListener: LocationListener? = null
|
||||
|
||||
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
|
||||
val providerState = mutableStateOf(false)
|
||||
val isStart: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
private val locHandlerThread = HandlerThread("LocationUtil Thread")
|
||||
|
||||
init {
|
||||
locHandlerThread.start()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(minTimeMs: Long = MIN_TIME, minDistanceM: Float = MIN_DISTANCE) {
|
||||
locationListener().let {
|
||||
locationListener = it
|
||||
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, minTimeMs, minDistanceM, it, locHandlerThread.looper)
|
||||
}
|
||||
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
isStart.value = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
locationListener?.let {
|
||||
locationManager.removeUpdates(it)
|
||||
}
|
||||
isStart.value = false
|
||||
}
|
||||
|
||||
private fun locationListener() = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
locationStateFlow.value = location
|
||||
}
|
||||
|
||||
override fun onProviderEnabled(provider: String) {
|
||||
providerState.value = true
|
||||
}
|
||||
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
providerState.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReverseGeoLocationUtil {
|
||||
suspend fun execute(
|
||||
location: Location,
|
||||
context: Context
|
||||
): String? {
|
||||
return try {
|
||||
Geocoder(context).getFromLocation(location.latitude, location.longitude, 1)?.firstOrNull()?.let { address ->
|
||||
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode).joinToString(", ")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
@@ -26,55 +23,46 @@ class Nip05Verifier() {
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun fetchNip05Json(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
|
||||
return
|
||||
return@withContext
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
|
||||
}
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
} catch (e: java.lang.Exception) {
|
||||
onError("Could not resolve '$url': ${e.message}")
|
||||
}
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server is up and if the address $nip05 is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
} catch (e: java.lang.Exception) {
|
||||
onError("Could not resolve '$url': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// check fails on tests
|
||||
checkNotInMainThread()
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
object Nip11CachedRetriever {
|
||||
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100)
|
||||
val retriever = Nip11Retriever()
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
dirtyUrl: String,
|
||||
onInfo: (RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit
|
||||
) {
|
||||
val url = retriever.cleanUrl(dirtyUrl)
|
||||
val doc = relayInformationDocumentCache.get(url)
|
||||
|
||||
if (doc != null) {
|
||||
onInfo(doc)
|
||||
} else {
|
||||
Nip11Retriever().loadRelayInfo(
|
||||
url,
|
||||
dirtyUrl,
|
||||
onInfo = {
|
||||
relayInformationDocumentCache.put(url, it)
|
||||
onInfo(it)
|
||||
},
|
||||
onError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Nip11Retriever {
|
||||
enum class ErrorCode {
|
||||
FAIL_TO_ASSEMBLE_URL,
|
||||
FAIL_TO_REACH_SERVER,
|
||||
FAIL_TO_PARSE_RESULT,
|
||||
FAIL_WITH_HTTP_STATUS
|
||||
}
|
||||
|
||||
fun cleanUrl(dirtyUrl: String): String {
|
||||
return if (dirtyUrl.contains("://")) {
|
||||
dirtyUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
} else {
|
||||
"https://$dirtyUrl"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
onInfo: (RelayInformation) -> Unit,
|
||||
onError: (String, ErrorCode, String?) -> Unit
|
||||
) {
|
||||
try {
|
||||
val request: Request = Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient()
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
onInfo(RelayInformation.fromJson(body))
|
||||
} else {
|
||||
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
|
||||
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
|
||||
import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
|
||||
// Rename to the corect nip number when ready.
|
||||
object Nip47 {
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
|
||||
|
||||
fun createAccountContactListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
@@ -47,13 +60,24 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountRelayListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.kind, StatusEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 5
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
kinds = listOf(BadgeProfilesEvent.kind, EmojiPackSelectionEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
limit = 10
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -111,21 +135,127 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
)
|
||||
|
||||
fun createGiftWrapsToMeFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(GiftWrapEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
)
|
||||
)
|
||||
|
||||
val accountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
|
||||
if (hasLoadedTheBasics[account.userProfile()] != null) {
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
|
||||
} else {
|
||||
hasLoadedTheBasics[account.userProfile()] = true
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
override fun consume(event: Event, relay: Relay) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (LocalCache.justVerify(event)) {
|
||||
if (event is GiftWrapEvent) {
|
||||
val privateKey = account.keyPair.privKey
|
||||
if (privateKey != null) {
|
||||
event.cachedGift(privateKey)?.let {
|
||||
this.consume(it, relay)
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGift(account.keyPair.pubKey, cached)?.let {
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event is SealedGossipEvent) {
|
||||
val privateKey = account.keyPair.privKey
|
||||
if (privateKey != null) {
|
||||
event.cachedGossip(privateKey)?.let {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't store sealed gossips to avoid rebroadcasting by mistake.
|
||||
} else {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun markAsSeenOnRelay(eventId: String, relay: Relay) {
|
||||
checkNotInMainThread()
|
||||
|
||||
super.markAsSeenOnRelay(eventId, relay)
|
||||
|
||||
val note = LocalCache.getNoteIfExists(eventId) ?: return
|
||||
val privKey = account.keyPair.privKey ?: return
|
||||
|
||||
val noteEvent = note.event ?: return
|
||||
markInnerAsSeenOnRelay(noteEvent, privKey, relay)
|
||||
}
|
||||
|
||||
private fun markInnerAsSeenOnRelay(noteEvent: EventInterface, privKey: ByteArray, relay: Relay) {
|
||||
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
|
||||
|
||||
if (noteEvent is GiftWrapEvent) {
|
||||
val gift = noteEvent.cachedGift(privKey) ?: return
|
||||
markInnerAsSeenOnRelay(gift, privKey, relay)
|
||||
} else if (noteEvent is SealedGossipEvent) {
|
||||
val rumor = noteEvent.cachedGossip(privKey) ?: return
|
||||
markInnerAsSeenOnRelay(rumor, privKey, relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter(),
|
||||
createAccountLastPostsListFilter()
|
||||
).ifEmpty { null }
|
||||
return if (hasLoadedTheBasics[account.userProfile()] != null) {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createAccountRelayListFilter(),
|
||||
createNotificationFilter(),
|
||||
createGiftWrapsToMeFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter(),
|
||||
createAccountLastPostsListFilter()
|
||||
).ifEmpty { null }
|
||||
} else {
|
||||
// just the basics.
|
||||
accountChannel.typedFilters = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createAccountRelayListFilter(),
|
||||
createAccountBookmarkListFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
@@ -133,7 +263,6 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
val event = account.createAuthEvent(relay, challenge)
|
||||
|
||||
if (event != null) {
|
||||
Client.send(
|
||||
event,
|
||||
|
||||
@@ -4,11 +4,11 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
|
||||
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
var account: Account? = null
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
|
||||
object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var withUser: User? = null
|
||||
private var withRoom: ChatroomKey? = null
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, user: User) {
|
||||
account = accountIn
|
||||
withUser = user
|
||||
private val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, withRoom: ChatroomKey) {
|
||||
this.account = accountIn
|
||||
this.withRoom = withRoom
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createMessagesToMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
val myPeer = withRoom
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(myPeer.pubkeyHex),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
authors = myPeer.users.map { it },
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -35,7 +39,7 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
}
|
||||
|
||||
fun createMessagesFromMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
val myPeer = withRoom
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
@@ -43,7 +47,8 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
|
||||
tags = mapOf("p" to myPeer.users.map { it }),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -51,9 +56,18 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
val inandoutChannel = requestNewChannel()
|
||||
fun clearEOSEs(account: Account) {
|
||||
latestEOSEs.removeDataFor(account.userProfile())
|
||||
}
|
||||
|
||||
val inandoutChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
|
||||
inandoutChannel.typedFilters = listOfNotNull(
|
||||
createMessagesToMeFilter(),
|
||||
createMessagesFromMeFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
|
||||
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
@@ -44,17 +44,23 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
)
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList(),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
fun createMyChannelsFilter(): TypedFilter {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = followingEvents.toList(),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
@@ -66,8 +72,10 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
|
||||
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
|
||||
private var communityToWatch: AddressableNote? = null
|
||||
|
||||
@@ -2,25 +2,28 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.Subscription
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import kotlin.Error
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
data class Counter(var counter: Int)
|
||||
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
var changingFilters = AtomicBoolean()
|
||||
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
@@ -39,7 +42,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
}
|
||||
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
consume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +50,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
|
||||
override fun onRelayStateChange(type: Relay.StateType, relay: Relay, subscriptionId: String?) {
|
||||
// Log.d("RELAY", "Relay ${relay.url} ${when (type) {
|
||||
// Relay.Type.CONNECT -> "connected."
|
||||
// Relay.Type.DISCONNECT -> "disconnected."
|
||||
@@ -55,13 +58,19 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// Relay.Type.EOSE -> "sent all events it had stored."
|
||||
// }}")
|
||||
|
||||
if (type == Relay.Type.EOSE && channel != null) {
|
||||
if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[channel]?.updateEOSE(TimeUtils.now(), relay.url)
|
||||
subscriptions[subscriptionId]?.updateEOSE(
|
||||
TimeUtils.fiveMinutesAgo(), // in case people's clock is slighly off.
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
if (success) {
|
||||
markAsSeenOnRelay(eventId, relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
@@ -77,6 +86,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
fun destroy() {
|
||||
stop()
|
||||
Client.unsubscribe(clientListener)
|
||||
scope.cancel()
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
@@ -107,18 +118,19 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateFilters() {
|
||||
bundler.invalidate() {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
scope.launch(Dispatchers.IO) {
|
||||
bundler.invalidate() {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
resetFiltersSuspend()
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
@@ -131,6 +143,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// saves the current content to only update if it changes
|
||||
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
|
||||
|
||||
changingFilters.getAndSet(true)
|
||||
|
||||
updateChannelFilters()
|
||||
|
||||
// Makes sure to only send an updated filter when it actually changes.
|
||||
@@ -162,6 +176,16 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changingFilters.getAndSet(false)
|
||||
}
|
||||
|
||||
open fun consume(event: Event, relay: Relay) {
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
}
|
||||
|
||||
open fun markAsSeenOnRelay(eventId: String, relay: Relay) {
|
||||
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
|
||||
@@ -1,74 +1,86 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
|
||||
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val dataSource = "Discovery"
|
||||
|
||||
fun createLiveStreamFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
fun createLiveStreamFilter(): List<TypedFilter> {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
)
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
),
|
||||
follows?.let {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
tags = mapOf("p" to it),
|
||||
kinds = listOf(LiveActivitiesEvent.kind),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
fun createPublicChatFilter(): List<TypedFilter> {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
|
||||
val followChats = account.selectedChatsFollowList().toList()
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
ids = followChats,
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
authors = follows,
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -88,7 +100,27 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -108,7 +140,27 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatsGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -128,21 +180,46 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val discoveryFeedChannel = requestNewChannel()
|
||||
fun createCommunitiesGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val discoveryFeedChannel = requestNewChannel() { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters = listOfNotNull(
|
||||
createLiveStreamFilter(),
|
||||
createPublicChatFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createLiveStreamTagsFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createCommunitiesTagsFilter()
|
||||
discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
|
||||
listOfNotNull(
|
||||
createLiveStreamTagsFilter(),
|
||||
createLiveStreamGeohashesFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createCommunitiesTagsFilter(),
|
||||
createCommunitiesGeohashesFilter(),
|
||||
createPublicChatsGeohashesFilter()
|
||||
)
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
|
||||
private var geohashToWatch: String? = null
|
||||
|
||||
fun createLoadHashtagFilter(): TypedFilter? {
|
||||
val hashToLoad = geohashToWatch ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
tags = mapOf(
|
||||
"g" to listOf(
|
||||
hashToLoad
|
||||
)
|
||||
),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadGeohashChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadHashtag(tag: String?) {
|
||||
geohashToWatch = tag
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
private var hashtagToWatch: String? = null
|
||||
@@ -26,7 +30,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
hashToLoad.capitalize()
|
||||
)
|
||||
),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,20 +2,23 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -52,12 +55,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
@@ -66,10 +64,12 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
TextNoteEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ClassifiedsEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
AudioHeaderEvent.kind,
|
||||
PinListEvent.kind,
|
||||
LiveActivitiesChatMessageEvent.kind,
|
||||
LiveActivitiesEvent.kind
|
||||
@@ -89,7 +89,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
@@ -101,11 +101,63 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowCommunitiesFilter(): TypedFilter? {
|
||||
val communitiesToLoad = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
if (communitiesToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
ClassifiedsEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
AudioHeaderEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind,
|
||||
CommunityPostApprovalEvent.kind
|
||||
),
|
||||
tags = mapOf(
|
||||
"a" to communitiesToLoad.toList()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
followAccountChannel.typedFilters = listOfNotNull(
|
||||
createFollowAccountsFilter(),
|
||||
createFollowCommunitiesFilter(),
|
||||
createFollowTagsFilter(),
|
||||
createFollowGeohashesFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
|
||||
class NostrLnZapPaymentResponseDataSource(
|
||||
private val fromServiceHex: String,
|
||||
@@ -44,7 +44,7 @@ class NostrLnZapPaymentResponseDataSource(
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
val event = RelayAuthEvent.create(relay.url, challenge, authSigningKey)
|
||||
val event = RelayAuthEvent.create(relay.url, challenge, "", authSigningKey)
|
||||
Client.send(
|
||||
event,
|
||||
relay.url
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.NNSEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
private var searchString: String? = null
|
||||
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
@@ -54,6 +71,29 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, BadgeDefinitionEvent.kind,
|
||||
PeopleListEvent.kind, BookmarkListEvent.kind, AudioHeaderEvent.kind,
|
||||
AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind,
|
||||
ChannelCreateEvent.kind
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
ChannelMetadataEvent.kind,
|
||||
ClassifiedsEvent.kind,
|
||||
CommunityDefinitionEvent.kind,
|
||||
EmojiPackEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
LiveActivitiesEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
NNSEvent.kind
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100
|
||||
)
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
|
||||
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<String>()
|
||||
private var channelsToWatch = setOf<Channel>()
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): TypedFilter? {
|
||||
val reactionsToWatch = channelsToWatch.map { it }
|
||||
private fun createMetadataChangeFilter(): TypedFilter? {
|
||||
val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex }
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
@@ -32,7 +32,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() && it is PublicChatChannel }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
@@ -53,7 +52,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filterIsInstance<LiveActivitiesChannel>()
|
||||
.filter { it.info == null }
|
||||
|
||||
@@ -81,7 +79,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val reactions = createMetadataChangeFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
|
||||
|
||||
@@ -90,13 +88,17 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
fun add(eventId: Channel) {
|
||||
if (eventId !in channelsToWatch) {
|
||||
channelsToWatch = channelsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
fun remove(eventId: Channel) {
|
||||
if (eventId in channelsToWatch) {
|
||||
channelsToWatch = channelsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,25 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
private var addressesToWatch = setOf<Note>()
|
||||
|
||||
private fun createTagToAddressFilter(): List<TypedFilter>? {
|
||||
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
|
||||
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
@@ -25,12 +33,15 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind, GenericRepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
|
||||
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
|
||||
PeopleListEvent.kind, BookmarkListEvent.kind
|
||||
TextNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
CommunityPostApprovalEvent.kind,
|
||||
LiveActivitiesChatMessageEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime,
|
||||
@@ -50,14 +61,26 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex)
|
||||
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
authors = listOf(aTag.pubKeyHex),
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex),
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,17 +98,12 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapRequestEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind
|
||||
PollNoteEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime,
|
||||
@@ -124,6 +142,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
val singleEventChannel = requestNewChannel { time, relayUrl ->
|
||||
// Ignores EOSE if it is in the middle of a filter change.
|
||||
if (changingFilters.get()) return@requestNewChannel
|
||||
|
||||
checkNotInMainThread()
|
||||
|
||||
eventsToWatch.forEach {
|
||||
@@ -144,7 +165,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val addresses = createAddressFilter()
|
||||
val addressReactions = createTagToAddressFilter()
|
||||
val addressReactions = createReactionsToWatchInAddressFilter()
|
||||
|
||||
singleEventChannel.typedFilters = listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
@@ -1,44 +1,71 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
|
||||
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
var usersToWatch = setOf<User>()
|
||||
|
||||
fun createUserFilter(): List<TypedFilter>? {
|
||||
fun createUserMetadataFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
|
||||
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
|
||||
authors = firstTimers,
|
||||
limit = 10 * firstTimers.size
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserReportFilter(): List<TypedFilter>? {
|
||||
fun createUserMetadataFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
since = it.latestEOSEs
|
||||
)
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(StatusEvent.kind),
|
||||
authors = usersToWatch.map { it.pubkeyHex },
|
||||
since = minLatestEOSEs
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserStatusFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(StatusEvent.kind),
|
||||
authors = usersToWatch.map { it.pubkeyHex },
|
||||
since = minLatestEOSEs
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserReportFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to usersToWatch.map { it.pubkeyHex }),
|
||||
since = minLatestEOSEs
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val userChannel = requestNewChannel() { time, relayUrl ->
|
||||
@@ -50,17 +77,40 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val userChannelFirstTimers = requestNewChannel() { time, relayUrl ->
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
val userChannelOnce = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
|
||||
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
|
||||
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
|
||||
val neverGottenAnEOSE = mutableSetOf<String>()
|
||||
usersToWatch.forEach {
|
||||
if (it.latestEOSEs.isEmpty()) { // first time
|
||||
neverGottenAnEOSE.add(it.pubkeyHex)
|
||||
} else {
|
||||
it.latestEOSEs.forEach {
|
||||
val minEose = minLatestEOSEs[it.key]
|
||||
if (minEose == null) {
|
||||
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
|
||||
} else if (it.value.time < minEose.time) {
|
||||
minEose.time = it.value.time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userChannel.typedFilters = listOfNotNull(
|
||||
createUserMetadataFilter(minLatestEOSEs),
|
||||
createUserStatusFilter(minLatestEOSEs),
|
||||
createUserReportFilter(minLatestEOSEs)
|
||||
).ifEmpty { null }
|
||||
userChannelFirstTimers.typedFilters = listOfNotNull(
|
||||
createUserMetadataFilter()
|
||||
).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(user: User) {
|
||||
|
||||
@@ -36,8 +36,10 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String?) {
|
||||
eventToWatch = noteId
|
||||
if (eventToWatch != noteId) {
|
||||
eventToWatch = noteId
|
||||
|
||||
invalidateFilters()
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
@@ -34,6 +50,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
RepostEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
AudioHeaderEvent.kind,
|
||||
PinListEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)?.toList()
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
authors = follows,
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200
|
||||
limit = 200,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -41,14 +41,41 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel = requestNewChannel()
|
||||
fun createFollowGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
tags = mapOf(
|
||||
"g" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel = requestNewChannel() { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
videoFeedChannel.typedFilters = listOfNotNull(
|
||||
createContextualFilter(),
|
||||
createFollowTagsFilter(),
|
||||
createFollowGeohashesFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ object OnlineChecker {
|
||||
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
|
||||
val fiveMinutes = 1000 * 60 * 5
|
||||
|
||||
fun isOnlineCached(url: String?): Boolean {
|
||||
if (url.isNullOrBlank()) return false
|
||||
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
|
||||
return checkOnlineCache.get(url).online
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isOnline(url: String?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -28,7 +36,10 @@ object OnlineChecker {
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
|
||||
val result = HttpClient.getHttpClient().newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
}
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object PackageUtils {
|
||||
private fun isPackageInstalled(context: Context, target: String): Boolean {
|
||||
return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null
|
||||
}
|
||||
|
||||
fun isOrbotInstalled(context: Context): Boolean {
|
||||
return isPackageInstalled(context, "org.torproject.android")
|
||||
}
|
||||
|
||||
fun isAmberInstalled(context: Context): Boolean {
|
||||
return isPackageInstalled(context, "com.greenart7c3.nostrsigner")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.round
|
||||
|
||||
class ZapPaymentHandler(val account: Account) {
|
||||
|
||||
@Immutable
|
||||
data class Payable(
|
||||
val info: ZapSplitSetup,
|
||||
val user: User?,
|
||||
val amountMilliSats: Long,
|
||||
val invoice: String
|
||||
)
|
||||
|
||||
suspend fun zap(
|
||||
note: Note,
|
||||
amountMilliSats: Long,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
|
||||
zapType: LnZapEvent.ZapType
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val zapSplitSetup = note.event?.zapSplitSetup()
|
||||
|
||||
val zapsToSend = if (!zapSplitSetup.isNullOrEmpty()) {
|
||||
zapSplitSetup
|
||||
} else {
|
||||
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
onError(
|
||||
context.getString(R.string.missing_lud16),
|
||||
context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
|
||||
}
|
||||
|
||||
val totalWeight = zapsToSend.sumOf { it.weight }
|
||||
|
||||
val invoicesToPayOnIntent = mutableListOf<Payable>()
|
||||
|
||||
zapsToSend.forEachIndexed { index, value ->
|
||||
val outerProgressMin = index / zapsToSend.size.toFloat()
|
||||
val outerProgressMax = (index + 1) / zapsToSend.size.toFloat()
|
||||
|
||||
val zapValue =
|
||||
round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
|
||||
|
||||
if (value.isLnAddress) {
|
||||
innerZap(
|
||||
lud16 = value.lnAddressOrPubKeyHex,
|
||||
note = note,
|
||||
amount = zapValue,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
context = context,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
|
||||
},
|
||||
zapType = zapType,
|
||||
onPayInvoiceThroughIntent = {
|
||||
invoicesToPayOnIntent.add(
|
||||
Payable(
|
||||
info = value,
|
||||
user = null,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = it
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
|
||||
if (lud16 != null) {
|
||||
innerZap(
|
||||
lud16 = lud16,
|
||||
note = note,
|
||||
amount = zapValue,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
context = context,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
|
||||
},
|
||||
zapType = zapType,
|
||||
overrideUser = user,
|
||||
onPayInvoiceThroughIntent = {
|
||||
invoicesToPayOnIntent.add(
|
||||
Payable(
|
||||
info = value,
|
||||
user = user,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = it
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
onError(
|
||||
context.getString(
|
||||
R.string.missing_lud16
|
||||
),
|
||||
context.getString(
|
||||
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (invoicesToPayOnIntent.isNotEmpty()) {
|
||||
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
|
||||
onProgress(1f)
|
||||
} else {
|
||||
launch(Dispatchers.IO) {
|
||||
// Awaits for the event to come back to LocalCache.
|
||||
delay(5000)
|
||||
onProgress(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun innerZap(
|
||||
lud16: String,
|
||||
note: Note,
|
||||
amount: Long,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayInvoiceThroughIntent: (String) -> Unit,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
overrideUser: User? = null
|
||||
) {
|
||||
var zapRequestJson = ""
|
||||
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP) {
|
||||
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType, overrideUser)
|
||||
if (zapRequest != null) {
|
||||
zapRequestJson = zapRequest.toJson()
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(0.10f)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount,
|
||||
message,
|
||||
zapRequestJson,
|
||||
onSuccess = {
|
||||
onProgress(0.7f)
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = it,
|
||||
note,
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_dialog_pay_invoice_error),
|
||||
context.getString(
|
||||
R.string.wallet_connect_pay_invoice_error_error,
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString()
|
||||
?: "Error parsing error message"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
onProgress(1f)
|
||||
}
|
||||
}
|
||||
)
|
||||
onProgress(0.8f)
|
||||
} else {
|
||||
onPayInvoiceThroughIntent(it)
|
||||
onProgress(0f)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
context = context
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import android.content.Context
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
import com.vitorpamplona.quartz.encoders.toLnUrl
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Bech32
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.net.URLEncoder
|
||||
|
||||
class LightningAddressResolver() {
|
||||
@@ -28,116 +27,124 @@ class LightningAddressResolver() {
|
||||
}
|
||||
|
||||
if (lnaddress.lowercase().startsWith("lnurl")) {
|
||||
return try {
|
||||
String(Bech32.decodeBytes(lnaddress, false).second)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
return Lud06().toLnUrlp(lnaddress)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
private suspend fun fetchLightningAddressJson(
|
||||
lnaddress: String,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context
|
||||
) = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(lnaddress)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
|
||||
return
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup,
|
||||
lnaddress
|
||||
)
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(response.body.string())
|
||||
} else {
|
||||
onError("Could not fetch invoice from $lnCallback")
|
||||
}
|
||||
}
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
|
||||
url,
|
||||
lnaddress,
|
||||
it.code.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct,
|
||||
url,
|
||||
lnaddress
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
suspend fun fetchLightningInvoice(
|
||||
lnCallback: String,
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(R.string.could_not_fetch_invoice_from, lnCallback)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String, String) -> Unit, context: Context) {
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = {
|
||||
onSuccess(Bech32.encodeBytes("lnurl", it.toByteArray(), Bech32.Encoding.Bech32))
|
||||
onSuccess(it.toByteArray().toLnUrl())
|
||||
},
|
||||
onError = onError
|
||||
onError = onError,
|
||||
context = context
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
|
||||
suspend fun lnAddressInvoice(
|
||||
lnaddress: String,
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context
|
||||
) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchLightningAddressJson(
|
||||
@@ -148,14 +155,20 @@ class LightningAddressResolver() {
|
||||
val lnurlp = try {
|
||||
mapper.readTree(lnAddressJson)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup)
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val callback = lnurlp?.get("callback")?.asText()
|
||||
|
||||
if (callback == null) {
|
||||
onError("Callback URL not found in the User's lightning address server configuration")
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration)
|
||||
)
|
||||
}
|
||||
|
||||
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
|
||||
@@ -172,26 +185,56 @@ class LightningAddressResolver() {
|
||||
val lnInvoice = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup")
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(R.string.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup)
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
|
||||
// Forces LN Invoice amount to be the requested amount.
|
||||
val expectedAmountInSats = BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
|
||||
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
|
||||
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
|
||||
if (invoiceAmount.toLong() == expectedAmountInSats) {
|
||||
onProgress(0.7f)
|
||||
onSuccess(pr)
|
||||
} else {
|
||||
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server")
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
|
||||
invoiceAmount.toLong().toString(),
|
||||
lnaddress,
|
||||
expectedAmountInSats.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
|
||||
} ?: lnInvoice?.get("reason")?.asText()?.ifBlank { null }?.let { reason ->
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
|
||||
reason
|
||||
)
|
||||
)
|
||||
} ?: run {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json)
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
onError = onError,
|
||||
context
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
onError = onError,
|
||||
context
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
@Immutable
|
||||
class AppDefinitionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun appMetaData() = try {
|
||||
MetadataEvent.metadataParser.readValue(
|
||||
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
|
||||
UserMetadata::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
|
||||
null
|
||||
}
|
||||
|
||||
fun supportedKinds() = tags.filter { it.size > 1 && it[0] == "k" }.mapNotNull {
|
||||
runCatching { it[1].toInt() }.getOrNull()
|
||||
}
|
||||
|
||||
fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 31990
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class BookmarkListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
companion object {
|
||||
const val kind = 30001
|
||||
|
||||
fun create(
|
||||
name: String = "",
|
||||
|
||||
events: List<String>? = null,
|
||||
users: List<String>? = null,
|
||||
addresses: List<ATag>? = null,
|
||||
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): BookmarkListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
events?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
users?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return BookmarkListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
data class Contact(val pubKeyHex: String, val relayUri: String?)
|
||||
|
||||
@Stable
|
||||
class ContactListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
// This function is only used by the user logged in
|
||||
// But it is used all the time.
|
||||
val verifiedFollowKeySet: Set<HexKey> by lazy {
|
||||
tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
|
||||
try {
|
||||
decodePublicKey(it[1]).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
val verifiedFollowTagSet: Set<String> by lazy {
|
||||
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
|
||||
}
|
||||
|
||||
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
|
||||
verifiedFollowKeySet + pubKey
|
||||
}
|
||||
|
||||
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
|
||||
try {
|
||||
Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2))
|
||||
} catch (e: Exception) {
|
||||
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull {
|
||||
it.getOrNull(2)
|
||||
}
|
||||
|
||||
fun relays(): Map<String, ReadWrite>? = try {
|
||||
if (content.isNotEmpty()) {
|
||||
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite>
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e)
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 3
|
||||
|
||||
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
|
||||
val content = if (relayUse != null) {
|
||||
gson.toJson(relayUse)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = follows.map {
|
||||
if (it.relayUri != null) {
|
||||
listOf("p", it.pubKeyHex, it.relayUri)
|
||||
} else {
|
||||
listOf("p", it.pubKeyHex)
|
||||
}
|
||||
} + followTags.map {
|
||||
listOf("t", it)
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
data class ReadWrite(val read: Boolean, val write: Boolean)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class DeletionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun deleteEvents() = tags.map { it[1] }
|
||||
|
||||
companion object {
|
||||
const val kind = 5
|
||||
|
||||
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): DeletionEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = deleteEvents.map { listOf("e", it) }
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return DeletionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.*
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import fr.acinq.secp256k1.Secp256k1
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
import java.lang.reflect.Type
|
||||
import java.math.BigDecimal
|
||||
import java.security.MessageDigest
|
||||
import java.util.*
|
||||
|
||||
@Immutable
|
||||
open class Event(
|
||||
val id: HexKey,
|
||||
@SerializedName("pubkey") val pubKey: HexKey,
|
||||
@SerializedName("created_at") val createdAt: Long,
|
||||
val kind: Int,
|
||||
val tags: List<List<String>>,
|
||||
val content: String,
|
||||
val sig: HexKey
|
||||
) : EventInterface {
|
||||
override fun id(): HexKey = id
|
||||
|
||||
override fun pubKey(): HexKey = pubKey
|
||||
|
||||
override fun createdAt(): Long = createdAt
|
||||
|
||||
override fun kind(): Int = kind
|
||||
|
||||
override fun tags(): List<List<String>> = tags
|
||||
|
||||
override fun content(): String = content
|
||||
|
||||
override fun sig(): HexKey = sig
|
||||
|
||||
override fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
|
||||
|
||||
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
|
||||
|
||||
override fun isSensitive() = tags.any {
|
||||
(it.size > 0 && it[0].equals("content-warning", true)) ||
|
||||
(it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) ||
|
||||
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
|
||||
}
|
||||
|
||||
override fun zapraiserAmount() = tags.firstOrNull() {
|
||||
(it.size > 1 && it[0] == "zapraiser")
|
||||
}?.get(1)?.toLongOrNull()
|
||||
|
||||
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
val aTagValue = it[1]
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
ATag.parse(aTagValue, relay)
|
||||
}
|
||||
|
||||
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
|
||||
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
|
||||
|
||||
override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex }
|
||||
|
||||
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
|
||||
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
|
||||
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
|
||||
|
||||
override fun firstIsTaggedAddressableNote(addressableNotes: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1)
|
||||
|
||||
override fun isTaggedAddressableKind(kind: Int): Boolean {
|
||||
val kindStr = kind.toString()
|
||||
return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
|
||||
}
|
||||
|
||||
override fun getTagOfAddressableKind(kind: Int): ATag? {
|
||||
val kindStr = kind.toString()
|
||||
val aTag = tags
|
||||
.firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
|
||||
?.getOrNull(1)
|
||||
?: return null
|
||||
|
||||
return ATag.parse(aTag, null)
|
||||
}
|
||||
|
||||
override fun getPoWRank(): Int {
|
||||
var rank = 0
|
||||
for (i in 0..id.length) {
|
||||
if (id[i] == '0') {
|
||||
rank += 4
|
||||
} else if (id[i] in '4'..'7') {
|
||||
rank += 1
|
||||
break
|
||||
} else if (id[i] in '2'..'3') {
|
||||
rank += 2
|
||||
break
|
||||
} else if (id[i] == '1') {
|
||||
rank += 3
|
||||
break
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
override fun getReward(): BigDecimal? {
|
||||
return try {
|
||||
tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
|
||||
*/
|
||||
override fun checkSignature() {
|
||||
if (!id.contentEquals(generateId())) {
|
||||
throw Exception(
|
||||
"""|Unexpected ID.
|
||||
| Event: ${toJson()}
|
||||
| Actual ID: $id
|
||||
| Generated: ${generateId()}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
if (!secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
|
||||
throw Exception("""Bad signature!""")
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasValidSignature(): Boolean {
|
||||
return try {
|
||||
id.contentEquals(generateId()) && secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Event", "Fail checking if event $id has a valid signature", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateId(): String {
|
||||
val rawEvent = listOf(0, pubKey, createdAt, kind, tags, content)
|
||||
|
||||
// GSON decided to hardcode these replacements.
|
||||
// They break Nostr's hash check.
|
||||
// These lines revert their code.
|
||||
// https://github.com/google/gson/issues/2295
|
||||
val rawEventJson = gson.toJson(rawEvent)
|
||||
.replace("\\u2028", "\u2028")
|
||||
.replace("\\u2029", "\u2029")
|
||||
|
||||
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray()).toHexKey()
|
||||
}
|
||||
|
||||
private class EventDeserializer : JsonDeserializer<Event> {
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type?,
|
||||
context: JsonDeserializationContext?
|
||||
): Event {
|
||||
val jsonObject = json.asJsonObject
|
||||
return Event(
|
||||
id = jsonObject.get("id").asString,
|
||||
pubKey = jsonObject.get("pubkey").asString,
|
||||
createdAt = jsonObject.get("created_at").asLong,
|
||||
kind = jsonObject.get("kind").asInt,
|
||||
tags = jsonObject.get("tags").asJsonArray.map {
|
||||
it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString }
|
||||
},
|
||||
content = jsonObject.get("content").asString,
|
||||
sig = jsonObject.get("sig").asString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class EventSerializer : JsonSerializer<Event> {
|
||||
override fun serialize(
|
||||
src: Event,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
addProperty("id", src.id)
|
||||
addProperty("pubkey", src.pubKey)
|
||||
addProperty("created_at", src.createdAt)
|
||||
addProperty("kind", src.kind)
|
||||
add(
|
||||
"tags",
|
||||
JsonArray().also { jsonTags ->
|
||||
src.tags.forEach { tag ->
|
||||
jsonTags.add(
|
||||
JsonArray().also { jsonTagElement ->
|
||||
tag.forEach { tagElement ->
|
||||
jsonTagElement.add(tagElement)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
addProperty("content", src.content)
|
||||
addProperty("sig", src.sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ByteArrayDeserializer : JsonDeserializer<ByteArray> {
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type?,
|
||||
context: JsonDeserializationContext?
|
||||
): ByteArray = Hex.decode(json.asString)
|
||||
}
|
||||
|
||||
private class ByteArraySerializer : JsonSerializer<ByteArray> {
|
||||
override fun serialize(
|
||||
src: ByteArray,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
) = JsonPrimitive(src.toHex())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val secp256k1 = Secp256k1.get()
|
||||
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(Event::class.java, EventSerializer())
|
||||
.registerTypeAdapter(Event::class.java, EventDeserializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
|
||||
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
|
||||
.registerTypeAdapter(Request::class.java, RequestDeserializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
|
||||
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BookmarkListEvent.kind -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
|
||||
RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
else -> this
|
||||
}
|
||||
|
||||
fun generateId(pubKey: HexKey, createdAt: Long, kind: Int, tags: List<List<String>>, content: String): ByteArray {
|
||||
val rawEvent = listOf(
|
||||
0,
|
||||
pubKey,
|
||||
createdAt,
|
||||
kind,
|
||||
tags,
|
||||
content
|
||||
)
|
||||
|
||||
// GSON decided to hardcode these replacements.
|
||||
// They break Nostr's hash check.
|
||||
// These lines revert their code.
|
||||
// https://github.com/google/gson/issues/2295
|
||||
val rawEventJson = gson.toJson(rawEvent)
|
||||
.replace("\\u2028", "\u2028")
|
||||
.replace("\\u2029", "\u2029")
|
||||
|
||||
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray())
|
||||
}
|
||||
|
||||
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = TimeUtils.now()): Event {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = Companion.generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey).toHexKey()
|
||||
return Event(id.toHexKey(), pubKey, createdAt, kind, tags, content, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
interface AddressableEvent {
|
||||
fun dTag(): String
|
||||
fun address(): ATag
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Immutable
|
||||
interface EventInterface {
|
||||
fun id(): HexKey
|
||||
|
||||
fun pubKey(): HexKey
|
||||
|
||||
fun createdAt(): Long
|
||||
|
||||
fun kind(): Int
|
||||
|
||||
fun tags(): List<List<String>>
|
||||
|
||||
fun content(): String
|
||||
|
||||
fun sig(): HexKey
|
||||
|
||||
fun toJson(): String
|
||||
|
||||
fun checkSignature()
|
||||
|
||||
fun hasValidSignature(): Boolean
|
||||
|
||||
fun isTaggedUser(idHex: String): Boolean
|
||||
fun isTaggedAddressableNote(idHex: String): Boolean
|
||||
|
||||
fun isTaggedHash(hashtag: String): Boolean
|
||||
fun isTaggedHashes(hashtags: Set<String>): Boolean
|
||||
fun firstIsTaggedHashes(hashtags: Set<String>): String?
|
||||
fun firstIsTaggedAddressableNote(addressableNotes: Set<String>): String?
|
||||
|
||||
fun isTaggedAddressableKind(kind: Int): Boolean
|
||||
fun getTagOfAddressableKind(kind: Int): ATag?
|
||||
|
||||
fun hashtags(): List<String>
|
||||
|
||||
fun getReward(): BigDecimal?
|
||||
fun getPoWRank(): Int
|
||||
|
||||
fun zapAddress(): String?
|
||||
fun isSensitive(): Boolean
|
||||
fun zapraiserAmount(): Long?
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
abstract class GeneralListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun category() = dTag()
|
||||
fun bookmarkedPosts() = taggedEvents()
|
||||
fun bookmarkedPeople() = taggedUsers()
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
if (content.isBlank()) return null
|
||||
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("GeneralList", "Error decrypting the message ${e.message} for ${dTag()}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
private var privateTagsCache: List<List<String>>? = null
|
||||
|
||||
fun privateTags(privKey: ByteArray): List<List<String>>? {
|
||||
if (privateTagsCache != null) {
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
privateTagsCache = try {
|
||||
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
|
||||
} catch (e: Throwable) {
|
||||
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
|
||||
null
|
||||
}
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
|
||||
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
|
||||
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createPrivateTags(
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
pubKey: ByteArray
|
||||
): String {
|
||||
val privTags = mutableListOf<List<String>>()
|
||||
privEvents?.forEach {
|
||||
privTags.add(listOf("e", it))
|
||||
}
|
||||
privUsers?.forEach {
|
||||
privTags.add(listOf("p", it))
|
||||
}
|
||||
privAddresses?.forEach {
|
||||
privTags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
val msg = gson.toJson(privTags)
|
||||
|
||||
return Utils.encrypt(
|
||||
msg,
|
||||
privateKey,
|
||||
pubKey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.Utils
|
||||
import java.nio.charset.Charset
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@Immutable
|
||||
class LnZapRequestEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
@Transient
|
||||
private var privateZapEvent: Event? = null
|
||||
|
||||
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
|
||||
|
||||
fun getPrivateZapEvent(loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
|
||||
if (privateZapEvent != null) return privateZapEvent
|
||||
|
||||
val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
|
||||
if (anonTag != null) {
|
||||
val encnote = anonTag[1]
|
||||
if (encnote.isNotBlank()) {
|
||||
try {
|
||||
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray())
|
||||
val decryptedEvent = fromJson(note)
|
||||
if (decryptedEvent.kind == 9733) {
|
||||
privateZapEvent = decryptedEvent
|
||||
return privateZapEvent
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
|
||||
fun create(
|
||||
originalNote: EventInterface,
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): LnZapRequestEvent {
|
||||
var content = message
|
||||
var privkey = privateKey
|
||||
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("e", originalNote.id()),
|
||||
listOf("p", originalNote.pubKey()),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (originalNote is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
if (pollOption != null && pollOption >= 0) {
|
||||
tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString()))
|
||||
}
|
||||
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
|
||||
tags = tags + listOf(listOf("anon", ""))
|
||||
privkey = Utils.privkeyCreate()
|
||||
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
|
||||
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
|
||||
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().hexToByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = "" // make sure public content is empty, as the content is encrypted
|
||||
privkey = encryptionPrivateKey // sign event with generated privkey
|
||||
pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey() // updated event with according pubkey
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privkey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun create(
|
||||
userHex: String,
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): LnZapRequestEvent {
|
||||
var content = message
|
||||
var privkey = privateKey
|
||||
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("p", userHex),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
|
||||
privkey = Utils.privkeyCreate()
|
||||
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
|
||||
tags = tags + listOf(listOf("anon", ""))
|
||||
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
|
||||
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.hexToByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = ""
|
||||
privkey = encryptionPrivateKey
|
||||
pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey()
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privkey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
|
||||
var str = privkey + id + createdAt.toString()
|
||||
var strbyte = str.toByteArray(Charset.forName("utf-8"))
|
||||
return MessageDigest.getInstance("SHA-256").digest(strbyte)
|
||||
}
|
||||
|
||||
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
val iv = ByteArray(16)
|
||||
SecureRandom().nextBytes(iv)
|
||||
|
||||
val keySpec = SecretKeySpec(sharedSecret, "AES")
|
||||
val ivSpec = IvParameterSpec(iv)
|
||||
|
||||
var utf8message = msg.toByteArray(Charset.forName("utf-8"))
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
|
||||
val encryptedMsg = cipher.doFinal(utf8message)
|
||||
|
||||
val encryptedMsgBech32 = Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32)
|
||||
val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32)
|
||||
|
||||
return encryptedMsgBech32 + "_" + ivBech32
|
||||
}
|
||||
|
||||
private fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
if (sharedSecret.size != 16 && sharedSecret.size != 32) {
|
||||
throw IllegalArgumentException("Invalid shared secret size")
|
||||
}
|
||||
val parts = msg.split("_")
|
||||
if (parts.size != 2) {
|
||||
throw IllegalArgumentException("Invalid message format")
|
||||
}
|
||||
val iv = parts[1].run { Bech32.decode(this).second }
|
||||
val encryptedMsg = parts.first().run { Bech32.decode(this).second }
|
||||
val encryptedBytes = Bech32.five2eight(encryptedMsg, 0)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0)))
|
||||
|
||||
try {
|
||||
val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
|
||||
return String(decryptedMsgBytes)
|
||||
} catch (ex: BadPaddingException) {
|
||||
throw IllegalArgumentException("Bad padding: ${ex.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class PeopleListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
companion object {
|
||||
const val kind = 30000
|
||||
|
||||
fun create(
|
||||
name: String = "",
|
||||
|
||||
events: List<String>? = null,
|
||||
users: List<String>? = null,
|
||||
addresses: List<ATag>? = null,
|
||||
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): PeopleListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
events?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
users?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.HexValidator
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
|
||||
@Immutable
|
||||
class PrivateDmEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
/**
|
||||
* This may or may not be the actual recipient's pub key. The event is intended to look like a
|
||||
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
||||
* for initial messages.
|
||||
*/
|
||||
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull()
|
||||
|
||||
fun verifiedRecipientPubKey(): HexKey? {
|
||||
val recipient = recipientPubKey()
|
||||
return if (HexValidator.isHex(recipient)) {
|
||||
recipient
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun talkingWith(oneSideHex: String): HexKey {
|
||||
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
|
||||
}
|
||||
|
||||
/**
|
||||
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
|
||||
*
|
||||
* Nip-18 messages should refer to other events by inline references in the content like
|
||||
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
|
||||
*/
|
||||
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun with(pubkeyHex: String): Boolean {
|
||||
return pubkeyHex == pubKey ||
|
||||
tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex }
|
||||
}
|
||||
|
||||
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
|
||||
|
||||
val retVal = Utils.decrypt(content, sharedSecret)
|
||||
|
||||
if (retVal.startsWith(nip18Advertisement)) {
|
||||
retVal.substring(16)
|
||||
} else {
|
||||
retVal
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("PrivateDM", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 4
|
||||
|
||||
const val nip18Advertisement = "[//]: # (nip18)\n"
|
||||
|
||||
fun create(
|
||||
recipientPubKey: ByteArray,
|
||||
msg: String,
|
||||
replyTos: List<String>? = null,
|
||||
mentions: List<String>? = null,
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
publishedRecipientPubKey: ByteArray? = null,
|
||||
advertiseNip18: Boolean = true,
|
||||
markAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long?
|
||||
): PrivateDmEvent {
|
||||
val content = Utils.encrypt(
|
||||
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
|
||||
privateKey,
|
||||
recipientPubKey
|
||||
)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
publishedRecipientPubKey?.let {
|
||||
tags.add(listOf("p", publishedRecipientPubKey.toHex()))
|
||||
}
|
||||
replyTos?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", it))
|
||||
}
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
zapRaiserAmount?.let {
|
||||
tags.add(listOf("zapraiser", "$it"))
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class ReactionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
companion object {
|
||||
const val kind = 7
|
||||
|
||||
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
|
||||
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
|
||||
return create("+", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
|
||||
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
|
||||
if (originalNote is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
class RelayAuthEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1)
|
||||
fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 22242
|
||||
|
||||
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RelayAuthEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
listOf("relay", relay),
|
||||
listOf("challenge", challenge)
|
||||
)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service.model.zaps
|
||||
package com.vitorpamplona.quartz.events.zaps
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
|
||||
import com.vitorpamplona.quartz.events.LnZapEventInterface
|
||||
|
||||
object UserZaps {
|
||||
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toByteArray
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object Nip19 {
|
||||
enum class Type {
|
||||
USER, NOTE, EVENT, RELAY, ADDRESS
|
||||
}
|
||||
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
@Immutable
|
||||
data class Return(
|
||||
val type: Type,
|
||||
val hex: String,
|
||||
val relay: String? = null,
|
||||
val author: String? = null,
|
||||
val kind: Int? = null,
|
||||
val additionalChars: String = ""
|
||||
)
|
||||
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
if (uri == null) return null
|
||||
|
||||
try {
|
||||
val matcher = nip19regex.matcher(uri)
|
||||
if (!matcher.find()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val uriScheme = matcher.group(1) // nostr:
|
||||
val type = matcher.group(2) // npub1
|
||||
val key = matcher.group(3) // bech32
|
||||
val additionalChars = matcher.group(4) // additional chars
|
||||
|
||||
return parseComponents(uriScheme, type, key, additionalChars)
|
||||
} catch (e: Throwable) {
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseComponents(
|
||||
uriScheme: String?,
|
||||
type: String,
|
||||
key: String?,
|
||||
additionalChars: String?
|
||||
): Return? {
|
||||
return try {
|
||||
val bytes = (type + key).bechToBytes()
|
||||
val parsed = when (type.lowercase()) {
|
||||
"npub1" -> npub(bytes)
|
||||
"note1" -> note(bytes)
|
||||
"nprofile1" -> nprofile(bytes)
|
||||
"nevent1" -> nevent(bytes)
|
||||
"nrelay1" -> nrelay(bytes)
|
||||
"naddr1" -> naddr(bytes)
|
||||
else -> null
|
||||
}
|
||||
parsed?.copy(additionalChars = additionalChars ?: "")
|
||||
} catch (e: Throwable) {
|
||||
Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun npub(bytes: ByteArray): Return {
|
||||
return Return(Type.USER, bytes.toHexKey())
|
||||
}
|
||||
|
||||
private fun note(bytes: ByteArray): Return {
|
||||
return Return(Type.NOTE, bytes.toHexKey())
|
||||
}
|
||||
|
||||
private fun nprofile(bytes: ByteArray): Return? {
|
||||
val tlv = Tlv.parse(bytes)
|
||||
|
||||
val hex = tlv.get(Tlv.Type.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toHexKey() ?: return null
|
||||
|
||||
val relay = tlv.get(Tlv.Type.RELAY.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
return Return(Type.USER, hex, relay)
|
||||
}
|
||||
|
||||
private fun nevent(bytes: ByteArray): Return? {
|
||||
val tlv = Tlv.parse(bytes)
|
||||
|
||||
val hex = tlv.get(Tlv.Type.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toHexKey() ?: return null
|
||||
|
||||
val relay = tlv.get(Tlv.Type.RELAY.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
val author = tlv.get(Tlv.Type.AUTHOR.id)
|
||||
?.get(0)
|
||||
?.toHexKey()
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }
|
||||
|
||||
return Return(Type.EVENT, hex, relay, author, kind)
|
||||
}
|
||||
|
||||
private fun nrelay(bytes: ByteArray): Return? {
|
||||
val relayUrl = Tlv.parse(bytes)
|
||||
.get(Tlv.Type.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8) ?: return null
|
||||
|
||||
return Return(Type.RELAY, relayUrl)
|
||||
}
|
||||
|
||||
private fun naddr(bytes: ByteArray): Return? {
|
||||
val tlv = Tlv.parse(bytes)
|
||||
|
||||
val d = tlv.get(Tlv.Type.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8) ?: return null
|
||||
|
||||
val relay = tlv.get(Tlv.Type.RELAY.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
val author = tlv.get(Tlv.Type.AUTHOR.id)
|
||||
?.get(0)
|
||||
?.toHexKey()
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }
|
||||
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
|
||||
}
|
||||
|
||||
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
|
||||
val kind = kind?.toByteArray()
|
||||
val author = author?.hexToByteArray()
|
||||
val idHex = idHex.hexToByteArray()
|
||||
val relay = relay?.toByteArray(Charsets.UTF_8)
|
||||
|
||||
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
|
||||
|
||||
if (relay != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
|
||||
}
|
||||
|
||||
if (author != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author
|
||||
}
|
||||
|
||||
if (kind != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
|
||||
}
|
||||
|
||||
return Bech32.encodeBytes(hrp = "nevent", fullArray, Bech32.Encoding.Bech32)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
object Tlv {
|
||||
enum class Type(val id: Byte) {
|
||||
SPECIAL(0),
|
||||
RELAY(1),
|
||||
AUTHOR(2),
|
||||
KIND(3);
|
||||
}
|
||||
|
||||
fun toInt32(bytes: ByteArray): Int {
|
||||
require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" }
|
||||
return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int
|
||||
}
|
||||
|
||||
fun parse(data: ByteArray): Map<Byte, List<ByteArray>> {
|
||||
val result = mutableMapOf<Byte, MutableList<ByteArray>>()
|
||||
var rest = data
|
||||
while (rest.isNotEmpty()) {
|
||||
val t = rest[0]
|
||||
val l = rest[1]
|
||||
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
|
||||
rest = rest.sliceArray(IntRange(2 + l, rest.size - 1))
|
||||
if (v.size < l) continue
|
||||
|
||||
if (!result.containsKey(t)) {
|
||||
result[t] = mutableListOf()
|
||||
}
|
||||
result[t]?.add(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -5,52 +5,201 @@ import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.SignerType
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import java.math.BigDecimal
|
||||
|
||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
fun consume(event: Event) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
if (LocalCache.notes[event.id] == null) {
|
||||
// adds to database
|
||||
LocalCache.verifyAndConsume(event, null)
|
||||
suspend fun consume(event: GiftWrapEvent) {
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (!notificationManager().areNotificationsEnabled()) return
|
||||
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
// PushNotification Wraps don't include a receiver.
|
||||
// Test with all logged in accounts
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
if (it.hasPrivKey || it.loggedInWithExternalSigner) {
|
||||
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
|
||||
consumeIfMatchesAccount(event, acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: PrivateDmEvent) {
|
||||
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
|
||||
val key = account.keyPair.privKey
|
||||
if (account.loginWithExternalSigner) {
|
||||
ExternalSignerUtils.account = account
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
pushWrappedEvent.content,
|
||||
pushWrappedEvent.pubKey,
|
||||
pushWrappedEvent.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id] ?: ""
|
||||
}
|
||||
pushWrappedEvent.unwrap(cached)?.let { notificationEvent ->
|
||||
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
|
||||
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
|
||||
|
||||
LocalCache.justConsume(notificationEvent, null)
|
||||
|
||||
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key != null) {
|
||||
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
|
||||
LocalCache.justConsume(notificationEvent, null)
|
||||
|
||||
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unwrapAndConsume(event: Event, account: Account): Event? {
|
||||
if (!LocalCache.justVerify(event)) return null
|
||||
|
||||
return when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
val key = account.keyPair.privKey
|
||||
if (key != null) {
|
||||
event.cachedGift(key)?.let {
|
||||
unwrapAndConsume(it, account)
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGift(account.keyPair.pubKey, cached)?.let {
|
||||
unwrapAndConsume(it, account)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
is SealedGossipEvent -> {
|
||||
val key = account.keyPair.privKey
|
||||
if (key != null) {
|
||||
event.cachedGossip(key)?.let {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
it
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
|
||||
LocalCache.justConsume(it, null)
|
||||
it
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LocalCache.justConsume(event, null)
|
||||
event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: ChatMessageEvent, acc: Account) {
|
||||
if (event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
|
||||
event.pubKey != acc.userProfile().pubkeyHex
|
||||
) { // from the user
|
||||
|
||||
val chatNote = LocalCache.notes[event.id] ?: return
|
||||
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
||||
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val isKnownRoom = (
|
||||
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(chatRoom)
|
||||
) && !acc.isAllHidden(chatRoom.users)
|
||||
|
||||
if (isKnownRoom) {
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri = chatNote.toNEvent()
|
||||
notificationManager().sendDMNotification(
|
||||
event.id,
|
||||
content,
|
||||
user,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: PrivateDmEvent, acc: Account) {
|
||||
val note = LocalCache.notes[event.id] ?: return
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val messagingWith = acc.userProfile().privateChatrooms.keys.filter {
|
||||
(
|
||||
it.pubkeyHex in followingKeySet || acc.userProfile()
|
||||
.hasSentMessagesTo(it)
|
||||
) && !acc.isHidden(it)
|
||||
}.toSet()
|
||||
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
|
||||
(
|
||||
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(it)
|
||||
) && !acc.isAllHidden(it.users)
|
||||
}.toSet()
|
||||
|
||||
if (note.author in messagingWith) {
|
||||
note.author?.let {
|
||||
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
|
||||
val content = acc.decryptContent(note) ?: ""
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
@@ -61,49 +210,50 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: LnZapEvent) {
|
||||
private fun notify(event: LnZapEvent, acc: Account) {
|
||||
val noteZapEvent = LocalCache.notes[event.id] ?: return
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||
val amount = showAmount(event.amount)
|
||||
val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
Pair(author, decryptedContent.content)
|
||||
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
||||
Pair(noteZapRequest.author, noteZapRequest.event?.content())
|
||||
} else {
|
||||
Pair(noteZapRequest.author, null)
|
||||
}
|
||||
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||
val amount = showAmount(event.amount)
|
||||
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
Pair(author, decryptedContent.content)
|
||||
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
||||
Pair(noteZapRequest.author, noteZapRequest.event?.content())
|
||||
} else {
|
||||
Pair(noteZapRequest.author, null)
|
||||
}
|
||||
|
||||
val zappedContent =
|
||||
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
|
||||
|
||||
val user = senderInfo?.first?.toBestDisplayName() ?: ""
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo?.second?.ifBlank { null }?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
zappedContent?.let {
|
||||
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
|
||||
val zappedContent =
|
||||
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
|
||||
|
||||
val user = senderInfo?.first?.toBestDisplayName() ?: ""
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo?.second?.ifBlank { null }?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
zappedContent?.let {
|
||||
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationManager(): NotificationManager {
|
||||
fun notificationManager(): NotificationManager {
|
||||
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
object NotificationUtils {
|
||||
private var dmChannel: NotificationChannel? = null
|
||||
private var zapChannel: NotificationChannel? = null
|
||||
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
||||
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
||||
|
||||
private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
if (dmChannel != null) return dmChannel!!
|
||||
|
||||
dmChannel = NotificationChannel(
|
||||
@@ -39,7 +41,7 @@ object NotificationUtils {
|
||||
return dmChannel!!
|
||||
}
|
||||
|
||||
private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
|
||||
fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
|
||||
if (zapChannel != null) return zapChannel!!
|
||||
|
||||
zapChannel = NotificationChannel(
|
||||
@@ -70,7 +72,7 @@ object NotificationUtils {
|
||||
val zapChannel = getOrCreateZapChannel(applicationContext)
|
||||
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
|
||||
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, ZAP_GROUP_KEY, applicationContext)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendDMNotification(
|
||||
@@ -84,7 +86,7 @@ object NotificationUtils {
|
||||
val dmChannel = getOrCreateDMChannel(applicationContext)
|
||||
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
|
||||
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, DM_GROUP_KEY, applicationContext)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendNotification(
|
||||
@@ -94,6 +96,7 @@ object NotificationUtils {
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
notificationGroupKey: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
if (pictureUrl != null) {
|
||||
@@ -110,6 +113,7 @@ object NotificationUtils {
|
||||
picture = imageResult.drawable as? BitmapDrawable,
|
||||
uri = uri,
|
||||
channelId,
|
||||
notificationGroupKey,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
} else {
|
||||
@@ -120,6 +124,7 @@ object NotificationUtils {
|
||||
picture = null,
|
||||
uri = uri,
|
||||
channelId,
|
||||
notificationGroupKey,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
}
|
||||
@@ -132,6 +137,7 @@ object NotificationUtils {
|
||||
picture: BitmapDrawable?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
notificationGroupKey: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
val notId = id.hashCode()
|
||||
@@ -164,7 +170,8 @@ object NotificationUtils {
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
.setGroup(messageTitle)
|
||||
// .setGroup(messageTitle)
|
||||
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we activate this
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
@@ -178,7 +185,8 @@ object NotificationUtils {
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(messageBody)
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
.setGroup(messageTitle)
|
||||
// .setGroup(messageTitle)
|
||||
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we activate this
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPublicVersion(builderPublic.build())
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
|
||||
@@ -4,12 +4,11 @@ import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -19,14 +18,20 @@ class RegisterAccounts(
|
||||
) {
|
||||
|
||||
// creates proof that it controls all accounts
|
||||
private fun signEventsToProveControlOfAccounts(
|
||||
private suspend fun signEventsToProveControlOfAccounts(
|
||||
accounts: List<AccountInfo>,
|
||||
notificationToken: String
|
||||
): List<RelayAuthEvent> {
|
||||
return accounts.mapNotNull {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if (acc != null) {
|
||||
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
|
||||
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
|
||||
if (acc != null && (acc.isWriteable() || acc.loginWithExternalSigner)) {
|
||||
if (acc.loginWithExternalSigner) {
|
||||
ExternalSignerUtils.account = acc
|
||||
}
|
||||
|
||||
val readRelays = acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays()
|
||||
|
||||
val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null }
|
||||
if (relayToUse != null) {
|
||||
acc.createAuthEvent(relayToUse, notificationToken)
|
||||
} else {
|
||||
@@ -38,7 +43,7 @@ class RegisterAccounts(
|
||||
}
|
||||
}
|
||||
|
||||
private fun postRegistrationEvent(events: List<RelayAuthEvent>) {
|
||||
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
|
||||
try {
|
||||
val jsonObject = """{
|
||||
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
|
||||
@@ -56,18 +61,23 @@ class RegisterAccounts(
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
client.newCall(request).execute()
|
||||
val isSucess = client.newCall(request).execute().use {
|
||||
it.isSuccessful
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e("FirebaseMsgService", "Unable to register with push server", e)
|
||||
val tag = if (BuildConfig.FLAVOR == "play") {
|
||||
"FirebaseMsgService"
|
||||
} else {
|
||||
"UnifiedPushService"
|
||||
}
|
||||
Log.e(tag, "Unable to register with push server", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun go(notificationToken: String) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
postRegistrationEvent(
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken)
|
||||
)
|
||||
}
|
||||
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
|
||||
postRegistrationEvent(
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken)
|
||||
)
|
||||
PushNotificationUtils.hasInit = true
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user