mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
1966 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
3ee743ce1a | ||
|
|
d1ffea5432 | ||
|
|
628e73ef69 | ||
|
|
764928a492 | ||
|
|
88638b0cae | ||
|
|
51384e654b | ||
|
|
aecfaa5fd8 | ||
|
|
fc5089f7f1 | ||
|
|
25520e7d2a | ||
|
|
7ea5be0152 | ||
|
|
9a7e678bbe | ||
|
|
1f0ca70c47 | ||
|
|
93705e5baf | ||
|
|
72e8fa3679 | ||
|
|
c08ce91197 | ||
|
|
b520a0faed | ||
|
|
80837abab5 | ||
|
|
5c78135f1a | ||
|
|
386658ed37 | ||
|
|
ab8b0beb02 | ||
|
|
bc89062681 | ||
|
|
14685be92d | ||
|
|
6d565a2960 | ||
|
|
3fc247d85c | ||
|
|
cfbc4536c6 | ||
|
|
dcf825c43e | ||
|
|
8ae05d9964 | ||
|
|
f11faa87b1 | ||
|
|
bfbaabd7e8 | ||
|
|
d35d6a44fe | ||
|
|
9c7265565e | ||
|
|
293b023add | ||
|
|
bee5c1bfa6 | ||
|
|
c93a6dffc4 | ||
|
|
60dadc0088 | ||
|
|
a26c7e3d97 | ||
|
|
9e2b2cedd6 | ||
|
|
636d59659d | ||
|
|
63e8391893 | ||
|
|
2f354e4b81 | ||
|
|
b3a369d91b | ||
|
|
4406532994 | ||
|
|
8fd5176e22 | ||
|
|
e3396065c6 | ||
|
|
84600c016e | ||
|
|
64d96ab1d7 | ||
|
|
6d6720a511 | ||
|
|
17232a1909 | ||
|
|
eb02049507 | ||
|
|
ed7ad2f51b | ||
|
|
3e0202e6cb | ||
|
|
d59a68ec2e | ||
|
|
f249884907 | ||
|
|
c823d1689f | ||
|
|
b10f268e69 | ||
|
|
eff21c4f8f | ||
|
|
5537208abb | ||
|
|
53a5d3e88e | ||
|
|
20f840fd99 | ||
|
|
dc940c0608 | ||
|
|
f5c77d41f1 | ||
|
|
014c613577 | ||
|
|
d369c2c658 | ||
|
|
536868d3e5 | ||
|
|
5706738898 | ||
|
|
2947c2c7d0 | ||
|
|
18ac527629 | ||
|
|
6d80d14421 | ||
|
|
e60d8c5c20 | ||
|
|
51b4cf444a | ||
|
|
f78ec5cc90 | ||
|
|
fd6a63a76c | ||
|
|
e0f186bb94 | ||
|
|
9fadab5bb9 | ||
|
|
ea768fb560 | ||
|
|
71cb18f360 | ||
|
|
3a1694ac9a | ||
|
|
91c47b6e87 | ||
|
|
fe4e03df39 | ||
|
|
d110b9fbbf | ||
|
|
dc8c177f20 | ||
|
|
1beacd240e | ||
|
|
083394b928 | ||
|
|
6de8d9d7e0 | ||
|
|
fb4457e767 | ||
|
|
d4c1907349 | ||
|
|
c92926f3ab | ||
|
|
8f4f8f7953 | ||
|
|
5a3ea1c258 | ||
|
|
a567ec70ad | ||
|
|
e402081777 | ||
|
|
ed9c27341f | ||
|
|
56d9c9a50f | ||
|
|
d2a22f4ca0 | ||
|
|
09582fd0b1 | ||
|
|
1d5dfbfd29 | ||
|
|
044d47adad | ||
|
|
cd9465c0e7 | ||
|
|
d179c93c0b | ||
|
|
3c84eae647 | ||
|
|
53a146d0e5 | ||
|
|
57db7f0272 | ||
|
|
b820b6564f | ||
|
|
3908c42c7f | ||
|
|
1793fe40bf | ||
|
|
0857695332 | ||
|
|
a7b3bac8f8 | ||
|
|
e6f122d8b7 | ||
|
|
b2cdc2ddef | ||
|
|
94057059bb | ||
|
|
927ef17896 | ||
|
|
33f915879b | ||
|
|
98bca48359 | ||
|
|
171f763908 | ||
|
|
605d83e530 | ||
|
|
5f710b7304 | ||
|
|
1c6593fb17 | ||
|
|
33fe34d3b3 | ||
|
|
e257fec92c | ||
|
|
6a70a6c72f | ||
|
|
270d6b411a | ||
|
|
803b1e861e | ||
|
|
7300dc3d78 | ||
|
|
405d64402b | ||
|
|
fe1bff1074 | ||
|
|
2952ca1978 | ||
|
|
32dcd7747b | ||
|
|
eca5a1de15 | ||
|
|
886442ac52 | ||
|
|
8521611008 | ||
|
|
df647e4047 | ||
|
|
af26026d25 | ||
|
|
43be55f3d6 | ||
|
|
9b3cb92cb3 | ||
|
|
f2b0ce5fd0 | ||
|
|
787ca98daf | ||
|
|
71517dc454 | ||
|
|
949301d58f | ||
|
|
cf8ac4faae | ||
|
|
ad3aa124fc | ||
|
|
2326aa9f7c | ||
|
|
acfd687d74 | ||
|
|
9b9183395d | ||
|
|
45b4902b2b | ||
|
|
6056d60d32 | ||
|
|
61af248c09 | ||
|
|
5a580db1b8 | ||
|
|
a72ae49e93 | ||
|
|
40a01219cc | ||
|
|
c652390a9c | ||
|
|
0916a97526 | ||
|
|
d76ef0a1ca | ||
|
|
3a70ce1587 | ||
|
|
9f96e84378 | ||
|
|
985771beba | ||
|
|
3967633f77 | ||
|
|
ee5699c2de | ||
|
|
b8f4d9f0bb | ||
|
|
8b1de47517 | ||
|
|
c6ff514ba2 | ||
|
|
50f2d18b61 | ||
|
|
fa7ccdfc20 | ||
|
|
fe02519737 | ||
|
|
f2e9fbebea | ||
|
|
52af109b4e | ||
|
|
833e6bc3e1 | ||
|
|
9bed6fdb9a | ||
|
|
a4a867ebcd | ||
|
|
4da2f18957 | ||
|
|
4141a52d51 | ||
|
|
4dbc71d577 | ||
|
|
3b5d792562 | ||
|
|
dd7f3225c6 | ||
|
|
3477e7b8ba | ||
|
|
a3782bb459 | ||
|
|
d4003eee04 | ||
|
|
c9bbb66284 | ||
|
|
1498fa9868 | ||
|
|
26b493aa1d | ||
|
|
f9d652f849 | ||
|
|
30b0ee444f | ||
|
|
bf8341ef58 | ||
|
|
f9f1b0d98e | ||
|
|
743a4c9d87 | ||
|
|
275baa7497 | ||
|
|
ac1fb879c3 | ||
|
|
270ad56450 | ||
|
|
d155241efc | ||
|
|
d61927cbee | ||
|
|
996b68f9c6 | ||
|
|
f6913663d1 | ||
|
|
58600e13a8 | ||
|
|
54e15ad3cd | ||
|
|
777c652bf5 | ||
|
|
7fb1473e21 | ||
|
|
95db1ec84f | ||
|
|
7438973a20 | ||
|
|
fe31855860 | ||
|
|
d7bce4414d | ||
|
|
b4a81c137c | ||
|
|
a6390f58d9 | ||
|
|
ca3f4c3ec6 | ||
|
|
53bfd40a31 | ||
|
|
798be25c43 | ||
|
|
ebe1bcedd0 | ||
|
|
4731575b87 | ||
|
|
383e761372 | ||
|
|
54a95e6a4e | ||
|
|
3105ade755 | ||
|
|
f651034303 | ||
|
|
7554dc010c | ||
|
|
70a962062b | ||
|
|
5cc7f3bf2b | ||
|
|
b040e0ca13 | ||
|
|
775094356c | ||
|
|
0771b179b5 | ||
|
|
8af307e8bb | ||
|
|
e6ec3f1ce4 | ||
|
|
b4395e7585 | ||
|
|
f8acb26763 | ||
|
|
aa931a4cc5 | ||
|
|
e1cf188fbe | ||
|
|
9e57a706ae | ||
|
|
8c73e981b5 | ||
|
|
87f5007fd3 | ||
|
|
5c42bb0282 | ||
|
|
415252b770 | ||
|
|
4f43d7cde5 | ||
|
|
c819a23f83 | ||
|
|
4a00a9a80d | ||
|
|
1f5f6b251c | ||
|
|
c925b82e86 | ||
|
|
96a5d1a658 | ||
|
|
36154422f1 | ||
|
|
5b2a878f5d | ||
|
|
976d96ef35 | ||
|
|
94b31fe5f9 | ||
|
|
cd2a59ae43 | ||
|
|
e8f5ef3aa3 | ||
|
|
887fc33073 | ||
|
|
c24ba18207 | ||
|
|
5137009f5f | ||
|
|
ba6bd6a2d2 | ||
|
|
bfc16d0878 | ||
|
|
94c41e0c6d | ||
|
|
33454cc2f8 | ||
|
|
d969a483a9 | ||
|
|
a51104fd82 | ||
|
|
a536388123 | ||
|
|
c7cb4b7015 | ||
|
|
fbb1b59e34 | ||
|
|
7d7f047f55 | ||
|
|
e9e0b0f57f | ||
|
|
baf0372537 | ||
|
|
f62716a226 | ||
|
|
9b1c47f4a5 | ||
|
|
a4dd3aa488 | ||
|
|
9dbe7379cd | ||
|
|
ad353d2d1a | ||
|
|
32b2b1ace8 | ||
|
|
13710dde06 | ||
|
|
915ddd0bad | ||
|
|
52ef356c06 | ||
|
|
69481a046b | ||
|
|
157802142c | ||
|
|
54462f8d26 | ||
|
|
fcf612d22a | ||
|
|
e8eea4be25 | ||
|
|
cce9d6cf68 | ||
|
|
143f58fd68 | ||
|
|
6fe66986be | ||
|
|
68e8abecb2 | ||
|
|
bb698a351e | ||
|
|
4506ff9c93 | ||
|
|
f88a4d56c7 | ||
|
|
fbe0f584af | ||
|
|
e28f26d471 | ||
|
|
80a9a2a712 | ||
|
|
2a47cd4fd1 | ||
|
|
ce207aed39 | ||
|
|
244e3f3b29 | ||
|
|
6a44d283f9 | ||
|
|
6addce3d20 | ||
|
|
8aae101ef6 | ||
|
|
d1bbdef5c4 | ||
|
|
b78967f8be | ||
|
|
01a2f746d0 | ||
|
|
d063c2990c | ||
|
|
e31ee031cb | ||
|
|
8fc489cc59 | ||
|
|
ffb2a19963 | ||
|
|
b271c1775c | ||
|
|
f937e95b35 | ||
|
|
1613e79860 | ||
|
|
dd40b4f205 | ||
|
|
2ad9401ee6 | ||
|
|
69ade2f6e6 | ||
|
|
4006e62a82 | ||
|
|
ec88a8b157 | ||
|
|
128211ecd2 | ||
|
|
8f53537ebc | ||
|
|
58c93e0cce | ||
|
|
c35b5e3703 | ||
|
|
be8848fb1b | ||
|
|
a3efd15b95 | ||
|
|
a1930c232b | ||
|
|
58a4fb18ae | ||
|
|
be4870da1a | ||
|
|
d2f6317d5c | ||
|
|
05fb408842 | ||
|
|
611305a406 | ||
|
|
5b856af19b | ||
|
|
ce5106684f | ||
|
|
f0fe2dfc17 | ||
|
|
fb5fad5d02 | ||
|
|
6314e3fcb2 | ||
|
|
da62760fd7 | ||
|
|
e3eae80d4c | ||
|
|
3cd0828cba | ||
|
|
806b86f950 | ||
|
|
c931e2cbee | ||
|
|
4e57eed5d8 | ||
|
|
8e725259eb | ||
|
|
b48d2df8d2 | ||
|
|
dbf34ae457 | ||
|
|
74be724835 | ||
|
|
012c7a7105 | ||
|
|
e50dff3b62 | ||
|
|
ad5a4d0b59 | ||
|
|
3116ea1ad9 | ||
|
|
72e474c9ec | ||
|
|
fe9e85c282 | ||
|
|
01ac90fbec | ||
|
|
dc079941e7 | ||
|
|
9a7a94cb77 | ||
|
|
a44703cb49 | ||
|
|
52bd6b4443 | ||
|
|
279894f9d7 | ||
|
|
a9e1ce5aec | ||
|
|
639fdb174d | ||
|
|
3cd02ee6cf | ||
|
|
52199fe281 | ||
|
|
5616fa52d1 | ||
|
|
7241c8bd9f | ||
|
|
991131f8cd | ||
|
|
a24c95070c | ||
|
|
a346d31554 | ||
|
|
9b5c7ad23f | ||
|
|
a01a060abc | ||
|
|
1cd39e0d8f | ||
|
|
305ecc6a2e | ||
|
|
858f94ae74 | ||
|
|
fc224d99e3 | ||
|
|
b8584964d1 | ||
|
|
fbd0c96a8a | ||
|
|
e2251f9c86 | ||
|
|
eaaacbf218 | ||
|
|
243fbeed44 | ||
|
|
d62bab1b08 | ||
|
|
70e068ed17 | ||
|
|
824e8795d5 | ||
|
|
f768a09bd8 | ||
|
|
1a69a5a4c7 | ||
|
|
b9cc3781bb | ||
|
|
97964ed65c | ||
|
|
42a1d157bf | ||
|
|
e27efbc9c2 | ||
|
|
57a5d0b618 | ||
|
|
8f668cb5fb | ||
|
|
df5ea6f741 | ||
|
|
2e6847df1c | ||
|
|
76a49d6556 | ||
|
|
59fe3ccf3a | ||
|
|
ba884bd1aa | ||
|
|
e98bb76ee0 | ||
|
|
e08bf3eb97 | ||
|
|
9ef02d63a0 | ||
|
|
7007796497 | ||
|
|
9f030ffbf8 | ||
|
|
9ca172e0fc | ||
|
|
360758a2f3 | ||
|
|
ffe6f4b55c | ||
|
|
267b5e474c | ||
|
|
bf6bc5ba68 | ||
|
|
1efc4c91e2 | ||
|
|
a898196571 | ||
|
|
c158c7fc3d | ||
|
|
e3d83fe39a | ||
|
|
5327e1ddb2 | ||
|
|
a2ad13c54a | ||
|
|
816a22ec69 | ||
|
|
8a99dc2db4 | ||
|
|
f9cfe1479b | ||
|
|
5d8aa7bb58 | ||
|
|
f0638b1766 | ||
|
|
b3e2b78415 | ||
|
|
d0094cc781 | ||
|
|
cb77298f52 | ||
|
|
24532c8f20 | ||
|
|
b641684377 | ||
|
|
9589f68028 | ||
|
|
8b852d32e4 | ||
|
|
5b864fa1d9 | ||
|
|
8e91987c31 | ||
|
|
1c3b34a9c7 | ||
|
|
5a44f8d663 | ||
|
|
2ab7e6b24d | ||
|
|
e9f8fd6cec | ||
|
|
8d7060afe6 | ||
|
|
784f983746 | ||
|
|
ecc4141f50 | ||
|
|
b964c6a7fa | ||
|
|
5d37e337bc | ||
|
|
d9815b669b | ||
|
|
6906993cc3 | ||
|
|
705309cb8a | ||
|
|
89bdc9dd58 | ||
|
|
ac0103a53e | ||
|
|
b2c31d3b46 | ||
|
|
931bd58c51 | ||
|
|
6a7d6a843d | ||
|
|
eaae672a30 | ||
|
|
ccfbfeffe2 | ||
|
|
eb62da8d26 | ||
|
|
d334c6b2e1 | ||
|
|
90f0e4aad1 | ||
|
|
fabf52ffd7 | ||
|
|
c499c4baec | ||
|
|
4bd19c3e3d | ||
|
|
6a47f5aa3b | ||
|
|
03d1754498 | ||
|
|
e9374370b9 | ||
|
|
88a5e309bd | ||
|
|
f7a292598e | ||
|
|
cff8684347 | ||
|
|
b9654d164f | ||
|
|
c10db10430 | ||
|
|
50f3f7f176 | ||
|
|
49c3334509 | ||
|
|
e75f70985a | ||
|
|
e956f51f19 | ||
|
|
165678f118 | ||
|
|
d32d2da280 | ||
|
|
d6a6a52821 | ||
|
|
dfb697975f | ||
|
|
c0c0fd9e6d | ||
|
|
5bdcdc6f4a | ||
|
|
102ab63673 | ||
|
|
b34c1f98d5 | ||
|
|
74b05e3d6a | ||
|
|
972bce426a | ||
|
|
3de57d6ae2 | ||
|
|
5695a82c20 | ||
|
|
e171120836 | ||
|
|
abdfe531f7 | ||
|
|
ca90866877 | ||
|
|
450fa2b70d | ||
|
|
93c9ffeb02 | ||
|
|
2e5e546e7d | ||
|
|
00e7add001 | ||
|
|
e28cd2212e | ||
|
|
8f38353271 | ||
|
|
a41ee653d8 | ||
|
|
839be06f62 | ||
|
|
bbef06de59 | ||
|
|
515159f18c | ||
|
|
e7b7ebbbdf | ||
|
|
5965d9a3bb | ||
|
|
31959721b1 | ||
|
|
8e16daaaa3 | ||
|
|
e9fd47a536 | ||
|
|
6191ef9a6b | ||
|
|
05aa12ebac | ||
|
|
42428c5e0f | ||
|
|
1a3685c5ab | ||
|
|
fff892e1c8 | ||
|
|
99c30816c6 | ||
|
|
92c61f317b | ||
|
|
b0e50e0c00 | ||
|
|
f065dcfc5a | ||
|
|
91f34000f5 | ||
|
|
93e2af3c1f | ||
|
|
6072e5977e | ||
|
|
8575ea018d | ||
|
|
ed919ae531 | ||
|
|
3eb832c4e0 | ||
|
|
a57ca2a79d | ||
|
|
07a681c002 | ||
|
|
cb561d432c | ||
|
|
d942c12627 | ||
|
|
a466f33b4e | ||
|
|
3744f90e87 | ||
|
|
1cc0f4bf80 | ||
|
|
dec409c209 | ||
|
|
c664397b11 | ||
|
|
bc1b71e9bf | ||
|
|
a42606af0d | ||
|
|
b301e1d79b | ||
|
|
5ab94e8a1c | ||
|
|
ffa03e1d0d | ||
|
|
7ad982ec71 | ||
|
|
030064e112 | ||
|
|
d9a392d2c1 | ||
|
|
97cd1c5e30 | ||
|
|
24a6d8a155 | ||
|
|
ea185147b3 | ||
|
|
f2db2b88c2 | ||
|
|
6ea0972151 | ||
|
|
093705f3b6 | ||
|
|
1972297755 | ||
|
|
ac00694690 | ||
|
|
605861dc32 | ||
|
|
43d508ba33 | ||
|
|
e692647d58 | ||
|
|
64f98e4b92 | ||
|
|
c58c20cc54 | ||
|
|
89ddb7f4b7 | ||
|
|
cb92a51191 | ||
|
|
213b172c32 | ||
|
|
cfdd470626 | ||
|
|
a5a3c62edd | ||
|
|
c9bcfa1c17 | ||
|
|
02ad85a740 | ||
|
|
446273de80 | ||
|
|
b513877552 | ||
|
|
013eedd892 | ||
|
|
d2cbb27756 | ||
|
|
1a477ea1c8 | ||
|
|
83df639aff | ||
|
|
f298cc6f13 | ||
|
|
300a70ce18 | ||
|
|
0f6bf1cc59 | ||
|
|
6bc1b6b28a | ||
|
|
1c381f64f9 | ||
|
|
5e3703e849 | ||
|
|
acbd41ce61 | ||
|
|
056d00b73b | ||
|
|
72fc21cdc6 | ||
|
|
3e983cdf1c | ||
|
|
d81af9a194 | ||
|
|
7f6b44cd0b | ||
|
|
88bad1e308 | ||
|
|
aa0a4d1aac | ||
|
|
b0a61cce42 | ||
|
|
b3be4567e2 | ||
|
|
c5a586ec56 | ||
|
|
ece3664e74 | ||
|
|
2297c797dc | ||
|
|
4517ca9fdd | ||
|
|
1146b8ad54 | ||
|
|
545643d165 | ||
|
|
359e22ae42 | ||
|
|
69be28ec98 | ||
|
|
a8ffbb922d | ||
|
|
bd4a450336 | ||
|
|
39818268ab | ||
|
|
d26b83a851 | ||
|
|
8dc9c2ed2f | ||
|
|
860f3bd096 | ||
|
|
a3adadb251 | ||
|
|
755d89dc79 | ||
|
|
fa4463f1d7 | ||
|
|
56a68b9100 | ||
|
|
fe317964b8 | ||
|
|
07ccf75d44 | ||
|
|
ff927d4830 | ||
|
|
6461c3e498 | ||
|
|
2ed5eae029 | ||
|
|
1693455d0d | ||
|
|
3bcda33fa6 | ||
|
|
9f394900a7 | ||
|
|
4f2c78e73a | ||
|
|
6a3600fd73 | ||
|
|
b40c6f1361 | ||
|
|
5ce19474ae | ||
|
|
e9bdc045ba | ||
|
|
aed7f46ca0 | ||
|
|
310a93ef29 | ||
|
|
1ccaa1a028 | ||
|
|
493888d121 | ||
|
|
e5aba62fdd | ||
|
|
b643cedf8f | ||
|
|
2c0bfe2118 | ||
|
|
f1ecaf1ee5 | ||
|
|
719f5c2ac6 | ||
|
|
4841d4004e | ||
|
|
75d56214d2 | ||
|
|
f0ba68e467 | ||
|
|
5a375b94d6 | ||
|
|
dcbd4148e2 | ||
|
|
0015100dea | ||
|
|
8898e5d10b | ||
|
|
15621f16a2 | ||
|
|
a29d9ad433 | ||
|
|
d355f22938 | ||
|
|
2082c6cb28 | ||
|
|
4b49cc1aab | ||
|
|
538e61b38e | ||
|
|
93f8847544 | ||
|
|
a1da4affeb | ||
|
|
0bf94f316f | ||
|
|
a3fc22a04a | ||
|
|
6437a2106a | ||
|
|
0a8c79dacd | ||
|
|
22dbaaf95a | ||
|
|
278091e6ef | ||
|
|
d99711d3e6 | ||
|
|
48f8cd7d95 | ||
|
|
3b816559e4 | ||
|
|
e96a50b454 | ||
|
|
93282b71ae | ||
|
|
8223476cb3 | ||
|
|
460187c4a4 | ||
|
|
5da49c920b | ||
|
|
df5b5b90af | ||
|
|
e5b937ed7e | ||
|
|
b4b90cfd19 | ||
|
|
99d302802a | ||
|
|
ec2fc2f7e3 | ||
|
|
26634ccb04 | ||
|
|
7766886587 | ||
|
|
b219b3631c | ||
|
|
a0797f7104 | ||
|
|
330f3503a6 | ||
|
|
7da87af0fb | ||
|
|
2eb0096630 | ||
|
|
459e579bcb | ||
|
|
3c069d574e | ||
|
|
639569cd86 | ||
|
|
e41df98920 | ||
|
|
323e71c7cb | ||
|
|
cbe9462f28 | ||
|
|
95febc1c8e | ||
|
|
5d8ffd1e2c | ||
|
|
f2616855c2 | ||
|
|
5040350be5 | ||
|
|
d6c8651f0d | ||
|
|
c5176b8c49 | ||
|
|
d239020895 | ||
|
|
117446688b | ||
|
|
a8e650a38d | ||
|
|
95a21cc08c | ||
|
|
0823f2e7b5 | ||
|
|
cb42196889 | ||
|
|
6aadf8a883 | ||
|
|
bb9fa8e144 | ||
|
|
5a3cf40402 | ||
|
|
1519e70b24 | ||
|
|
135596591a | ||
|
|
389afcb600 | ||
|
|
d6c2966792 | ||
|
|
dd8b208a0d | ||
|
|
27182a77a4 | ||
|
|
cf5f345d54 | ||
|
|
3950517743 | ||
|
|
d2accf7622 | ||
|
|
7a14a1ab26 | ||
|
|
9e93a35ee7 | ||
|
|
76df70c6f6 | ||
|
|
1d5ff5bea9 | ||
|
|
1767d4a3a3 | ||
|
|
f2d87b78b1 | ||
|
|
4bf76d1b79 | ||
|
|
17fbf8c01f | ||
|
|
332490376b | ||
|
|
14e670cd9a | ||
|
|
d29e6b4d0b | ||
|
|
3558bab786 | ||
|
|
7c21f077db | ||
|
|
a2a9d04e1e | ||
|
|
fc56f3988e | ||
|
|
95ad33ace2 | ||
|
|
cf850415b8 | ||
|
|
bd77bb7780 | ||
|
|
6a696bfa39 | ||
|
|
d5fc97184c | ||
|
|
e0b9120e17 | ||
|
|
1523a29a4f | ||
|
|
1db6b4793f | ||
|
|
426a495eb4 | ||
|
|
e07b4d49ef | ||
|
|
595cab3630 | ||
|
|
817a9c3e8d | ||
|
|
32927040e3 | ||
|
|
c973d941cc | ||
|
|
db6ebe609c | ||
|
|
32913915ce | ||
|
|
4a8680e582 | ||
|
|
7cf0291b5a | ||
|
|
0406632e9d | ||
|
|
bf2949717c | ||
|
|
bbea28c6df | ||
|
|
1c76b7a35c | ||
|
|
814d05812a | ||
|
|
b4d3cf87c8 | ||
|
|
e5ff92c804 | ||
|
|
c3a31fd313 | ||
|
|
3fd4c176e8 | ||
|
|
cf9395d5e3 | ||
|
|
89ae229ea2 | ||
|
|
f40b6c394c | ||
|
|
f84501e40f | ||
|
|
d261003217 | ||
|
|
fc1ee54548 | ||
|
|
6842d12b39 | ||
|
|
3e69b81d81 | ||
|
|
ea4e3e75d6 | ||
|
|
5db04ef2b0 | ||
|
|
b51186de30 | ||
|
|
8fc0f5a8b8 | ||
|
|
754ac9894b | ||
|
|
69744db53b | ||
|
|
db9a0ebfa1 | ||
|
|
38ebeded1c | ||
|
|
2b8fafc123 | ||
|
|
fc2408b526 | ||
|
|
6723806534 | ||
|
|
93c3a49ecf | ||
|
|
e80c8b37b3 | ||
|
|
66b84c0dea | ||
|
|
a7063042fb | ||
|
|
aa60367c72 | ||
|
|
19a58bb669 | ||
|
|
55aafffb37 | ||
|
|
9b92dfca5e | ||
|
|
3ae75a3333 | ||
|
|
9ebb3fbefc | ||
|
|
96d66646b4 | ||
|
|
f202422ff1 | ||
|
|
f02e18ff9b | ||
|
|
b72b0b028f | ||
|
|
eb9be2108d | ||
|
|
875eb0df78 | ||
|
|
ea8a665bc7 | ||
|
|
142f850eb7 | ||
|
|
9f12fffd0d | ||
|
|
28a1f0d6da | ||
|
|
b1de36f423 | ||
|
|
ff54d581ae | ||
|
|
8fcd05a6bb | ||
|
|
434c1e9af3 | ||
|
|
a62f2e44d0 | ||
|
|
aa0ace5014 | ||
|
|
ee4c54aa27 | ||
|
|
7ff408bfb9 | ||
|
|
c1d127ac1e | ||
|
|
0b2e5de752 | ||
|
|
920ce54fe3 | ||
|
|
c87b246a4e | ||
|
|
b8bc370bc1 | ||
|
|
a8069f7a3e | ||
|
|
ca9b7adfa0 | ||
|
|
3c3b6544cf | ||
|
|
910849b3c7 | ||
|
|
411ba0f137 | ||
|
|
d48a15bfb3 | ||
|
|
489941106b | ||
|
|
a0391fb8a5 | ||
|
|
baa3a21dd2 | ||
|
|
633be54dd4 | ||
|
|
470281fa87 | ||
|
|
3287196243 | ||
|
|
bb8bb16370 | ||
|
|
4325975686 | ||
|
|
53ec9d777f | ||
|
|
3e6e7d4863 | ||
|
|
81290f2b26 | ||
|
|
4dcf38c492 | ||
|
|
04c1300317 | ||
|
|
0d051117b2 | ||
|
|
d31eca1b42 | ||
|
|
a503a1b218 | ||
|
|
70890def82 | ||
|
|
d0eadcf30d | ||
|
|
ae268cc197 | ||
|
|
0bf317a818 | ||
|
|
915f7c07e8 | ||
|
|
69a1302404 | ||
|
|
d6d273e34c | ||
|
|
462c73341f | ||
|
|
aa07fbfc94 | ||
|
|
59d7f7cc70 | ||
|
|
950eefa4e3 | ||
|
|
f2bbdd44a6 | ||
|
|
cb09a26ef5 | ||
|
|
43156e3e11 | ||
|
|
eba14cf9f1 | ||
|
|
13de5973ca | ||
|
|
936f750826 | ||
|
|
faf215110a | ||
|
|
839e00ae36 | ||
|
|
b829a42d76 | ||
|
|
e1c3191f31 | ||
|
|
f44549de41 | ||
|
|
1c0fcc4ab8 | ||
|
|
fcc6793244 | ||
|
|
dea35552d5 | ||
|
|
b093873eb0 | ||
|
|
e5de2c45de | ||
|
|
70d8d9e2e0 | ||
|
|
dc0fe75b3d | ||
|
|
25a34dd2e8 | ||
|
|
90227e1cdc | ||
|
|
bba45dd0a7 | ||
|
|
fdca0af97b | ||
|
|
795120e932 | ||
|
|
79da9cee01 | ||
|
|
fbbb9b0d72 | ||
|
|
97d56d61d1 | ||
|
|
f3316f53ea | ||
|
|
e3ccf71a84 | ||
|
|
635279d59a | ||
|
|
0c673e91c6 | ||
|
|
3519e20a98 | ||
|
|
7167d57d22 | ||
|
|
86adac95c9 | ||
|
|
cffcc158f5 | ||
|
|
49a8778975 | ||
|
|
dd62d2bae4 | ||
|
|
3c3b6c97c0 | ||
|
|
dabeea43ea | ||
|
|
8cb84c2395 | ||
|
|
5b6b6f19e5 | ||
|
|
28370dbc01 | ||
|
|
28521c1a60 | ||
|
|
9c30547bd7 | ||
|
|
b07fd253c7 | ||
|
|
ab841c6490 | ||
|
|
12ff434a07 | ||
|
|
51432c8c50 | ||
|
|
483e7d673c | ||
|
|
3ce6ce9951 | ||
|
|
84f51dc473 | ||
|
|
5f4886ce52 | ||
|
|
dea6f0ca21 | ||
|
|
01bdc41286 | ||
|
|
8c2e89197e | ||
|
|
712950c2a1 | ||
|
|
95cc2ee589 | ||
|
|
76283463b0 | ||
|
|
5179a821b2 | ||
|
|
974a8011f9 | ||
|
|
771bb3fc27 | ||
|
|
10bd63ca2f | ||
|
|
1fad3d1c8a | ||
|
|
67cc24c4d9 | ||
|
|
4368b34569 | ||
|
|
264d6a40b3 | ||
|
|
65540c8a95 | ||
|
|
b801aa6bd1 | ||
|
|
7224ad4b05 | ||
|
|
ee52e725d9 | ||
|
|
b043e2da2c | ||
|
|
f8a60203ec | ||
|
|
7f18bc3a88 | ||
|
|
31740d8855 | ||
|
|
8641e0fec5 | ||
|
|
c8f1e13448 | ||
|
|
580cf76577 | ||
|
|
61549ebfd3 | ||
|
|
797d1bb2b4 | ||
|
|
99c16879c1 | ||
|
|
0bf80e88ce | ||
|
|
a71679ef43 | ||
|
|
4bbb34e8c9 | ||
|
|
0e1420a626 | ||
|
|
79782ee8e7 | ||
|
|
81b70780a6 | ||
|
|
277fc5971e | ||
|
|
850bc15f4f | ||
|
|
6d2db65c12 | ||
|
|
89ce6dbab2 | ||
|
|
80bfbf68b4 | ||
|
|
4464247ddb | ||
|
|
486d43dc1b | ||
|
|
6731ca59cd | ||
|
|
7d620cd89a | ||
|
|
82a2586eab | ||
|
|
5a3f912b73 | ||
|
|
bc54134e65 | ||
|
|
0f080219b2 | ||
|
|
ff54629601 | ||
|
|
f889ff2547 | ||
|
|
f3e0c31087 | ||
|
|
8f257a7e2d | ||
|
|
9015089593 | ||
|
|
2b87930283 | ||
|
|
92ccdfc55f | ||
|
|
15d94ca9c4 | ||
|
|
44af7f958c | ||
|
|
2b2028853e | ||
|
|
66dcc03351 | ||
|
|
0528e59572 | ||
|
|
0ab3219115 | ||
|
|
22ba150c4e | ||
|
|
63cc00976f | ||
|
|
cc9d4c237a | ||
|
|
446135ea2a | ||
|
|
a38bbd7168 | ||
|
|
635ed1afc1 | ||
|
|
130ad40a3a | ||
|
|
be5e58111e | ||
|
|
8b012884b1 | ||
|
|
4949a5c459 | ||
|
|
ab330fea96 | ||
|
|
8209c45303 | ||
|
|
206935289e | ||
|
|
571ff0e72b | ||
|
|
940914adf5 | ||
|
|
1496f012a5 | ||
|
|
20a65a0878 | ||
|
|
6285bfaae7 | ||
|
|
2a552bea4d | ||
|
|
d1dc06a467 | ||
|
|
f173f3194b | ||
|
|
8ca63f32f4 | ||
|
|
6b3e10ed28 | ||
|
|
1ae785061e | ||
|
|
ddba4ac510 | ||
|
|
0fe6bd6c83 | ||
|
|
925289ae64 | ||
|
|
f6f82a16f5 | ||
|
|
54511dcc69 | ||
|
|
2f457d135f | ||
|
|
362082d608 | ||
|
|
78cfa98456 | ||
|
|
5e37a97d1e | ||
|
|
561189144e | ||
|
|
285a61e38f | ||
|
|
2ca877b3c3 | ||
|
|
f105188738 | ||
|
|
0a11de1892 | ||
|
|
1596a7b559 | ||
|
|
d4a3ee2651 | ||
|
|
c60ad472e8 | ||
|
|
04e4c4692f | ||
|
|
ab747f5e93 | ||
|
|
e370e75ba4 | ||
|
|
a669e37774 | ||
|
|
86fe9b4a65 | ||
|
|
c6a0b0950a | ||
|
|
77a1b2ba89 | ||
|
|
724cfddb69 | ||
|
|
f03cd04566 | ||
|
|
3217d5095b | ||
|
|
5d9d7ba16e | ||
|
|
c648f54b51 | ||
|
|
4f8d33d850 | ||
|
|
9f3d6a75e6 | ||
|
|
3175d40482 | ||
|
|
24d722cdb7 | ||
|
|
14bc1fb7d4 | ||
|
|
90955515d0 | ||
|
|
a22ec35a26 | ||
|
|
f38f5737b0 | ||
|
|
e958515dad | ||
|
|
add6b06446 | ||
|
|
d070712f2b | ||
|
|
b9f58b3701 | ||
|
|
91f3f036ae | ||
|
|
4ef90e26cf | ||
|
|
f7182d6393 | ||
|
|
99861facc9 | ||
|
|
a5728daeb0 | ||
|
|
a4ac2eeb89 | ||
|
|
a5a320b889 | ||
|
|
8f927c29e3 | ||
|
|
a739b27d14 | ||
|
|
2f2ca97ec1 | ||
|
|
114186ce06 | ||
|
|
8187a7f5a9 | ||
|
|
f8319c720a | ||
|
|
3d93fe3d18 | ||
|
|
b4373ae1ed | ||
|
|
ad420da6de | ||
|
|
a37effd42d | ||
|
|
655cd20a01 | ||
|
|
a354fe928b | ||
|
|
a13dbf2bda | ||
|
|
f1affc2dbb | ||
|
|
cbc0f95498 | ||
|
|
b3f8b06fea | ||
|
|
ed4c19ac79 | ||
|
|
37bd7e34a0 | ||
|
|
7fcc28a320 | ||
|
|
c1d05f8b2f | ||
|
|
9c715dd381 | ||
|
|
a720a636d3 | ||
|
|
5322d6e900 | ||
|
|
9d849f12c0 | ||
|
|
18dc5354bd | ||
|
|
a013f1f1cc | ||
|
|
e1d236d9cf | ||
|
|
12a2a21c0e | ||
|
|
9ae13a9f97 | ||
|
|
bd64a7de63 | ||
|
|
12a3cd5113 | ||
|
|
1d23e87bd9 | ||
|
|
b23bea8971 | ||
|
|
94051f178b | ||
|
|
830ade9ad6 | ||
|
|
ed07248f7d | ||
|
|
9aa5993748 | ||
|
|
e7909beaab | ||
|
|
79e3450a46 | ||
|
|
783204b57f | ||
|
|
1adb2b2caa | ||
|
|
3f579793ca | ||
|
|
8040608e14 | ||
|
|
2d202e2244 | ||
|
|
8e4f339aa1 | ||
|
|
2e66d278ad | ||
|
|
1bfbf3643d | ||
|
|
5990bf0213 | ||
|
|
5e3db99675 | ||
|
|
a291741c37 | ||
|
|
94ee80d8ea | ||
|
|
f7de66c6a5 | ||
|
|
48d890b354 | ||
|
|
c7a9bd1226 | ||
|
|
c40c7bc62b | ||
|
|
cca7a10322 | ||
|
|
fe6ef1df0a | ||
|
|
cf8747b19f | ||
|
|
3e27567532 | ||
|
|
0a15e079dc | ||
|
|
48f4fc4a0f | ||
|
|
d9396ce453 | ||
|
|
817c5172b6 | ||
|
|
cfdb20892e | ||
|
|
75922f93bc | ||
|
|
42801e3eca | ||
|
|
64321dbe7c | ||
|
|
b11b52b60c | ||
|
|
00e5470642 | ||
|
|
7ee3b8a196 | ||
|
|
2ae54b544f | ||
|
|
585bb57f64 | ||
|
|
6981fe8f8a | ||
|
|
abdad7fbea | ||
|
|
57b35398be | ||
|
|
47398e6e86 | ||
|
|
e775ae9ada | ||
|
|
6a9a321e2b | ||
|
|
0aee4407db | ||
|
|
a816fa561a | ||
|
|
dc745c3cdb | ||
|
|
0b30c83143 | ||
|
|
dc8804933d | ||
|
|
b28f0bff74 | ||
|
|
e96e3c0760 | ||
|
|
b7ea167420 | ||
|
|
744a55f497 | ||
|
|
b55f999efb | ||
|
|
4e09d9e54a | ||
|
|
5afa333fab | ||
|
|
c0946f5c0e | ||
|
|
3ece4fcf8b | ||
|
|
b1193c5d11 | ||
|
|
12b0afd9c6 | ||
|
|
df45d68b74 | ||
|
|
11bf702e02 | ||
|
|
47e2683aa3 | ||
|
|
e4fc00669e | ||
|
|
f64db1271b | ||
|
|
35647a8361 | ||
|
|
a2c8658972 | ||
|
|
38e18cf0a7 | ||
|
|
78c9974036 | ||
|
|
7a60a6804a | ||
|
|
5e6e2835ca | ||
|
|
180083147d | ||
|
|
50b0b5af37 | ||
|
|
9d27b8d47e | ||
|
|
fbb4dc5e59 | ||
|
|
a2296f2365 | ||
|
|
58ff2e6157 | ||
|
|
ff490a2b14 | ||
|
|
067d694036 | ||
|
|
425aa69691 | ||
|
|
401d807d29 | ||
|
|
eab202f721 | ||
|
|
f726c656b5 | ||
|
|
37dab1c8b6 | ||
|
|
9a5d8522fb | ||
|
|
5abbd07722 | ||
|
|
1c65334b91 | ||
|
|
726d04f0f4 | ||
|
|
9d1ab5a9b8 | ||
|
|
09bad79870 | ||
|
|
13579f9473 | ||
|
|
cea9cdcd34 | ||
|
|
ed22155940 | ||
|
|
43497f2ce0 | ||
|
|
80e58f72f8 | ||
|
|
234751a4b3 | ||
|
|
ef567110d0 | ||
|
|
42b89f438b | ||
|
|
b01df2b38e | ||
|
|
b89efb547e | ||
|
|
e6554a5b4a | ||
|
|
c58b13e2f7 | ||
|
|
241372ce61 | ||
|
|
49ed594f53 | ||
|
|
03b76357ff | ||
|
|
97490bb6ef | ||
|
|
dbf5267c5c | ||
|
|
e1ce638d7f | ||
|
|
88b80a0da1 | ||
|
|
d6b571590a | ||
|
|
83f3af340f | ||
|
|
e9cf9b21a8 | ||
|
|
b1645590fe | ||
|
|
a5da4ea53d | ||
|
|
3a082d131f | ||
|
|
28d501ed02 | ||
|
|
a04fd4a9f2 | ||
|
|
2424dc3961 | ||
|
|
58f1515387 | ||
|
|
9ee3da3910 | ||
|
|
cc44b4dd55 | ||
|
|
b8e4ccfc28 | ||
|
|
0fc6757d4a | ||
|
|
ee94cc6bf6 | ||
|
|
f224fe6b40 | ||
|
|
403a1723de | ||
|
|
6cf1960df4 | ||
|
|
8676752f19 | ||
|
|
7012d949c2 | ||
|
|
8344274011 | ||
|
|
2bda50ad4a | ||
|
|
b0953310c2 | ||
|
|
65910295db | ||
|
|
7a957bd6ec | ||
|
|
292ce779a1 | ||
|
|
924b21cdfc | ||
|
|
7b539e63bd | ||
|
|
d37fef94fe | ||
|
|
93a1853317 | ||
|
|
9944f98bde | ||
|
|
05138232f4 | ||
|
|
83d7085375 | ||
|
|
a8a3915c8d | ||
|
|
83bcf53583 | ||
|
|
875d9544ec | ||
|
|
7ce2a0bbea | ||
|
|
a3b9ea6a42 | ||
|
|
ca2c811859 | ||
|
|
b09bb0c3c5 | ||
|
|
a68ea1abe5 | ||
|
|
02a9aaab60 | ||
|
|
efd2ddba71 | ||
|
|
e10c5771d8 | ||
|
|
b685b38565 | ||
|
|
15822fee8e | ||
|
|
e96e218025 | ||
|
|
a11263ce9e | ||
|
|
4417201582 | ||
|
|
6a85ab743d | ||
|
|
4434b0e0e5 | ||
|
|
7c8381624b | ||
|
|
e470531210 | ||
|
|
182cf62a3b | ||
|
|
3e666df896 | ||
|
|
20aca144fa | ||
|
|
25d9f7b529 | ||
|
|
306a7b3041 | ||
|
|
bae1b6c6e6 | ||
|
|
f2311f294e | ||
|
|
ad2eabf386 | ||
|
|
b51f551b4b | ||
|
|
af751d56ce | ||
|
|
f4d66d9cbf | ||
|
|
37d839504c | ||
|
|
e8e696dcea | ||
|
|
4db0faeda4 | ||
|
|
b8ca1a15fb | ||
|
|
407ee74a55 | ||
|
|
67bcc52b25 | ||
|
|
9afc94c050 | ||
|
|
9d9283e05f | ||
|
|
5692625922 | ||
|
|
7e07573fa5 | ||
|
|
1f08f33600 | ||
|
|
1ee3dc0ff3 | ||
|
|
3626f24b5f | ||
|
|
4bced4dbea | ||
|
|
737244fb60 | ||
|
|
22adbddd88 | ||
|
|
6c5fecc4d2 | ||
|
|
97017e2bd5 | ||
|
|
3145e381a0 | ||
|
|
bd62736002 | ||
|
|
6884d5e8dd | ||
|
|
27235b0f77 | ||
|
|
24dc4ba7a5 | ||
|
|
a01a28de0e | ||
|
|
9fa3b1db34 | ||
|
|
3193a5e0ef | ||
|
|
3137750a58 | ||
|
|
20068a40d4 | ||
|
|
ef5980e90b | ||
|
|
04a3fe2f4c | ||
|
|
33467c62fa | ||
|
|
a3e94dbaef | ||
|
|
b4e39f2b73 | ||
|
|
b4e409db4d | ||
|
|
eff07bfb1e | ||
|
|
7f305f498b | ||
|
|
bfb7de896c | ||
|
|
2c8c4bede7 | ||
|
|
b079b3ef79 | ||
|
|
a8c8ee1539 | ||
|
|
8f14ccda86 | ||
|
|
a66f001033 | ||
|
|
ca79ceb1b9 | ||
|
|
0abac47ff2 | ||
|
|
5038588d70 | ||
|
|
19dab9ef4d | ||
|
|
374fe85ce9 | ||
|
|
c1120aede3 | ||
|
|
e2f93ec17e | ||
|
|
78adb757da | ||
|
|
9a6f88b81b | ||
|
|
3cd6bab7e9 | ||
|
|
1918fe24fc | ||
|
|
5262368b61 | ||
|
|
d6a8b70852 | ||
|
|
d61844436d | ||
|
|
32067fe800 | ||
|
|
e41da35a02 | ||
|
|
b865d15adf | ||
|
|
42e0cda2e2 | ||
|
|
ad70cfb8d7 | ||
|
|
caaf554371 | ||
|
|
73cabc1b25 | ||
|
|
9590eecacd | ||
|
|
8eb3a47844 | ||
|
|
9ae860a66a | ||
|
|
ceab807f98 | ||
|
|
6b61132f15 | ||
|
|
cdcf6f9b0a | ||
|
|
573cfa8b75 | ||
|
|
e1f2313054 | ||
|
|
23a99f3a8c | ||
|
|
53393a350c | ||
|
|
56e83d3fe3 | ||
|
|
8aa545f920 | ||
|
|
5f459ac924 | ||
|
|
5ce35363d9 | ||
|
|
913fa4ba5b | ||
|
|
7a50f97773 | ||
|
|
0bd5ad9496 | ||
|
|
2c4be592ad | ||
|
|
2183639719 | ||
|
|
0602150cd8 | ||
|
|
c7327c8b91 | ||
|
|
e5e47795c1 | ||
|
|
0980208cce | ||
|
|
93d6d2ed3e | ||
|
|
b65139f520 | ||
|
|
62ff9ac94b | ||
|
|
584c2860e4 | ||
|
|
4a77d8b134 | ||
|
|
7e89822cbf | ||
|
|
8231e03a94 | ||
|
|
26e47d7a10 | ||
|
|
d02ee4dc53 | ||
|
|
1f4f5e3094 | ||
|
|
fb14c4e872 | ||
|
|
ec503f4963 | ||
|
|
a0869349bd | ||
|
|
0f71c01632 | ||
|
|
c2723fe9dd | ||
|
|
3936edb3b7 | ||
|
|
12a1c3fe6d | ||
|
|
5b1e7c3451 | ||
|
|
9b9cc092dc | ||
|
|
5cc0bf54be | ||
|
|
27e053dcd2 | ||
|
|
aaa0678c18 | ||
|
|
cd03ed354f | ||
|
|
10f3551c4f | ||
|
|
6bb1f260ea | ||
|
|
d889c675a0 | ||
|
|
757f6982bd | ||
|
|
d88581ac88 | ||
|
|
2b60c24c86 | ||
|
|
99f84e891e | ||
|
|
efa0672241 | ||
|
|
987a3026ac | ||
|
|
bbf33df04c | ||
|
|
8b81e2e279 | ||
|
|
3d8870c342 | ||
|
|
1a923cefaf | ||
|
|
eed62c36b9 | ||
|
|
712c8ab2dd | ||
|
|
839a691a67 | ||
|
|
dbf0256b1c | ||
|
|
e910260375 | ||
|
|
7c9510a6c9 | ||
|
|
3030d474e2 | ||
|
|
d9c0b8cd20 | ||
|
|
51fbf1b1a5 | ||
|
|
ba33b5e1c6 | ||
|
|
81d9037807 | ||
|
|
79c894dcfd | ||
|
|
e4c98cf39f | ||
|
|
ed1e080fbe | ||
|
|
afef10189b | ||
|
|
1a66319a6d | ||
|
|
7a1470ef7b | ||
|
|
be09577db1 | ||
|
|
f91a0d4cd0 | ||
|
|
4589e6f329 | ||
|
|
7375c9e9e3 | ||
|
|
ccd71a8023 | ||
|
|
3a76d176c5 | ||
|
|
6626be8e1c | ||
|
|
171a7841b3 | ||
|
|
611dcc0189 | ||
|
|
dd9ea5f27e | ||
|
|
cb20e14822 | ||
|
|
b9aa099a1e | ||
|
|
1e8bc10ad1 | ||
|
|
ba40b1815f | ||
|
|
cf2c56e088 | ||
|
|
896758121b | ||
|
|
f0f9726ede | ||
|
|
ddb3990c11 | ||
|
|
8a72e3b7ac | ||
|
|
aae1a65cba | ||
|
|
fb017c3903 | ||
|
|
ef31f56eab | ||
|
|
771cdd6ebe | ||
|
|
0c1b9195d7 | ||
|
|
1fe58810a0 | ||
|
|
3cde9bcdd5 | ||
|
|
0c3b73f4d3 | ||
|
|
25883d00ce | ||
|
|
d04bbfc640 | ||
|
|
7da837f49e | ||
|
|
9709ffe388 | ||
|
|
f7cc656288 | ||
|
|
3ba979e513 | ||
|
|
eadb4a108c | ||
|
|
8b44816520 | ||
|
|
2fb673acf0 | ||
|
|
8401f09475 | ||
|
|
c4400ded28 | ||
|
|
fc37789727 | ||
|
|
abb27ac811 | ||
|
|
fbf5fb4aab | ||
|
|
3887a6ae44 | ||
|
|
cfb342cd74 | ||
|
|
2d693145d9 | ||
|
|
d62a2480f4 | ||
|
|
b30fb1d9c7 | ||
|
|
d1f61c0bba | ||
|
|
5d04f3ea99 | ||
|
|
bd37e4a9df | ||
|
|
3cdfdfa8e8 | ||
|
|
fb7c7a723b | ||
|
|
8171c1ee5e | ||
|
|
0366f6235c | ||
|
|
7890ac9db5 | ||
|
|
fd8517e047 | ||
|
|
7a53708ccc | ||
|
|
ab0361bf27 | ||
|
|
eb40540f0d | ||
|
|
c1f8116a5a | ||
|
|
40aadf77f7 | ||
|
|
adafb6b183 | ||
|
|
d38a7169b8 | ||
|
|
53705f214a | ||
|
|
155bb80118 | ||
|
|
de77f2e18b | ||
|
|
b2f05b4247 | ||
|
|
88292aa179 | ||
|
|
79ba910f52 | ||
|
|
30402b26e8 | ||
|
|
cb6401c7ab | ||
|
|
435e99e81a | ||
|
|
4b7167d5ef | ||
|
|
9887ec2d38 | ||
|
|
edb61e2423 | ||
|
|
8b134892ce | ||
|
|
21cb82a05d | ||
|
|
412c5c557a | ||
|
|
81e31319c7 | ||
|
|
97ec28d2c8 | ||
|
|
c6906c42fd | ||
|
|
7921db3ba9 | ||
|
|
b12413cbff | ||
|
|
07cd1738f1 | ||
|
|
b8aa7e64b3 | ||
|
|
055ed62cb7 | ||
|
|
578c70d272 | ||
|
|
d43f0435d0 | ||
|
|
52978fa237 | ||
|
|
f266a9e964 | ||
|
|
01cb27e6f5 | ||
|
|
551ed64e98 | ||
|
|
f0b4660719 | ||
|
|
38730ea534 | ||
|
|
a955f55fc6 | ||
|
|
a42cdaaf91 | ||
|
|
0633707ec6 | ||
|
|
8768daa06f | ||
|
|
fb0a7b0ae4 | ||
|
|
f8b6b8fb45 | ||
|
|
888b6daa2a | ||
|
|
29c4c42547 | ||
|
|
107e09aefb | ||
|
|
811c373e4c | ||
|
|
1812ec296e | ||
|
|
59cdd81aec | ||
|
|
9297afc02a | ||
|
|
a8d8be3307 | ||
|
|
53ec6c817c | ||
|
|
b7c4ec8358 | ||
|
|
3426ff307c | ||
|
|
cddb713b2f | ||
|
|
1871fe6177 | ||
|
|
83a8558f23 | ||
|
|
dbc155454f | ||
|
|
80e251acfb | ||
|
|
a597bd7882 | ||
|
|
a53bb53c12 | ||
|
|
4fa7aa54a8 | ||
|
|
a9490e1299 | ||
|
|
2bf36f1cfe | ||
|
|
c781d5eff7 | ||
|
|
e8d84795d5 | ||
|
|
b351a781a9 | ||
|
|
97d9e96e2b | ||
|
|
625b8c3bce | ||
|
|
edd4a4cc2c | ||
|
|
b305ccad1d | ||
|
|
14ca6f1d08 | ||
|
|
ad4e6d6596 | ||
|
|
e94d2b5340 | ||
|
|
0241bf0913 | ||
|
|
36885eb6dc | ||
|
|
c1917855d0 | ||
|
|
0272073636 | ||
|
|
063ef1e3a9 | ||
|
|
4ec7af3265 | ||
|
|
f09f472f26 | ||
|
|
2e365b67ea | ||
|
|
ee753e5c95 | ||
|
|
8b53d28721 | ||
|
|
f8bb4a3168 | ||
|
|
1b8923b6d7 | ||
|
|
675b59c0d2 | ||
|
|
a71078f90f | ||
|
|
d4be271060 | ||
|
|
2d59baf898 | ||
|
|
18f651b67f | ||
|
|
f65e09f388 | ||
|
|
a4bb01ef7d | ||
|
|
e9c0d350e1 | ||
|
|
faa29675db | ||
|
|
96990c44a6 | ||
|
|
424fe5fd3c | ||
|
|
06d3784261 | ||
|
|
9e3c08ade8 | ||
|
|
3b7fc7173d | ||
|
|
105c7b9fe8 | ||
|
|
c0c45d1385 | ||
|
|
9b2ce25668 | ||
|
|
7e2b28503e | ||
|
|
d798db7cf1 | ||
|
|
aa2f718589 | ||
|
|
f3a4a7e143 | ||
|
|
732abb70f3 | ||
|
|
8c98a8849c | ||
|
|
5ce6d06920 | ||
|
|
bb6b4c208d | ||
|
|
ea63522745 | ||
|
|
698c9b00cb | ||
|
|
1c740d4d7a | ||
|
|
0491c25e9f | ||
|
|
75ed380449 | ||
|
|
2820197905 | ||
|
|
01818ff458 | ||
|
|
d19592aef3 | ||
|
|
9c4f9fce37 | ||
|
|
c06ed8d338 | ||
|
|
0f42c2707e | ||
|
|
5c518501ab | ||
|
|
887d963c5b | ||
|
|
0eb21a6650 | ||
|
|
9f6867b6ab | ||
|
|
6403bd21f8 | ||
|
|
b3f2a03295 | ||
|
|
27b2cda14f | ||
|
|
f61ec0b5ab | ||
|
|
53d692ec7f | ||
|
|
12876cdacd | ||
|
|
63a14b7409 | ||
|
|
cd5e0187e8 | ||
|
|
02d74c611a | ||
|
|
a367bf5982 | ||
|
|
d8c2e623d1 | ||
|
|
70434eb244 | ||
|
|
85d722f96d | ||
|
|
63ad7fd205 | ||
|
|
c31b99b1dc | ||
|
|
851ec71f4a | ||
|
|
e30b4f7c7e | ||
|
|
e4cec20d1a | ||
|
|
00f9f7ba52 | ||
|
|
bb7dcbdb78 | ||
|
|
260ed29309 | ||
|
|
9fc1fa9f6c | ||
|
|
199f9fbe69 | ||
|
|
2fea7dac62 | ||
|
|
90147ce557 | ||
|
|
4fa584ca1a | ||
|
|
7630e07dc9 | ||
|
|
5da70a0f95 | ||
|
|
be19d0a9cf | ||
|
|
20431a87ea | ||
|
|
cc36dcffe0 | ||
|
|
32b50418de | ||
|
|
b1cb7d599f | ||
|
|
f34d79106d | ||
|
|
269197a59d | ||
|
|
992796a7bf | ||
|
|
e472a494a1 | ||
|
|
b40bde10a0 | ||
|
|
9561261bf4 | ||
|
|
3a2403b344 | ||
|
|
b7f8241a08 | ||
|
|
907aa7b734 | ||
|
|
74336d72c0 | ||
|
|
0b9b288146 | ||
|
|
b7deaaa1a6 | ||
|
|
9dc9d7d947 | ||
|
|
a69a5b1a1e | ||
|
|
5842a84309 | ||
|
|
332ddc15f7 | ||
|
|
30f4ff9d6f | ||
|
|
d166f22c58 | ||
|
|
bee361cea7 | ||
|
|
564f926213 | ||
|
|
0cf891bb4d | ||
|
|
fba873dab9 | ||
|
|
322853f696 | ||
|
|
19c3421e92 | ||
|
|
206d6c68bb | ||
|
|
31e724662d | ||
|
|
6109131ef5 | ||
|
|
fe9798fdfc | ||
|
|
a8dc03c477 | ||
|
|
d4cee941ef | ||
|
|
9adb376198 | ||
|
|
b387808a02 | ||
|
|
8eb37f9864 | ||
|
|
5a45c6d25f | ||
|
|
78261ccd94 | ||
|
|
57ee0ff1ff | ||
|
|
a2d04c9908 | ||
|
|
1ecc4308c1 | ||
|
|
b5b6bd5499 | ||
|
|
e1d1a6125f | ||
|
|
ad8d9cabe2 | ||
|
|
1d74bfb191 | ||
|
|
3d877fda28 | ||
|
|
88e03c1a0a | ||
|
|
b1de2bf326 | ||
|
|
ae1f0c5547 | ||
|
|
b9304aa1b5 | ||
|
|
37b3da685d | ||
|
|
ae6cf15768 | ||
|
|
844fb4ce3d | ||
|
|
4c495f0270 | ||
|
|
8530cf5afe | ||
|
|
0c8ddc5067 | ||
|
|
9cf7cc4e5d | ||
|
|
2704421fb3 | ||
|
|
e68374525a | ||
|
|
e7f814e205 | ||
|
|
7c94af2fb2 | ||
|
|
ccf2b06b10 | ||
|
|
c511ad6f73 | ||
|
|
25f8bde880 | ||
|
|
ab88629085 | ||
|
|
82c4e02cb1 | ||
|
|
1b20f9d85c | ||
|
|
1c9b702a06 | ||
|
|
d41836ca19 | ||
|
|
99d5b19acb | ||
|
|
3b582636f4 | ||
|
|
680c0f1089 | ||
|
|
56433a3ad3 | ||
|
|
2ebbac0f58 | ||
|
|
e616d2bdca | ||
|
|
da4b3e78fd | ||
|
|
8bff85749a | ||
|
|
a577bf8e6a | ||
|
|
c8d19f142e | ||
|
|
e3aa6b5d3c | ||
|
|
61d8a31a8a | ||
|
|
91f92043b4 | ||
|
|
bb635c43e9 | ||
|
|
0cbe1b746a | ||
|
|
ddeead15ac | ||
|
|
f9965bc7d1 | ||
|
|
e820250e4e | ||
|
|
f2f53c66bd | ||
|
|
39b53c05b7 | ||
|
|
d148644799 | ||
|
|
ceb0895b8d | ||
|
|
30e2070b89 | ||
|
|
05446af068 | ||
|
|
0459c4f90a | ||
|
|
b93f28f09d | ||
|
|
38df1ba6f7 | ||
|
|
6c9aa9b6ff | ||
|
|
26b581b13c | ||
|
|
6a3cae1c8d | ||
|
|
40ac31983c | ||
|
|
8c25667d0f | ||
|
|
6acc842d4f | ||
|
|
fc4b68b264 | ||
|
|
9470560002 | ||
|
|
cd950ee946 | ||
|
|
f9f68e2a4f | ||
|
|
0986698c4b | ||
|
|
24b85ed9ac | ||
|
|
2199d98a06 | ||
|
|
bef122e909 | ||
|
|
ce70dd26be | ||
|
|
19c7ab189c | ||
|
|
4be15e23da | ||
|
|
c6a991967c | ||
|
|
f2b1fa9101 | ||
|
|
62497fd270 | ||
|
|
ffed3e7e22 | ||
|
|
13e488e1e2 | ||
|
|
b62adc61ab | ||
|
|
5055afd979 | ||
|
|
9e58ef1110 | ||
|
|
07e4c82c1b | ||
|
|
32cd5f7eef | ||
|
|
15771289e2 | ||
|
|
4967361257 | ||
|
|
6324dc64d5 | ||
|
|
71671865ff | ||
|
|
4d7f29ba8f | ||
|
|
7b7d87a277 | ||
|
|
9c7a5b73b3 | ||
|
|
40a6feab8a | ||
|
|
a41f2c38ec | ||
|
|
26b5061042 | ||
|
|
49199fe892 | ||
|
|
30fe861853 | ||
|
|
1998711e60 | ||
|
|
9ab1828634 | ||
|
|
c7ba0bce5b | ||
|
|
dffce92aa7 | ||
|
|
6f71685f08 | ||
|
|
b763d3c140 | ||
|
|
12b2957648 | ||
|
|
3fd656da9f | ||
|
|
6543df5d28 | ||
|
|
67d6b24993 | ||
|
|
9f5b3d82c5 | ||
|
|
7275799f9d | ||
|
|
77a70c3202 | ||
|
|
00f38670a5 | ||
|
|
773d747f5a | ||
|
|
a82fc73346 | ||
|
|
4a8ec97f2f | ||
|
|
c9b859610e | ||
|
|
f1b6927bb3 | ||
|
|
9720fdfce9 | ||
|
|
1919dc5c81 | ||
|
|
eff8dbbafa | ||
|
|
2d92e1d444 | ||
|
|
c4c8c0fbf7 | ||
|
|
f39c84d6a4 | ||
|
|
f8d6cf8dba | ||
|
|
691b822503 | ||
|
|
d9d05bf6ba | ||
|
|
5add9669f2 | ||
|
|
a1751cc831 | ||
|
|
f22a1ddf47 | ||
|
|
9fb8bae349 | ||
|
|
17b9280660 | ||
|
|
b8bc076240 | ||
|
|
2a82b9e6ca | ||
|
|
95905e6c95 | ||
|
|
d0bcb4f4c2 | ||
|
|
829121eb6a | ||
|
|
1b0453cbc4 | ||
|
|
43e8106aa9 | ||
|
|
e86ae4ac41 | ||
|
|
1424267628 | ||
|
|
6aa7c229b0 | ||
|
|
53e520ee8a | ||
|
|
f18ea8cf76 | ||
|
|
b8937594bc | ||
|
|
12570d3e26 | ||
|
|
8fe2c895de | ||
|
|
f251d56b1a | ||
|
|
e5518e23a8 | ||
|
|
d6f3e9540f | ||
|
|
79e7bb2931 | ||
|
|
3e3d683234 | ||
|
|
429f54848a | ||
|
|
4085285f90 | ||
|
|
b3d219b2de | ||
|
|
797eda2ae2 | ||
|
|
2478fdbcce | ||
|
|
d7cf198134 | ||
|
|
681e3eb44e | ||
|
|
5cc851ecc8 | ||
|
|
2a0426b845 | ||
|
|
567cf83b92 | ||
|
|
c7debb272d | ||
|
|
cf7464b5b6 | ||
|
|
9cb3406d7d | ||
|
|
3613fce68d | ||
|
|
1b62574eca | ||
|
|
fbd5a3296c | ||
|
|
c5e61400b7 | ||
|
|
42dd900491 | ||
|
|
295f1f53cd | ||
|
|
0b2b2d58ab | ||
|
|
c1637de63a | ||
|
|
566b44f4ef | ||
|
|
6275051857 | ||
|
|
58e0aa5f8f | ||
|
|
d6b5af6d77 | ||
|
|
9432135847 | ||
|
|
e7be1cc8e7 | ||
|
|
c0bdb40c7d | ||
|
|
bb59f68253 | ||
|
|
8961675a3e | ||
|
|
3dd5d5c4b2 | ||
|
|
bd8e9da966 | ||
|
|
6067a7d890 | ||
|
|
42a27a6d8d | ||
|
|
308a79a305 | ||
|
|
5e5c393472 | ||
|
|
ea905ef6ed | ||
|
|
5f98a54452 | ||
|
|
8627fd4b4c | ||
|
|
2a65b7241a | ||
|
|
7863961aba | ||
|
|
a84be7fc61 | ||
|
|
657f99a65a | ||
|
|
91591abd14 | ||
|
|
fd58da2a93 | ||
|
|
b35a59372c | ||
|
|
c1113f9df9 | ||
|
|
bd3d7e1aa3 | ||
|
|
47f3fe5cc6 | ||
|
|
b6e16ad470 | ||
|
|
b469915905 | ||
|
|
d3b557b778 | ||
|
|
1154f35c77 | ||
|
|
5ae552117d | ||
|
|
fe80b509e3 | ||
|
|
f3bc190b0b | ||
|
|
3674f6b354 | ||
|
|
3e32a91e34 | ||
|
|
da9027b430 | ||
|
|
10d6011009 | ||
|
|
62d22bf4ca | ||
|
|
d05d0f0829 | ||
|
|
0aaf1f9f34 | ||
|
|
756d9b17f7 | ||
|
|
f52bf26520 | ||
|
|
f4d5785710 | ||
|
|
75901d83a8 | ||
|
|
f34dea868d | ||
|
|
e07400409f | ||
|
|
8e858996fe | ||
|
|
f1711719f4 | ||
|
|
8d7897601b | ||
|
|
83f46f1a66 | ||
|
|
88ceb436ac | ||
|
|
3cd37b8658 | ||
|
|
04e099a212 | ||
|
|
c0bcb638c0 | ||
|
|
0369b45385 | ||
|
|
bab314ebc9 | ||
|
|
5a127706d7 | ||
|
|
4f9b1ab72c | ||
|
|
65d4d59e77 | ||
|
|
b95c013714 | ||
|
|
10ce5a984e | ||
|
|
3521497cf8 | ||
|
|
0da542fede | ||
|
|
d883cc32f5 | ||
|
|
64debb79fc | ||
|
|
4ee6759957 | ||
|
|
723db61e8d | ||
|
|
cb39281268 | ||
|
|
baa1b3f69f | ||
|
|
c7bf4240dd | ||
|
|
fb7edd1797 | ||
|
|
4448e38100 | ||
|
|
716ebd01df | ||
|
|
58521b27de | ||
|
|
f4db64fe7f | ||
|
|
d704352f1f | ||
|
|
511cc86025 | ||
|
|
58e8e0591b | ||
|
|
57fc149051 | ||
|
|
55cc780c08 | ||
|
|
01a7ef3785 | ||
|
|
b77e1cf960 | ||
|
|
c6eb5a7282 | ||
|
|
bf48428fe3 | ||
|
|
f4b366c4bd | ||
|
|
51ce424352 | ||
|
|
01b8b2a5fa | ||
|
|
459e503c8a | ||
|
|
708f6b2e75 | ||
|
|
b7b7f4df39 | ||
|
|
855b2ff5b1 | ||
|
|
67e6349877 | ||
|
|
5eb3b2062c | ||
|
|
c649d0820c | ||
|
|
07fd8fc5ac | ||
|
|
e6afa7fb9a | ||
|
|
e0596dc3b9 |
7
.github/workflows/build.yml
vendored
7
.github/workflows/build.yml
vendored
@@ -14,10 +14,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v1
|
||||
@@ -27,6 +27,9 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Linter (gradle)
|
||||
run: ./gradlew ktlintCheck
|
||||
|
||||
- name: Test (gradle)
|
||||
run: ./gradlew test --no-daemon
|
||||
|
||||
|
||||
120
.github/workflows/create-release.yml
vendored
120
.github/workflows/create-release.yml
vendored
@@ -12,10 +12,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v1
|
||||
@@ -28,22 +28,40 @@ jobs:
|
||||
- name: Build AAB
|
||||
run: ./gradlew clean bundleRelease --stacktrace
|
||||
|
||||
- name: Sign AAB
|
||||
- name: Sign AAB (Google Play)
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/bundle/release
|
||||
releaseDirectory: app/build/outputs/bundle/playRelease
|
||||
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Sign AAB (F-Droid)
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/bundle/fdroidRelease
|
||||
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Build APK
|
||||
run: ./gradlew assembleRelease --stacktrace --no-daemon
|
||||
run: ./gradlew assembleRelease --stacktrace
|
||||
|
||||
- name: Sign APK
|
||||
- name: Sign APK (Google Play)
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/release
|
||||
releaseDirectory: app/build/outputs/apk/play/release
|
||||
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Sign APK (F-Droid)
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/fdroid/release
|
||||
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
@@ -60,24 +78,96 @@ jobs:
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload APK Asset
|
||||
id: upload-release-asset-apk
|
||||
# Google Play APK
|
||||
- name: Upload Play APK Universal Asset
|
||||
id: upload-release-asset-play-universal-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/release/app-release-unsigned-signed.apk
|
||||
asset_name: app-release-${{ github.ref_name }}.apk
|
||||
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
|
||||
|
||||
- name: Upload AAB Asset
|
||||
id: upload-release-asset-aab
|
||||
- name: Upload Play APK x86 Asset
|
||||
id: upload-release-asset-play-x86-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/bundle/release/app-release.aab
|
||||
asset_name: app-release-${{ github.ref_name }}.aab
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-x86-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Play APK x86_64 Asset
|
||||
id: upload-release-asset-play-x86-64-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-x86_64-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-x86_64-${{ 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
|
||||
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-universal-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload F-Droid APK x86 Asset
|
||||
id: upload-release-asset-fdroid-x86-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-x86-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload F-Droid APK x86_64 Asset
|
||||
id: upload-release-asset-fdroid-x86-64-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-x86_64-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
|
||||
|
||||
# Google Play AAB
|
||||
- name: Upload Google Play AAB Asset
|
||||
id: upload-release-asset-play-aab
|
||||
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/bundle/playRelease/app-play-release.aab
|
||||
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
|
||||
asset_content_type: application/zip
|
||||
|
||||
# FDroid AAB
|
||||
- name: Upload F-Droid AAB Asset
|
||||
id: upload-release-asset-fdroid-aab
|
||||
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/bundle/fdroidRelease/app-fdroid-release.aab
|
||||
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
|
||||
asset_content_type: application/zip
|
||||
62
.gitignore
vendored
62
.gitignore
vendored
@@ -1,5 +1,3 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
@@ -8,12 +6,11 @@
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/androidTestResultsUserPreferences.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
|
||||
# Built application files
|
||||
@@ -36,6 +33,7 @@ out/
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
@@ -48,25 +46,51 @@ proguard/
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
# Android Studio
|
||||
/*/build/
|
||||
/*/local.properties
|
||||
/*/out
|
||||
/*/*/build
|
||||
/*/*/production
|
||||
captures/
|
||||
.navigation/
|
||||
*.ipr
|
||||
*~
|
||||
*.swp
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
*.iws
|
||||
/out/
|
||||
deploymentTargetDropdown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# User-specific configurations
|
||||
.idea/**/caches/
|
||||
.idea/**/libraries/
|
||||
.idea/**/shelf/
|
||||
.idea/**/codeStyles
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/.name
|
||||
.idea/**/compiler.xml
|
||||
.idea/**/copyright/profiles_settings.xml
|
||||
.idea/**/encodings.xml
|
||||
.idea/**/misc.xml
|
||||
.idea/**/modules.xml
|
||||
.idea/**/scopes/scope_settings.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/vcs.xml
|
||||
.idea/**/jsLibraryMappings.xml
|
||||
.idea/**/datasources.xml
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/assetWizardSettings.xml
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/jarRepositories.xml
|
||||
.idea/**/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
|
||||
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
Amethyst
|
||||
6
.idea/compiler.xml
generated
6
.idea/compiler.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
</component>
|
||||
</project>
|
||||
3
.idea/inspectionProfiles/Project_Default.xml
generated
3
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -5,6 +5,9 @@
|
||||
<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" />
|
||||
|
||||
6
.idea/kotlinc.xml
generated
Normal file
6
.idea/kotlinc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.8.21" />
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/misc.xml
generated
10
.idea/misc.xml
generated
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
47
PRIVACY.md
47
PRIVACY.md
@@ -1,13 +1,48 @@
|
||||
# Amethyst Privacy Policy & Terms of Use
|
||||
# Amethyst Privacy Policy and Terms of Use
|
||||
|
||||
Effective as of Jan 20, 2023.
|
||||
## Privacy Policy
|
||||
|
||||
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
|
||||
Effective as of Jun 12, 2023
|
||||
|
||||
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
|
||||
|
||||
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts are only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
|
||||
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
|
||||
Amethyst offers a few options to upload pictures and videos in order to post them online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use.
|
||||
|
||||
### Privacy with Relays services
|
||||
|
||||
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
|
||||
|
||||
The relay can also see which public keys you are using and what information you are requesting from the network. Your public key is tied to your IP address and your relay filters.
|
||||
|
||||
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 the content inside Private Zaps and Private DMs.
|
||||
|
||||
While the content of direct messages (DMs) is only visible to you, and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link on the DM text. Your uploaded pictures are available to anyone with that direct link.
|
||||
|
||||
### Visibility & Permanence of Your Content on Nostr Relays
|
||||
|
||||
#### Information Visibility
|
||||
|
||||
Content that you share can be shared with other relays by any user of the network.
|
||||
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 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.
|
||||
|
||||
## Terms of Use
|
||||
|
||||
### For versions downloaded from Google's Play Store
|
||||
|
||||
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary rights, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
|
||||
### For versions downloaded from F-Droid
|
||||
|
||||
We do not control the distribution of the application in F-Droid. Legal matters should be resolved between the user and F-Droid.
|
||||
|
||||
## Other Notes
|
||||
|
||||
We reserve the right to modify this Privacy Policy and Terms of Use at any time. Any modifications to this document will be effective upon our posting of the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy and Terms of Use indicates your acceptance of the terms of the modified Privacy Policy and/or Terms of Use.
|
||||
|
||||
If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com
|
||||
|
||||
130
README.md
130
README.md
@@ -4,13 +4,27 @@
|
||||
|
||||
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
|
||||
|
||||
[<img src="./docs/design/obtainium.png"
|
||||
alt="Get it on Obtaininum"
|
||||
height="80">](https://github.com/ImranR98/Obtainium)
|
||||
[<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub"
|
||||
height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
|
||||
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
|
||||
alt="Get it on Google Play"
|
||||
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
|
||||
- [x] Home Feed
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
- [x] Reactions (likes NIP-25, boost, reply)
|
||||
- [x] Replies and mentions (NIP-10)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [x] Reposts (NIP-18)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] View Threads
|
||||
- [x] Private Messages (NIP-04)
|
||||
@@ -23,31 +37,64 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [x] Relay Sets (home, dms, public chats, global)
|
||||
- [x] User/Note Tagging (NIP-08, NIP-10)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [x] Created_at Limits (NIP-22)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
- [x] Nostr Address (NIP-05)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Internationalization
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Verifiable static content in URLs (NIP-94)
|
||||
- [x] Login with QR
|
||||
- [x] Wallet Connect API (NIP-47)
|
||||
- [x] Accessible uploads
|
||||
- [x] Bounty support (nostrbounties.com)
|
||||
- [x] De-googled F-Droid flavor
|
||||
- [x] External Identity Support (NIP-39)
|
||||
- [x] Multiple Accounts
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Zap Forwarding (NIP-57)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Audio Tracks (zapstr.live) (Kind:31337)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
- [x] Generic Tags (NIP-12)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [x] Generic Reposts (kind:16)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Relay Pages (NIP-11)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [x] Moderated Communities (NIP-172)
|
||||
- [x] Emoji Packs (Kind:30030)
|
||||
- [x] Personal Emoji Lists (Kind:10030)
|
||||
- [x] Classifieds (Kind:30403)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Sensitive Content (NIP-36)
|
||||
- [ ] Relay Pages (NIP-11)
|
||||
- [ ] Relay Authentication (NIP-42)
|
||||
- [ ] Generic Tags (NIP-12)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Events with a Subject (NIP-14)
|
||||
- [ ] Long-form Content (NIP-23)
|
||||
- [ ] Online Relay Search (NIP-50)
|
||||
- [ ] Workspaces
|
||||
- [ ] Expiration Support (NIP-40)
|
||||
- [ ] Internationalization
|
||||
- [ ] Infinity Scroll
|
||||
- [ ] Badges (NIP-58)
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [ ] Signing Requests (NIP-46)
|
||||
- [ ] Delegated Event Signing (NIP-26)
|
||||
- [ ] Account Creation / Backup Guidance (NIP-06)
|
||||
- [ ] Account Creation / Backup Guidance
|
||||
- [ ] Mnemonic seed phrase (NIP-06)
|
||||
- [ ] Message Sent feedback (NIP-20)
|
||||
- [ ] OpenTimestamps Attestations (NIP-03)
|
||||
|
||||
|
||||
# Development Overview
|
||||
|
||||
@@ -73,7 +120,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro
|
||||
## Setup
|
||||
|
||||
Make sure to have the following pre-requisites installed:
|
||||
1. Java 11
|
||||
1. Java 17
|
||||
2. Android Studio
|
||||
3. Android 8.0+ Phone or Emulation setup
|
||||
|
||||
@@ -96,9 +143,22 @@ Build the app:
|
||||
./gradlew connectedAndroidTest
|
||||
```
|
||||
|
||||
## Installing on device
|
||||
## Linting
|
||||
```bash
|
||||
./gradlew installDebug
|
||||
./gradlew ktlintCheck
|
||||
./gradlew ktlintFormat
|
||||
```
|
||||
|
||||
## Installing on device
|
||||
|
||||
For the F-Droid build:
|
||||
```bash
|
||||
./gradlew installFdroidDebug
|
||||
```
|
||||
|
||||
For the Play build:
|
||||
```bash
|
||||
./gradlew installPlayDebug
|
||||
```
|
||||
|
||||
## How to Deploy
|
||||
@@ -120,10 +180,40 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
|
||||
7. Add your CHANGE LOG to the description of the new release
|
||||
8. Download the `aab` file and upload it to the` PlayStore.
|
||||
|
||||
# Privacy on Relays & nostr
|
||||
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
|
||||
|
||||
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
|
||||
|
||||
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.
|
||||
|
||||
# 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 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.
|
||||
|
||||
# Screenshots
|
||||
|
||||
| Home Feed | Messages | Live Streams | Notifications |
|
||||
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
|
||||
|  |  |  |  |
|
||||
|
||||
# Contributing
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
|
||||
</a>
|
||||
|
||||
# MIT License
|
||||
|
||||
Copyright (c) 2023 Vitor Pamplona
|
||||
|
||||
BIN
amethyst.png
BIN
amethyst.png
Binary file not shown.
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 16 KiB |
186
app/build.gradle
186
app/build.gradle
@@ -1,6 +1,8 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
|
||||
id 'com.google.gms.google-services'
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -11,17 +13,19 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 78
|
||||
versionName "0.20.4"
|
||||
versionCode 259
|
||||
versionName "0.70.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resConfigs("ar", "cs", "de", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Make sure all of JSON parsers work when activating these.
|
||||
//minifyEnabled true
|
||||
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
resValue "string", "app_name", "@string/app_name_release"
|
||||
@@ -32,40 +36,73 @@ android {
|
||||
resValue "string", "app_name", "@string/app_name_debug"
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "channel"
|
||||
|
||||
productFlavors {
|
||||
play {
|
||||
getIsDefault().set(true)
|
||||
dimension "channel"
|
||||
}
|
||||
|
||||
fdroid {
|
||||
dimension "channel"
|
||||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include "x86", "x86_64"
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '11'
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion "1.4.3"
|
||||
kotlinCompilerExtensionVersion "1.4.7"
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
// Needs this to open gallery / image upload
|
||||
implementation "androidx.fragment:fragment-ktx:$fragment_version"
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:$nav_version")
|
||||
|
||||
// Observe Live data as State
|
||||
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-beta01'
|
||||
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
|
||||
|
||||
implementation 'androidx.compose.material:material:1.4.0-beta02'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.4.0-beta02"
|
||||
implementation "androidx.compose.material:material:$compose_ui_version"
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
|
||||
|
||||
// Lifecycle
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
|
||||
@@ -73,11 +110,13 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
|
||||
// Swipe Refresh
|
||||
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.1-alpha'
|
||||
implementation "net.engawapg.lib:zoomable:1.4.3"
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
@@ -86,71 +125,128 @@ dependencies {
|
||||
exclude module: 'guava-testlib'
|
||||
}
|
||||
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
// Websockets API
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// Json Serialization
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
|
||||
|
||||
// Robohash for Avatars
|
||||
implementation group: 'com.github.vitorpamplona', name: 'android-robohash', version: 'master-SNAPSHOT', ext: 'aar'
|
||||
// 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 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
implementation 'org.jsoup:jsoup:1.16.1'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.3'
|
||||
implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
|
||||
// important for proxy / tor
|
||||
implementation "androidx.media3:media3-datasource-okhttp:${exoplayer_version}"
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:2.2.2"
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
// view gifs
|
||||
implementation "io.coil-kt:coil-gif:2.2.2"
|
||||
implementation "io.coil-kt:coil-gif:$coil_version"
|
||||
// view svgs
|
||||
implementation("io.coil-kt:coil-svg:2.2.2")
|
||||
implementation "io.coil-kt:coil-svg:$coil_version"
|
||||
|
||||
// create blurhash
|
||||
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
|
||||
|
||||
// Rendering clickable text
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
||||
// Permission to upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
|
||||
// tabs for user profiles
|
||||
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
|
||||
implementation "com.google.accompanist:accompanist-pager-indicators:$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 "androidx.camera:camera-camera2:1.2.1"
|
||||
implementation 'androidx.camera:camera-lifecycle:1.2.1'
|
||||
implementation 'androidx.camera:camera-view:1.2.1'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
|
||||
// For QR Scanning
|
||||
implementation 'com.google.mlkit:vision-common:17.3.0'
|
||||
// Markdown
|
||||
//implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
|
||||
//implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
|
||||
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
|
||||
|
||||
// Local Barcode Scanning model
|
||||
// The idea is to make it work for degoogled phones
|
||||
implementation 'com.google.mlkit:barcode-scanning:17.0.3'
|
||||
// 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-commonmark:a0954aba63')
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
|
||||
implementation 'androidx.media3:media3-ui:1.1.0'
|
||||
implementation 'androidx.media3:media3-session:1.1.0'
|
||||
|
||||
// Local model for language identification
|
||||
implementation 'com.google.mlkit:language-id:17.0.4'
|
||||
playImplementation 'com.google.mlkit:language-id:17.0.4'
|
||||
|
||||
// Google services model the translate text
|
||||
implementation 'com.google.mlkit:translate:17.0.1'
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.1.1')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
// 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")
|
||||
|
||||
// Video compression lib
|
||||
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.10'
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
testImplementation 'io.mockk:mockk:1.13.5'
|
||||
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"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
|
||||
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.
|
||||
import com.android.tools.profgen.ArtProfileKt
|
||||
import com.android.tools.profgen.ArtProfileSerializer
|
||||
import com.android.tools.profgen.DexFile
|
||||
|
||||
project.afterEvaluate {
|
||||
tasks.each { task ->
|
||||
if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) {
|
||||
task.doLast {
|
||||
outputs.files.each { file ->
|
||||
if (file.name.endsWith(".profm")) {
|
||||
println("Sorting ${file} ...")
|
||||
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
|
||||
def profile = ArtProfileKt.ArtProfile(file)
|
||||
def keys = new ArrayList(profile.profileData.keySet())
|
||||
def sortedData = new LinkedHashMap()
|
||||
Collections.sort keys, new DexFile.Companion()
|
||||
keys.each { key -> sortedData[key] = profile.profileData[key] }
|
||||
new FileOutputStream(file).with {
|
||||
write(version.magicBytes$profgen)
|
||||
write(version.versionBytes$profgen)
|
||||
version.write$profgen(it, sortedData, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
app/google-services.json
Normal file
68
app/google-services.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "768341258853",
|
||||
"project_id": "amethyst-3057a",
|
||||
"storage_bucket": "amethyst-3057a.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:768341258853:android:5d07c35a37b24ff36b8c8c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vitorpamplona.amethyst"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:768341258853:android:e0200680f552484d6b8c8c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vitorpamplona.amethyst.debug"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
@@ -25,7 +25,7 @@
|
||||
-keep class fr.acinq.secp256k1.jni.** { *; }
|
||||
# For the NostrPostr library
|
||||
-keep class nostr.postr.** { *; }
|
||||
-keep class nostr.postr.events.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
|
||||
# Json parsing
|
||||
-keep class com.google.gson.reflect.** { *; }
|
||||
-keep class * extends com.google.gson.reflect.TypeToken
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CitationTests {
|
||||
val json = """
|
||||
{
|
||||
"content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
|
||||
"created_at": 1683596206,
|
||||
"id": "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758",
|
||||
"kind": 1,
|
||||
"pubkey": "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d",
|
||||
"sig": "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
"27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3",
|
||||
"",
|
||||
"root"
|
||||
],
|
||||
[
|
||||
"e",
|
||||
"be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc",
|
||||
"",
|
||||
"reply"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://nostr.wine/"
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
@Test
|
||||
fun parseEvent() {
|
||||
val event = Event.fromJson(json, true) as TextNoteEvent
|
||||
|
||||
val expectedCitations = setOf(
|
||||
"8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168",
|
||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
|
||||
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
)
|
||||
|
||||
assertEquals(expectedCitations, event.citedUsers())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.gson.JsonElement
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EventSigCheck {
|
||||
|
||||
val payload1 = "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nI’ll give you one final explanation to rule them all. First, let’s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays it’s 500, others 1000, some as high as 5000. Let’s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That won’t change if you have 20,000 followers or 100,000. You may get back a “different” 5000 each time, but you’ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesn’t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]"
|
||||
|
||||
@Test
|
||||
fun testUnicode2028and2029ShouldNotBeEscaped() {
|
||||
val msg = Event.gson.fromJson(payload1, JsonElement::class.java).asJsonArray
|
||||
val event = Event.fromJson(msg[2], Client.lenient)
|
||||
|
||||
// Should pass
|
||||
event.checkSignature()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
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 kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Base64
|
||||
|
||||
@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 {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
ImgurServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
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.LnZapRequestEvent.Companion.createEncryptionPrivateKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PrivateZapTests {
|
||||
|
||||
@Test
|
||||
fun testPollZap() {
|
||||
val poll = Event.Companion.fromJson(
|
||||
"""{
|
||||
"content": "New poll \n\n #zappoll",
|
||||
"created_at": 1682440713,
|
||||
"id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288",
|
||||
"kind": 6969,
|
||||
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
"sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279",
|
||||
"tags": [
|
||||
[
|
||||
"poll_option",
|
||||
"0",
|
||||
"Test 1"
|
||||
],
|
||||
[
|
||||
"poll_option",
|
||||
"1",
|
||||
"Test 2"
|
||||
],
|
||||
[
|
||||
"value_maximum",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"value_minimum",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"consensus_threshold",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"closed_at",
|
||||
"null"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
|
||||
|
||||
val privateZapRequest = LnZapRequestEvent.create(
|
||||
poll,
|
||||
setOf("wss://relay.damus.io/"),
|
||||
loggedIn,
|
||||
0,
|
||||
"",
|
||||
LnZapEvent.ZapType.PRIVATE
|
||||
)
|
||||
|
||||
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
|
||||
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
} else {
|
||||
fail("Should not be null")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKind1PrivateZap() {
|
||||
val textNote = Event.Companion.fromJson(
|
||||
"""{
|
||||
"content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||
"created_at": 1682369982,
|
||||
"id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1",
|
||||
"kind": 1,
|
||||
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
"sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1",
|
||||
"tags": [
|
||||
[
|
||||
"p",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
|
||||
|
||||
val privateZapRequest = LnZapRequestEvent.create(
|
||||
textNote,
|
||||
setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
|
||||
loggedIn,
|
||||
null,
|
||||
"test",
|
||||
LnZapEvent.ZapType.PRIVATE
|
||||
)
|
||||
|
||||
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
|
||||
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
} else {
|
||||
fail("Should not be null")
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,78 +0,0 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.android.gms.tasks.Tasks
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TranslationsTest {
|
||||
|
||||
fun translatePT(text: String, translateTo: String): String? {
|
||||
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
|
||||
return Tasks.await(task).result
|
||||
}
|
||||
|
||||
fun assertTranslate(expected: String, input: String, translateTo: String) {
|
||||
assertEquals(null, expected, translatePT(input, translateTo))
|
||||
}
|
||||
|
||||
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
|
||||
val translated = translatePT(input, translateTo)!!
|
||||
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslation() {
|
||||
assertTranslate("Olá mundo", "Hello World", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationName() {
|
||||
assertTranslate("Olá Vitor, como você está?", "Hello Vitor, how are you doing?", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationTag() {
|
||||
assertTranslate("Você já viu isso, #[0]", "Have you seen this, #[0]", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationUrl() {
|
||||
assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt")
|
||||
assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt")
|
||||
assertTranslateContains("http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg", "pt")
|
||||
assertTranslateContains("http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg", "pt")
|
||||
assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt")
|
||||
assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt")
|
||||
assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt")
|
||||
assertTranslate("https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg", "pt")
|
||||
assertTranslateContains("https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChineseWithUrlDetector() {
|
||||
assertTranslate("I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端", "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationEmail() {
|
||||
assertTranslateContains("vitor@amethyst.social", "Have you seen this vitor@amethyst.social", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationLnInvoice() {
|
||||
assertTranslateContains(
|
||||
"lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn",
|
||||
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", "pt"
|
||||
)
|
||||
|
||||
assertTranslateContains(
|
||||
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
|
||||
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", "pt"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ 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 org.junit.Assert.*
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@@ -19,82 +19,81 @@ import org.junit.runner.RunWith
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EUrlUserTagTransformationTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
|
||||
}
|
||||
class UrlUserTagTransformationTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transformationText() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
@Test
|
||||
fun transformationText() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
var transformedText = buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
|
||||
Color.Red
|
||||
)
|
||||
val transformedText = buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
|
||||
Color.Red
|
||||
)
|
||||
|
||||
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
|
||||
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
|
||||
|
||||
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
|
||||
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
|
||||
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
|
||||
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
|
||||
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
|
||||
|
||||
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
|
||||
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
|
||||
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8))
|
||||
assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9))
|
||||
|
||||
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
|
||||
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
|
||||
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8))
|
||||
assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9))
|
||||
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
|
||||
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
|
||||
}
|
||||
|
||||
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
|
||||
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
|
||||
}
|
||||
@Test
|
||||
fun transformationTextTwoKeys() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
@Test
|
||||
fun transformationTextTwoKeys() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
val transformedText = buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
|
||||
Color.Red
|
||||
)
|
||||
|
||||
var transformedText = buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
|
||||
Color.Red
|
||||
)
|
||||
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
|
||||
|
||||
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11))
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12))
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
|
||||
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11))
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12))
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
|
||||
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
|
||||
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
|
||||
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
|
||||
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
|
||||
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @
|
||||
assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n
|
||||
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
|
||||
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
|
||||
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
|
||||
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
|
||||
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
|
||||
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @
|
||||
assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n
|
||||
|
||||
assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
|
||||
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
|
||||
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
|
||||
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
|
||||
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
|
||||
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
|
||||
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @
|
||||
}
|
||||
}
|
||||
assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
|
||||
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
|
||||
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
|
||||
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
|
||||
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
|
||||
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
|
||||
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.android.gms.tasks.Tasks
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TranslationsTest {
|
||||
|
||||
fun translateTo(text: String, translateTo: String): String? {
|
||||
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
|
||||
return Tasks.await(task).result
|
||||
}
|
||||
|
||||
fun assertTranslate(expected: String, input: String, translateTo: String) {
|
||||
assertEquals(null, expected, translateTo(input, translateTo))
|
||||
}
|
||||
|
||||
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
|
||||
val translated = translateTo(input, translateTo)!!
|
||||
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslation() {
|
||||
assertTranslate("Olá mundo", "Hello World", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationName() {
|
||||
assertTranslate("Olá Vitor, como você está?", "Hello Vitor, how are you doing?", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationTag() {
|
||||
assertTranslate("Você já viu isso, #[0]", "Have you seen this, #[0]", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationUrl() {
|
||||
assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt")
|
||||
assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt")
|
||||
assertTranslateContains("http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg", "pt")
|
||||
assertTranslateContains("http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg", "pt")
|
||||
assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt")
|
||||
assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt")
|
||||
assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt")
|
||||
assertTranslate("https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg", "pt")
|
||||
assertTranslateContains("https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChineseWithUrlDetector() {
|
||||
assertTranslate("I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端", "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationEmail() {
|
||||
assertTranslateContains("vitor@amethyst.social", "Have you seen this vitor@amethyst.social", "pt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationLnInvoice() {
|
||||
assertTranslateContains(
|
||||
"lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn",
|
||||
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay",
|
||||
"pt"
|
||||
)
|
||||
|
||||
assertTranslateContains(
|
||||
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
|
||||
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
|
||||
"pt"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNostrEvents() {
|
||||
assertTranslateContains(
|
||||
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
|
||||
"sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
|
||||
"en"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testJapaneseTranslationsOfUrl() {
|
||||
assertTranslateContains(
|
||||
"https://youtu.be/wMYFmCDy_Eg",
|
||||
"うちの会社の小さい先輩の話 第1話「うちの会社の先輩は小さくて可愛い」\n" +
|
||||
"\n" +
|
||||
"https://youtu.be/wMYFmCDy_Eg\n" +
|
||||
"\n" +
|
||||
"先輩がうざい後輩の話と似たような話かと思ったけど、もっとオタクの妄想あるある的なものを詰め込んだやつだ。ワードとかシチュエーションとか、ヒロインのサイズ感とか。知らんけど",
|
||||
"en"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
|
||||
class PushNotificationUtils {
|
||||
fun init(accounts: List<AccountInfo>) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun TranslatableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) = ExpandableRichTextViewer(
|
||||
content,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
@@ -1,32 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<queries>
|
||||
<package android:name="org.torproject.android"/>
|
||||
</queries>
|
||||
|
||||
<!-- Doesn't require a camera -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<!-- To connect with relays -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- To Upload media (newer devices) -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
|
||||
<!-- To Upload media (old devices) -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
|
||||
<!-- To read QRs that contain nostr:<NIP19> -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Used for SDK < 29 -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
<!-- To read NFCs that contain nostr:<NIP19> -->
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
<!-- 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" />
|
||||
|
||||
<!-- Old permission to access media -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:name=".Amethyst"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/amethyst"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Amethyst"
|
||||
android:largeHeap="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
tools:targetApi="33">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
@@ -41,10 +75,41 @@
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrwalletconnect" />
|
||||
<data android:scheme="nostr+walletconnect" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.lib_name"
|
||||
android:value="" />
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<service
|
||||
android:name=".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>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
15
app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
Normal file
15
app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class Amethyst : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: Amethyst
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,29 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
class EncryptedStorage {
|
||||
object EncryptedStorage {
|
||||
private const val PREFERENCES_NAME = "secret_keeper"
|
||||
|
||||
fun preferences(context: Context): EncryptedSharedPreferences {
|
||||
val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
|
||||
val preferencesName = "secret_keeper"
|
||||
fun prefsFileName(npub: String? = null): String {
|
||||
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
|
||||
}
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
preferencesName,
|
||||
secretKey,
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
) as EncryptedSharedPreferences
|
||||
}
|
||||
fun preferences(npub: String? = null): EncryptedSharedPreferences {
|
||||
val context = Amethyst.instance
|
||||
val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
}
|
||||
val preferencesName = prefsFileName(npub)
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
preferencesName,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
) as EncryptedSharedPreferences
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +1,486 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import java.util.Locale
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
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.events.ContactListEvent
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.Event.Companion.getRefinedEvent
|
||||
import nostr.postr.toHex
|
||||
import nostr.postr.toNpub
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
class LocalPreferences(context: Context) {
|
||||
val encryptedPreferences = EncryptedStorage().preferences(context)
|
||||
val gson = GsonBuilder().create()
|
||||
// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
|
||||
// To use plaintext SharedPreferences for debugging, set this to true
|
||||
// It will only apply in Debug builds
|
||||
private const val DEBUG_PLAINTEXT_PREFERENCES = false
|
||||
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
|
||||
|
||||
fun clearEncryptedStorage() {
|
||||
encryptedPreferences.edit().apply {
|
||||
remove("nostr_privkey")
|
||||
remove("nostr_pubkey")
|
||||
remove("following_channels")
|
||||
remove("hidden_users")
|
||||
remove("relays")
|
||||
remove("dontTranslateFrom")
|
||||
remove("languagePreferences")
|
||||
remove("translateTo")
|
||||
remove("zapAmounts")
|
||||
remove("latestContactList")
|
||||
}.apply()
|
||||
}
|
||||
@Immutable
|
||||
data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean = false
|
||||
)
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
encryptedPreferences.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
|
||||
account.followingChannels.let { putStringSet("following_channels", it) }
|
||||
account.hiddenUsers.let { putStringSet("hidden_users", it) }
|
||||
account.localRelays.let { putString("relays", gson.toJson(it)) }
|
||||
account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) }
|
||||
account.languagePreferences.let { putString("languagePreferences", gson.toJson(it)) }
|
||||
account.translateTo.let { putString("translateTo", it) }
|
||||
account.zapAmountChoices.let { putString("zapAmounts", gson.toJson(it)) }
|
||||
account.backupContactList.let { putString("latestContactList", Event.gson.toJson(it)) }
|
||||
}.apply()
|
||||
}
|
||||
private object PrefKeys {
|
||||
const val CURRENT_ACCOUNT = "currently_logged_in_account"
|
||||
const val SAVED_ACCOUNTS = "all_saved_accounts"
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val FOLLOWING_COMMUNITIES = "following_communities"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val REACTION_CHOICES = "reactionChoices"
|
||||
const val DEFAULT_ZAPTYPE = "defaultZapType"
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
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 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"
|
||||
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"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences.apply {
|
||||
val privKey = getString("nostr_privkey", null)
|
||||
val pubKey = getString("nostr_pubkey", null)
|
||||
val followingChannels = getStringSet("following_channels", null) ?: setOf()
|
||||
val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf()
|
||||
val localRelays = gson.fromJson(
|
||||
getString("relays", "[]"),
|
||||
object : TypeToken<Set<RelaySetupInfo>>() {}.type
|
||||
) ?: setOf<RelaySetupInfo>()
|
||||
private val gson = GsonBuilder().create()
|
||||
|
||||
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
|
||||
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language
|
||||
object LocalPreferences {
|
||||
private const val comma = ","
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString("zapAmounts", "[]"),
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
private var _currentAccount: String? = null
|
||||
|
||||
val latestContactList = try {
|
||||
getString("latestContactList", null)?.let {
|
||||
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
|
||||
private fun currentAccount(): String? {
|
||||
if (_currentAccount == null) {
|
||||
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
return _currentAccount
|
||||
}
|
||||
|
||||
val languagePreferences = try {
|
||||
getString("languagePreferences", null)?.let {
|
||||
gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String>
|
||||
} ?: mapOf<String,String>()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
mapOf<String,String>()
|
||||
}
|
||||
private fun updateCurrentAccount(npub: String) {
|
||||
if (_currentAccount != npub) {
|
||||
_currentAccount = npub
|
||||
|
||||
if (pubKey != null) {
|
||||
return Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
localRelays,
|
||||
dontTranslateFrom,
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
latestContactList
|
||||
encryptedPreferences().edit().apply {
|
||||
putString(PrefKeys.CURRENT_ACCOUNT, npub)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private var _savedAccounts: List<String>? = null
|
||||
|
||||
private fun savedAccounts(): List<String> {
|
||||
if (_savedAccounts == null) {
|
||||
_savedAccounts = encryptedPreferences()
|
||||
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
|
||||
}
|
||||
return _savedAccounts!!
|
||||
}
|
||||
|
||||
private fun updateSavedAccounts(accounts: List<String>) {
|
||||
if (_savedAccounts != accounts) {
|
||||
_savedAccounts = accounts
|
||||
|
||||
encryptedPreferences().edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
|
||||
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 fun setCurrentAccount(account: Account) {
|
||||
val npub = account.userProfile().pubkeyNpub()
|
||||
updateCurrentAccount(npub)
|
||||
addAccount(npub)
|
||||
}
|
||||
|
||||
fun switchToAccount(npub: String) {
|
||||
updateCurrentAccount(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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the npub-specific shared preference file
|
||||
*/
|
||||
private fun deleteUserPreferenceFile(npub: String) {
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
File(prefsDir, it).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
|
||||
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)
|
||||
} else {
|
||||
return EncryptedStorage.preferences(npub)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the preferences for a given npub, deletes the preferences xml file,
|
||||
* and switches the user to the first account in the list if it exists
|
||||
*
|
||||
* We need to use `commit()` to write changes to disk and release the file
|
||||
* lock so that it can be deleted. If we use `apply()` there is a race
|
||||
* condition and the file will probably not be deleted
|
||||
*/
|
||||
@SuppressLint("ApplySharedPref")
|
||||
fun updatePrefsForLogout(npub: String) {
|
||||
val userPrefs = encryptedPreferences(npub)
|
||||
userPrefs.edit().clear().commit()
|
||||
removeAccount(npub)
|
||||
deleteUserPreferenceFile(npub)
|
||||
|
||||
if (savedAccounts().isEmpty()) {
|
||||
val appPrefs = encryptedPreferences()
|
||||
appPrefs.edit().clear().apply()
|
||||
} else if (currentAccount() == npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0))
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrefsForLogin(account: Account) {
|
||||
setCurrentAccount(account)
|
||||
saveToEncryptedStorage(account)
|
||||
}
|
||||
|
||||
fun allSavedAccounts(): List<AccountInfo> {
|
||||
return savedAccounts().map { npub ->
|
||||
AccountInfo(npub = npub)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
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()) }
|
||||
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))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
||||
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(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.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))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
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))
|
||||
|
||||
if (account.showSensitiveContent == null) {
|
||||
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
|
||||
} else {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
|
||||
}
|
||||
}.apply()
|
||||
|
||||
val globalPrefs = encryptedPreferences()
|
||||
globalPrefs.edit().apply {
|
||||
if (account.settings.automaticallyShowImages == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, account.settings.automaticallyShowImages!!)
|
||||
}
|
||||
|
||||
if (account.settings.automaticallyStartPlayback == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_START_PLAYBACK)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, account.settings.automaticallyStartPlayback!!)
|
||||
}
|
||||
if (account.settings.automaticallyShowUrlPreview == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview!!)
|
||||
}
|
||||
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun updateTheme(theme: Int) {
|
||||
encryptedPreferences().edit().apply {
|
||||
putInt(PrefKeys.THEME, theme)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun getTheme(): Int {
|
||||
encryptedPreferences().apply {
|
||||
return getInt(PrefKeys.THEME, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPreferredLanguage(): String {
|
||||
var language = ""
|
||||
encryptedPreferences().apply {
|
||||
language = getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
|
||||
}
|
||||
return language
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
val acc = loadFromEncryptedStorage(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)
|
||||
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 dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
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 reactionChoices = gson.fromJson<List<String>>(
|
||||
getString(PrefKeys.REACTION_CHOICES, "[]"),
|
||||
object : TypeToken<List<String>>() {}.type
|
||||
).ifEmpty { listOf("+") } ?: listOf("+")
|
||||
|
||||
val defaultZapType = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
|
||||
object : TypeToken<LnZapEvent.ZapType>() {}.type
|
||||
) ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "NOSTR_BUILD"),
|
||||
object : TypeToken<ServersAvailable>() {}.type
|
||||
) ?: ServersAvailable.NOSTR_BUILD
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
gson.fromJson(it, Nip47URI::class.java)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val latestContactList = try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
Event.gson.fromJson(it, Event::class.java)
|
||||
.getRefinedEvent(true) as ContactListEvent
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val languagePreferences = try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
gson.fromJson(
|
||||
it,
|
||||
object : TypeToken<Map<String, String>>() {}.type
|
||||
) as Map<String, String>
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_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)
|
||||
|
||||
val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
|
||||
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
|
||||
|
||||
val lastReadPerRoute = try {
|
||||
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
|
||||
gson.fromJson(
|
||||
it,
|
||||
object : TypeToken<Map<String, Long>>() {}.type
|
||||
) as Map<String, Long>
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val settings = Settings()
|
||||
encryptedPreferences().apply {
|
||||
settings.automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
|
||||
getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
settings.automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
settings.automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
|
||||
getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
|
||||
}
|
||||
|
||||
val a = Account(
|
||||
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels = followingChannels,
|
||||
followingCommunities = followingCommunities,
|
||||
hiddenUsers = hiddenUsers,
|
||||
localRelays = localRelays,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
translateTo = translateTo,
|
||||
zapAmountChoices = zapAmountChoices,
|
||||
reactionChoices = reactionChoices,
|
||||
defaultZapType = defaultZapType,
|
||||
defaultFileServer = defaultFileServer,
|
||||
defaultHomeFollowList = defaultHomeFollowList,
|
||||
defaultStoriesFollowList = defaultStoriesFollowList,
|
||||
defaultNotificationFollowList = defaultNotificationFollowList,
|
||||
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
backupContactList = latestContactList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
showSensitiveContent = showSensitiveContent,
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
settings = settings
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLastRead(route: String, timestampInSecs: Long) {
|
||||
encryptedPreferences.edit().apply {
|
||||
putLong("last_read_route_${route}", timestampInSecs)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
encryptedPreferences.run {
|
||||
return getLong("last_read_route_${route}", 0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.LruCache
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.STATE_IDLE
|
||||
import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
class MultiPlayerPlaybackManager(
|
||||
private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null,
|
||||
private val cachedPositions: VideoViewedPositionCache
|
||||
) {
|
||||
// protects from LruCache killing playing sessions
|
||||
private val playingMap = mutableMapOf<String, MediaSession>()
|
||||
|
||||
private val cache =
|
||||
object : LruCache<String, MediaSession>(10) { // up to 10 videos in the screen at the same time
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: String?,
|
||||
oldValue: MediaSession?,
|
||||
newValue: MediaSession?
|
||||
) {
|
||||
super.entryRemoved(evicted, key, oldValue, newValue)
|
||||
|
||||
if (!playingMap.contains(key)) {
|
||||
oldValue?.let {
|
||||
it.player.release()
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCallbackIntent(callbackUri: String, applicationContext: Context): PendingIntent {
|
||||
return PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun getMediaSession(id: String, uri: String, callbackUri: String?, context: Context, applicationContext: Context): MediaSession {
|
||||
val existingSession = playingMap.get(id) ?: cache.get(id)
|
||||
if (existingSession != null) return existingSession
|
||||
|
||||
val player = ExoPlayer.Builder(context).run {
|
||||
dataSourceFactory?.let { setMediaSourceFactory(it) }
|
||||
build()
|
||||
}
|
||||
|
||||
player.apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||
volume = 0f
|
||||
setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
}
|
||||
|
||||
val mediaSession = MediaSession.Builder(context, player).run {
|
||||
callbackUri?.let {
|
||||
setSessionActivity(getCallbackIntent(it, applicationContext))
|
||||
}
|
||||
setId(id)
|
||||
build()
|
||||
}
|
||||
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) {
|
||||
playingMap.put(id, mediaSession)
|
||||
} else {
|
||||
cache.put(id, mediaSession)
|
||||
playingMap.remove(id, mediaSession)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
STATE_IDLE -> {
|
||||
if (player.currentPosition > 5 * 60) { // 5 seconds
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
STATE_READY -> {
|
||||
cachedPositions.get(uri)?.let { lastPosition ->
|
||||
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
|
||||
player.seekTo(lastPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (player.currentPosition > 5 * 60) { // 5 seconds
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cache.put(id, mediaSession)
|
||||
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
fun releaseAppPlayers() {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
cache.evictAll()
|
||||
playingMap.forEach {
|
||||
it.value.player.release()
|
||||
it.value.release()
|
||||
}
|
||||
playingMap.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun playingContent(): Collection<MediaSession> {
|
||||
return playingMap.values
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object NotificationCache {
|
||||
val lastReadByRoute = mutableMapOf<String, Long>()
|
||||
|
||||
fun markAsRead(route: String, timestampInSecs: Long, context: Context) {
|
||||
val lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null || timestampInSecs > lastTime) {
|
||||
lastReadByRoute.put(route, timestampInSecs)
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
LocalPreferences(context).saveLastRead(route, timestampInSecs)
|
||||
live.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(route: String, context: Context): Long {
|
||||
var lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null) {
|
||||
lastTime = LocalPreferences(context).loadLastRead(route)
|
||||
lastReadByRoute[route] = lastTime
|
||||
}
|
||||
return lastTime
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: NotificationLiveData = NotificationLiveData(this)
|
||||
}
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationState(val cache: NotificationCache)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
object OptOutFromFilters {
|
||||
var warnAboutPostsWithReports: Boolean = true
|
||||
var filterSpamFromStrangers: Boolean = true
|
||||
|
||||
fun start(warnAboutReports: Boolean, filterSpam: Boolean) {
|
||||
warnAboutPostsWithReports = warnAboutReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
|
||||
object PlaybackClientController {
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun prepareController(
|
||||
controllerID: String,
|
||||
videoUri: String,
|
||||
callbackUri: String?,
|
||||
context: Context,
|
||||
onReady: (MediaController) -> Unit
|
||||
) {
|
||||
// creating a bundle object
|
||||
// creating a bundle object
|
||||
val bundle = Bundle()
|
||||
bundle.putString("id", controllerID)
|
||||
bundle.putString("uri", videoUri)
|
||||
bundle.putString("callbackUri", callbackUri)
|
||||
|
||||
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
val controllerFuture = MediaController
|
||||
.Builder(context, sessionTokenLocal)
|
||||
.setConnectionHints(bundle)
|
||||
.buildAsync()
|
||||
|
||||
controllerFuture.addListener(
|
||||
{
|
||||
val controller = controllerFuture.get()
|
||||
onReady(controller)
|
||||
},
|
||||
MoreExecutors.directExecutor()
|
||||
)
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt
Normal file
124
app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
@UnstableApi // Extend MediaSessionService
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private var videoViewedPositionCache = VideoViewedPositionCache()
|
||||
|
||||
private var managerHls: MultiPlayerPlaybackManager? = null
|
||||
private var managerProgressive: MultiPlayerPlaybackManager? = null
|
||||
private var managerLocal: MultiPlayerPlaybackManager? = null
|
||||
|
||||
fun newHslDataSource(): MediaSource.Factory {
|
||||
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))
|
||||
}
|
||||
|
||||
fun newProgressiveDataSource(): MediaSource.Factory {
|
||||
return ProgressiveMediaSource.Factory(VideoCache.get(applicationContext, HttpClient.getHttpClient()))
|
||||
}
|
||||
|
||||
// Create your Player and MediaSession in the onCreate lifecycle event
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
managerLocal = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
|
||||
|
||||
// Stop all videos and recreates all managers when the proxy changes.
|
||||
HttpClient.proxyChangeListeners.add {
|
||||
val toDestroyHls = managerHls
|
||||
val toDestroyProgressive = managerProgressive
|
||||
|
||||
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
|
||||
toDestroyHls?.releaseAppPlayers()
|
||||
toDestroyProgressive?.releaseAppPlayers()
|
||||
}
|
||||
|
||||
setMediaNotificationProvider(
|
||||
DefaultMediaNotificationProvider.Builder(applicationContext).build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
managerHls?.releaseAppPlayers()
|
||||
managerLocal?.releaseAppPlayers()
|
||||
managerProgressive?.releaseAppPlayers()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
|
||||
return if (fileName.startsWith("file")) {
|
||||
managerLocal
|
||||
} else if (fileName.endsWith("m3u8")) {
|
||||
managerHls
|
||||
} else {
|
||||
managerProgressive
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
// Updates any new player ready
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
|
||||
// Overrides the notification with any player actually playing
|
||||
managerHls?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerLocal?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerProgressive?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides again with playing with audio
|
||||
managerHls?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerLocal?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerProgressive?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return a MediaSession to link with the MediaController that is making
|
||||
// this request.
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
val id = controllerInfo.connectionHints.getString("id") ?: return null
|
||||
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
|
||||
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
|
||||
|
||||
val manager = getAppropriateMediaSessionManager(uri)
|
||||
|
||||
return manager?.getMediaSession(id, uri, callbackUri, context = this, applicationContext = applicationContext)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import java.util.UUID
|
||||
import name.neuhalfen.projects.android.robohash.RoboHash
|
||||
|
||||
object RoboHashCache {
|
||||
|
||||
lateinit var robots: RoboHash
|
||||
|
||||
lateinit var defaultAvatar: ImageBitmap
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context, hash: String): ImageBitmap {
|
||||
if (!this::robots.isInitialized) {
|
||||
robots = RoboHash(context)
|
||||
//robots.useCache(LruCache(100));
|
||||
|
||||
defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap()
|
||||
}
|
||||
|
||||
return defaultAvatar
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,79 +1,121 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
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 com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
|
||||
object ServiceManager {
|
||||
private var account: Account? = null
|
||||
private var account: Account? = null
|
||||
|
||||
fun start(account: Account) {
|
||||
this.account = account
|
||||
start()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
val myAccount = account
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrAccountDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleChannelDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
} else {
|
||||
// if not logged in yet, start a basic service wit default relays
|
||||
Client.connect(Constants.convertDefaultRelays())
|
||||
fun start(account: Account, context: Context) {
|
||||
this.account = account
|
||||
start(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
@Synchronized
|
||||
fun start(context: Context) {
|
||||
Log.d("ServiceManager", "Starting Relay Services")
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
val myAccount = account
|
||||
|
||||
Client.disconnect()
|
||||
}
|
||||
// Resets Proxy Use
|
||||
HttpClient.start(account)
|
||||
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(context).components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
} // .logger(DebugLogger())
|
||||
.okHttpClient { HttpClient.getHttpClient() }
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
LocalCache.cleanObservers()
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
//LocalCache.pruneNonFollows(it)
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
NostrDiscoveryDataSource.account = myAccount
|
||||
ImageUploader.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
NostrDiscoveryDataSource.start()
|
||||
NostrVideoDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleChannelDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
fun pause() {
|
||||
Log.d("ServiceManager", "Pausing Relay Services")
|
||||
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrDiscoveryDataSource.stop()
|
||||
|
||||
NostrCommunityDataSource.stop()
|
||||
NostrHashtagDataSource.stop()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
NostrVideoDataSource.stop()
|
||||
|
||||
Client.disconnect()
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
LocalCache.cleanObservers()
|
||||
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(it)
|
||||
// LocalCache.pruneNonFollows(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,54 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
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 androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
object VideoCache {
|
||||
@UnstableApi object VideoCache {
|
||||
|
||||
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
|
||||
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
|
||||
|
||||
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
|
||||
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
|
||||
|
||||
lateinit var exoDatabaseProvider: StandaloneDatabaseProvider
|
||||
lateinit var simpleCache: SimpleCache
|
||||
lateinit var exoDatabaseProvider: StandaloneDatabaseProvider
|
||||
lateinit var simpleCache: SimpleCache
|
||||
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
fun get(context: Context): CacheDataSource.Factory {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
@Synchronized
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
private fun init(context: Context, client: OkHttpClient) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
|
||||
renewCacheFactory(client)
|
||||
}
|
||||
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
// This method should be called when proxy setting changes.
|
||||
fun renewCacheFactory(client: OkHttpClient) {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(client)
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
fun get(context: Context, client: OkHttpClient): CacheDataSource.Factory {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
init(context, client)
|
||||
}
|
||||
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.util.LruCache
|
||||
|
||||
class VideoViewedPositionCache {
|
||||
val cachedPosition = LruCache<String, Long>(100)
|
||||
|
||||
fun add(uri: String, position: Long) {
|
||||
cachedPosition.put(uri, position)
|
||||
}
|
||||
|
||||
fun get(uri: String): Long? {
|
||||
return cachedPosition.get(uri)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,93 +2,90 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.toHex
|
||||
|
||||
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
|
||||
|
||||
class AntiSpamFilter {
|
||||
val recentMessages = LruCache<Int, String>(1000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
val recentMessages = LruCache<Int, String>(1000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
@Synchronized
|
||||
fun isSpam(event: Event): Boolean {
|
||||
val idHex = event.id.toHexKey()
|
||||
fun isSpam(event: Event, relay: Relay?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
// if already processed, ok
|
||||
if (LocalCache.notes[idHex] != null) return false
|
||||
if (!OptOutFromFilters.filterSpamFromStrangers) return false
|
||||
|
||||
// if short message, ok
|
||||
if (event.content.length < 50) return false
|
||||
val idHex = event.id
|
||||
|
||||
// double list strategy:
|
||||
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
|
||||
// if short message, ok
|
||||
// The idea here is to avoid considering repeated "GM" messages spam.
|
||||
if (event.content.length < 50) return false
|
||||
|
||||
// Considers tags so that same replies to different people don't count.
|
||||
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
|
||||
// if the message is actually short but because it cites a user/event, the nostr: string is really long, make it ok.
|
||||
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still blocking repeated "lnbc..." invoices or fishing urls
|
||||
if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false
|
||||
|
||||
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
|
||||
Log.w("Potential SPAM Message", "${event.id.toHex()} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
|
||||
// double list strategy:
|
||||
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
|
||||
|
||||
// Log down offenders
|
||||
if (spamMessages.get(hash) == null) {
|
||||
spamMessages.put(hash, Spammer(event.pubKey.toHexKey(), setOf(recentMessages[hash], event.id.toHex())))
|
||||
liveSpam.invalidateData()
|
||||
} else {
|
||||
val spammer = spamMessages.get(hash)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id.toHex()
|
||||
// Considers tags so that same replies to different people don't count.
|
||||
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
|
||||
|
||||
liveSpam.invalidateData()
|
||||
}
|
||||
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
|
||||
Log.w("Potential SPAM Message for sharing", "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}")
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
|
||||
|
||||
return true
|
||||
}
|
||||
// Log down offenders
|
||||
logOffender(hash, event)
|
||||
|
||||
recentMessages.put(hash, idHex)
|
||||
liveSpam.invalidateData()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
|
||||
}
|
||||
|
||||
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter): LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
recentMessages.put(hash, idHex)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun logOffender(hashCode: Int, event: Event) {
|
||||
if (spamMessages.get(hashCode) == null) {
|
||||
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
|
||||
} else {
|
||||
val spammer = spamMessages.get(hashCode)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
|
||||
}
|
||||
}
|
||||
|
||||
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
|
||||
}
|
||||
|
||||
class AntiSpamState(val cache: AntiSpamFilter) {
|
||||
@Stable
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
}
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AntiSpamState(val cache: AntiSpamFilter)
|
||||
|
||||
@@ -1,49 +1,128 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
@Stable
|
||||
class PublicChatChannel(idHex: String) : Channel(idHex) {
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
|
||||
var updatedMetadataAt: Long = 0;
|
||||
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
fun idNote() = id().toNote()
|
||||
fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return info.name ?: idDisplayNote()
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
notes[note.idHex] = note
|
||||
}
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.refresh()
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) info.picture = null
|
||||
return info.picture
|
||||
override fun toBestDisplayName(): String {
|
||||
return info.name ?: super.toBestDisplayName()
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
override fun summary(): String? {
|
||||
return info.about
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) return super.profilePicture()
|
||||
return info.picture ?: super.profilePicture()
|
||||
}
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info.name, info.about)
|
||||
.filter { it.startsWith(prefix, true) }.isNotEmpty()
|
||||
.filter { it.contains(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
|
||||
var info: LiveActivitiesEvent? = null
|
||||
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
fun address() = address
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) {
|
||||
this.info = channelInfo
|
||||
super.updateChannelInfo(creator, updatedAt)
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String {
|
||||
return info?.title() ?: super.toBestDisplayName()
|
||||
}
|
||||
|
||||
override fun summary(): String? {
|
||||
return info?.summary()
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
return info?.image()?.ifBlank { null }
|
||||
}
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info?.title(), info?.summary())
|
||||
.filter { it.contains(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
abstract class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
|
||||
open fun id() = Hex.decode(idHex)
|
||||
open fun idNote() = id().toNote()
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
open fun toBestDisplayName(): String {
|
||||
return idDisplayNote()
|
||||
}
|
||||
|
||||
open fun summary(): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
open fun creatorName(): String? {
|
||||
return creator?.toBestDisplayName()
|
||||
}
|
||||
|
||||
open fun profilePicture(): String? {
|
||||
return creator?.profilePicture()
|
||||
}
|
||||
|
||||
open fun updateChannelInfo(creator: User, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
notes[note.idHex] = note
|
||||
}
|
||||
|
||||
fun removeNote(note: Note) {
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
|
||||
fun removeNote(noteHex: String) {
|
||||
notes.remove(noteHex)
|
||||
}
|
||||
|
||||
abstract fun anyNameStartsWith(prefix: String): Boolean
|
||||
|
||||
// Observers line up here.
|
||||
val live: ChannelLiveData = ChannelLiveData(this)
|
||||
@@ -51,7 +130,7 @@ class Channel(val idHex: String) {
|
||||
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
|
||||
val important = notes.values
|
||||
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
.take(1000)
|
||||
.toSet()
|
||||
@@ -66,20 +145,37 @@ class Channel(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
class ChannelLiveData(val channel: Channel): LiveData<ChannelState>(ChannelState(channel)) {
|
||||
fun refresh() {
|
||||
postValue(ChannelState(channel))
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
if (hasActiveObservers()) {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
if (channel is PublicChatChannel) {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
} else {
|
||||
NostrSingleChannelDataSource.remove(channel.idHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
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))
|
||||
"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))
|
||||
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"cashu", "ecash", "nut", "nuts", "deeznuts" -> HashtagIcon(R.drawable.cashu, "Cashu", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
|
||||
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
|
||||
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
|
||||
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
|
||||
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
|
||||
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class HashtagIcon(
|
||||
val icon: Int,
|
||||
val description: String,
|
||||
val color: Color,
|
||||
val modifier: Modifier
|
||||
)
|
||||
@@ -1,11 +1,13 @@
|
||||
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
|
||||
@@ -14,69 +16,104 @@ typealias NPubKey = String
|
||||
typealias NoteId = String
|
||||
|
||||
fun NPubKey.toDisplayKey(): String {
|
||||
return this.toShortenHex()
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun NoteId.toDisplayId(): String {
|
||||
return this.toShortenHex()
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
|
||||
|
||||
fun ByteArray.toHexKey(): HexKey {
|
||||
return toHex()
|
||||
return toHex()
|
||||
}
|
||||
|
||||
fun HexKey.toByteArray(): ByteArray {
|
||||
return Hex.decode(this)
|
||||
fun HexKey.hexToByteArray(): ByteArray {
|
||||
return Hex.decode(this)
|
||||
}
|
||||
|
||||
fun HexKey.toDisplayHexKey(): String {
|
||||
return this.toShortenHex()
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray {
|
||||
return if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
} else if (key.startsWith("npub")) {
|
||||
key.bechToBytes()
|
||||
} else if (key.startsWith("note")) {
|
||||
key.bechToBytes()
|
||||
} else { //if (pattern.matcher(key).matches()) {
|
||||
//} else {
|
||||
Hex.decode(key)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
data class DirtyKeyInfo(val type: String, val keyHex: String, val restOfWord: String)
|
||||
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)) {
|
||||
return DirtyKeyInfo("npub", Persona(privKey = keyB32.bechToBytes()).pubKey.toHexKey(), restOfWord)
|
||||
} else if (key.startsWith("npub1", true)) {
|
||||
return DirtyKeyInfo("npub", keyB32.bechToBytes().toHexKey(), restOfWord)
|
||||
} else if (key.startsWith("note1", true)) {
|
||||
return DirtyKeyInfo("note", keyB32.bechToBytes().toHexKey(), restOfWord)
|
||||
var key = mightBeAKey
|
||||
if (key.startsWith("nostr:", true)) {
|
||||
key = key.substring("nostr:".length)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
key = key.removePrefix("@")
|
||||
|
||||
return null
|
||||
}
|
||||
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
@@ -1,43 +1,58 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
|
||||
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.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.events.Event
|
||||
|
||||
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
|
||||
class Note(val idHex: String) {
|
||||
@Stable
|
||||
class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun toNEvent() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
override fun address() = address
|
||||
override fun createdAt(): Long? {
|
||||
if (event == null) return null
|
||||
|
||||
val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
|
||||
val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE
|
||||
|
||||
return minOf(publishedAt, lastCreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
open class Note(val idHex: String) {
|
||||
// These fields are only available after the Text Note event is received.
|
||||
// They are immutable after that.
|
||||
var event: Event? = null
|
||||
var event: EventInterface? = null
|
||||
var author: User? = null
|
||||
var mentions: List<User>? = null
|
||||
var replyTo: List<Note>? = null
|
||||
|
||||
// These fields are updated every time an event related to this note is received.
|
||||
var replies = setOf<Note>()
|
||||
private set
|
||||
var reactions = setOf<Note>()
|
||||
var reactions = mapOf<String, Set<Note>>()
|
||||
private set
|
||||
var boosts = setOf<Note>()
|
||||
private set
|
||||
@@ -45,22 +60,51 @@ class Note(val idHex: String) {
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = setOf<String>()
|
||||
private set
|
||||
|
||||
var channel: Channel? = null
|
||||
|
||||
var lastReactionsDownloadTime: Long? = null
|
||||
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
fun idNote() = id().toNote()
|
||||
fun idDisplayNote() = idNote().toShortenHex()
|
||||
open fun idNote() = id().toNote()
|
||||
|
||||
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
|
||||
open fun toNEvent(): String {
|
||||
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
|
||||
fun toNostrUri(): String {
|
||||
return "nostr:${toNEvent()}"
|
||||
}
|
||||
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun channelHex(): HexKey? {
|
||||
return if (event is ChannelMessageEvent ||
|
||||
event is ChannelMetadataEvent ||
|
||||
event is ChannelCreateEvent ||
|
||||
event is LiveActivitiesChatMessageEvent ||
|
||||
event is LiveActivitiesEvent
|
||||
) {
|
||||
(event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
|
||||
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
open fun address(): ATag? = null
|
||||
|
||||
open fun createdAt() = event?.createdAt()
|
||||
|
||||
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
|
||||
this.event = event
|
||||
this.author = author
|
||||
this.mentions = mentions
|
||||
this.replyTo = replyTo
|
||||
|
||||
liveSet?.metadata?.invalidateData()
|
||||
@@ -76,20 +120,20 @@ class Note(val idHex: String) {
|
||||
*/
|
||||
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
|
||||
val replyTo = replyTo
|
||||
if (replyTo == null || replyTo.isEmpty()) {
|
||||
return "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
|
||||
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
|
||||
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
}
|
||||
|
||||
return replyTo
|
||||
.map {
|
||||
cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) }
|
||||
}
|
||||
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
|
||||
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";"
|
||||
}
|
||||
|
||||
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
|
||||
val replyTo = replyTo
|
||||
if (replyTo == null || replyTo.isEmpty()) {
|
||||
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -114,14 +158,27 @@ class Note(val idHex: String) {
|
||||
liveSet?.boosts?.invalidateData()
|
||||
}
|
||||
fun removeReaction(note: Note) {
|
||||
reactions = reactions - note
|
||||
liveSet?.reactions?.invalidateData()
|
||||
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)
|
||||
}
|
||||
|
||||
liveSet?.reactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReport(deleteNote: Note) {
|
||||
val author = deleteNote.author ?: return
|
||||
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) {
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.reports?.invalidateData()
|
||||
@@ -140,6 +197,16 @@ class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZapPayment(note: Note) {
|
||||
if (zapPayments[note] != null) {
|
||||
zapPayments = zapPayments.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPayments.containsValue(note)) {
|
||||
val toRemove = zapPayments.filterValues { it == note }
|
||||
zapPayments = zapPayments.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (note !in boosts) {
|
||||
@@ -148,19 +215,71 @@ class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
@Synchronized
|
||||
private fun innerAddZap(zapRequest: Note, zap: Note?): Boolean {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
|
||||
return true
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapRequest !in zaps.keys) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.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) {
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
} else if (zapPayments[zapPaymentRequest] == null) {
|
||||
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
|
||||
if (inserted) {
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (note !in reactions) {
|
||||
reactions = reactions + note
|
||||
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()
|
||||
} else if (reactions[reaction]?.contains(note) == false) {
|
||||
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
|
||||
liveSet?.reactions?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -184,76 +303,194 @@ class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun isZappedBy(user: User): Boolean {
|
||||
fun isZappedBy(user: User, account: Account): Boolean {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any { it.key.author == user }
|
||||
return zaps.any {
|
||||
it.key.author?.pubkeyHex == user.pubkeyHex || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
} || zapPayments.any {
|
||||
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
|
||||
val response = if (zapResponseEvent != null) {
|
||||
account.decryptZapPaymentResponseEvent(zapResponseEvent)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
|
||||
}
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): Boolean {
|
||||
return reactions.any { it.author == user }
|
||||
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 }) {
|
||||
it.key
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isBoostedBy(user: User): Boolean {
|
||||
return boosts.any { it.author == user }
|
||||
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): Set<Note> {
|
||||
return reports[user] ?: emptySet()
|
||||
}
|
||||
|
||||
fun reportAuthorsBy(users: Set<User>): List<User> {
|
||||
return reports.keys.filter { it in users }
|
||||
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
|
||||
return reports.keys.filter { it.pubkeyHex in users }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<User>): List<Note> {
|
||||
fun countReportAuthorsBy(users: Set<HexKey>): Int {
|
||||
return reports.keys.count { it.pubkeyHex in users }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<HexKey>): List<Note> {
|
||||
return reportAuthorsBy(users).mapNotNull {
|
||||
reports[it]
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
return zaps.mapNotNull { it.value?.event }
|
||||
fun countReactions(): Int {
|
||||
return reactions.values.sumOf { it.size }
|
||||
}
|
||||
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.filter { it.amount != null }
|
||||
.associate {
|
||||
it.lnInvoice() to it.amount
|
||||
}
|
||||
.toMap()
|
||||
|
||||
val completedPayments = if (privKey != null && walletServicePubkey != null) {
|
||||
// Payments confirmed by the User's Wallet
|
||||
zapPayments
|
||||
.asSequence()
|
||||
.filter {
|
||||
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
|
||||
response is PayInvoiceSuccessResponse
|
||||
}
|
||||
.associate {
|
||||
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
|
||||
val amount = try {
|
||||
if (lnInvoice == null) {
|
||||
null
|
||||
} else {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
null
|
||||
}
|
||||
lnInvoice to amount
|
||||
}
|
||||
.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
|
||||
}
|
||||
|
||||
fun hasPledgeBy(user: User): Boolean {
|
||||
return replies
|
||||
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
|
||||
.any {
|
||||
val pledgeValue = try {
|
||||
BigDecimal(it.event?.content())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
// do nothing if it can't convert to bigdecimal
|
||||
}
|
||||
|
||||
pledgeValue != null && it.author == user
|
||||
}
|
||||
}
|
||||
|
||||
fun pledgedAmountByOthers(): BigDecimal {
|
||||
return replies
|
||||
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
|
||||
.mapNotNull {
|
||||
it.amount
|
||||
}.sumOf { it }
|
||||
try {
|
||||
BigDecimal(it.event?.content())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
// do nothing if it can't convert to bigdecimal
|
||||
}
|
||||
}
|
||||
.sumOf { it }
|
||||
}
|
||||
|
||||
fun hasAnyReports(): Boolean {
|
||||
val dayAgo = Date().time / 1000 - 24*60*60
|
||||
val dayAgo = TimeUtils.oneDayAgo()
|
||||
return reports.isNotEmpty() ||
|
||||
(author?.reports?.values?.filter {
|
||||
it.firstOrNull { ( it.event?.createdAt ?: 0 ) > dayAgo } != null
|
||||
}?.isNotEmpty() ?: false)
|
||||
}
|
||||
|
||||
fun directlyCiteUsers(): Set<User> {
|
||||
val matcher = tagSearch.matcher(event?.content ?: "")
|
||||
val returningList = mutableSetOf<User>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) }
|
||||
if (tag != null && tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
returningList.add(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun directlyCites(userProfile: User): Boolean {
|
||||
return author == userProfile
|
||||
|| (userProfile in directlyCiteUsers())
|
||||
|| (event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|
||||
|| (event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|
||||
(
|
||||
author?.reports?.values?.any {
|
||||
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
|
||||
} ?: false
|
||||
)
|
||||
}
|
||||
|
||||
fun isNewThread(): Boolean {
|
||||
return event is RepostEvent || replyTo == null || replyTo?.size == 0
|
||||
return (
|
||||
event is RepostEvent ||
|
||||
event is GenericRepostEvent ||
|
||||
replyTo == null ||
|
||||
replyTo?.size == 0
|
||||
) &&
|
||||
event !is ChannelMessageEvent &&
|
||||
event !is LiveActivitiesChatMessageEvent
|
||||
}
|
||||
|
||||
fun hasZapped(loggedIn: User): Boolean {
|
||||
@@ -265,18 +502,57 @@ class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun reactedBy(loggedIn: User, content: String): List<Note> {
|
||||
return reactions.filter { it.author == loggedIn && it.event?.content == content }
|
||||
return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
|
||||
}
|
||||
|
||||
fun reactedBy(loggedIn: User): List<String> {
|
||||
return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
|
||||
}
|
||||
|
||||
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
|
||||
val currentTime = Date().time / 1000
|
||||
return boosts.firstOrNull { it.author == loggedIn && (it.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
|
||||
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() } != null // 5 minute protection
|
||||
}
|
||||
|
||||
fun boostedBy(loggedIn: User): List<Note> {
|
||||
return boosts.filter { it.author == loggedIn }
|
||||
}
|
||||
|
||||
fun moveAllReferencesTo(note: AddressableNote) {
|
||||
// migrates these comments to a new version
|
||||
replies.forEach {
|
||||
note.addReply(it)
|
||||
it.replyTo = it.replyTo?.updated(this, note)
|
||||
}
|
||||
reactions.forEach {
|
||||
it.value.forEach {
|
||||
note.addReaction(it)
|
||||
it.replyTo = it.replyTo?.updated(this, note)
|
||||
}
|
||||
}
|
||||
boosts.forEach {
|
||||
note.addBoost(it)
|
||||
it.replyTo = it.replyTo?.updated(this, note)
|
||||
}
|
||||
reports.forEach {
|
||||
it.value.forEach {
|
||||
note.addReport(it)
|
||||
it.replyTo = it.replyTo?.updated(this, note)
|
||||
}
|
||||
}
|
||||
zaps.forEach {
|
||||
note.addZap(it.key, it.value)
|
||||
it.key.replyTo = it.key.replyTo?.updated(this, note)
|
||||
it.value?.replyTo = it.value?.replyTo?.updated(this, note)
|
||||
}
|
||||
|
||||
replyTo = null
|
||||
replies = emptySet()
|
||||
reactions = emptyMap()
|
||||
boosts = emptySet()
|
||||
reports = emptyMap()
|
||||
zaps = emptyMap()
|
||||
}
|
||||
|
||||
var liveSet: NoteLiveSet? = null
|
||||
|
||||
fun live(): NoteLiveSet {
|
||||
@@ -291,8 +567,23 @@ class Note(val idHex: String) {
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
|
||||
if (event == null) return false
|
||||
|
||||
val isBoostedNoteHidden = if (event is GenericRepostEvent || event is RepostEvent || event is CommunityPostApprovalEvent) {
|
||||
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val isSensitive = event?.isSensitive() ?: false
|
||||
return isBoostedNoteHidden ||
|
||||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
|
||||
accountChoices.spammers.contains(author?.pubkeyHex) ||
|
||||
(isSensitive && accountChoices.showSensitiveContent == false)
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLiveSet(u: Note) {
|
||||
// Observers line up here.
|
||||
@@ -306,50 +597,50 @@ class NoteLiveSet(u: Note) {
|
||||
val zaps: NoteLiveData = NoteLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return metadata.hasObservers()
|
||||
|| reactions.hasObservers()
|
||||
|| boosts.hasObservers()
|
||||
|| replies.hasObservers()
|
||||
|| reports.hasObservers()
|
||||
|| relays.hasObservers()
|
||||
|| zaps.hasObservers()
|
||||
return metadata.hasObservers() ||
|
||||
reactions.hasObservers() ||
|
||||
boosts.hasObservers() ||
|
||||
replies.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
zaps.hasObservers()
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
checkNotInMainThread()
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleEventDataSource.add(note.idHex)
|
||||
if (note is AddressableNote) {
|
||||
NostrSingleEventDataSource.addAddress(note)
|
||||
} else {
|
||||
NostrSingleEventDataSource.add(note)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleEventDataSource.remove(note.idHex)
|
||||
if (note is AddressableNote) {
|
||||
NostrSingleEventDataSource.removeAddress(note)
|
||||
} else {
|
||||
NostrSingleEventDataSource.remove(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class NoteState(val note: Note)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
class ParticipantListBuilder {
|
||||
private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set<HexKey>?, set: MutableSet<User>) {
|
||||
baseNote.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
|
||||
// Breaks these searchers down to avoid the memory use of creating multiple lists
|
||||
baseNote.replies.forEach { reply ->
|
||||
reply.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.boosts.forEach { boost ->
|
||||
boost.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.zaps.forEach { zapPair ->
|
||||
zapPair.key.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.reactions.forEach { reactionSet ->
|
||||
reactionSet.value.forEach { reaction ->
|
||||
reaction.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun followsThatParticipateOnDirect(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
|
||||
if (baseNote == null) return mutableSetOf()
|
||||
|
||||
val set = mutableSetOf<User>()
|
||||
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set)
|
||||
return set
|
||||
}
|
||||
|
||||
fun followsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
|
||||
if (baseNote == null) return mutableSetOf()
|
||||
|
||||
val mySet = mutableSetOf<User>()
|
||||
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet)
|
||||
|
||||
baseNote.replies.forEach {
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
|
||||
baseNote.boosts.forEach {
|
||||
it.replyTo?.forEach {
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
}
|
||||
|
||||
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
|
||||
return mySet
|
||||
}
|
||||
|
||||
fun countFollowsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Int {
|
||||
if (baseNote == null) return 0
|
||||
|
||||
val list = followsThatParticipateOn(baseNote, followingSet)
|
||||
|
||||
return list.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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
|
||||
|
||||
class RelayInformation(
|
||||
val name: String?,
|
||||
val description: String?,
|
||||
val pubkey: String?,
|
||||
val contact: String?,
|
||||
val supported_nips: List<Int>?,
|
||||
val supported_nip_extensions: List<String>?,
|
||||
val software: String?,
|
||||
val version: String?,
|
||||
val limitation: RelayInformationLimitation?,
|
||||
val relay_countries: List<String>?,
|
||||
val language_tags: List<String>?,
|
||||
val tags: List<String>?,
|
||||
val posting_policy: String?,
|
||||
val payments_url: String?,
|
||||
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()
|
||||
|
||||
fun fromJson(json: String): RelayInformation = gson.fromJson(json, RelayInformation::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RelayInformationLimitation(
|
||||
val max_message_length: Int?,
|
||||
val max_subscriptions: Int?,
|
||||
val max_filters: Int?,
|
||||
val max_limit: Int?,
|
||||
val max_subid_length: Int?,
|
||||
val min_prefix: Int?,
|
||||
val max_event_tags: Int?,
|
||||
val max_content_length: Int?,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
|
||||
@Immutable
|
||||
data class RelaySetupInfo(
|
||||
val url: String,
|
||||
val read: Boolean,
|
||||
val write: Boolean,
|
||||
val errorCount: Int = 0,
|
||||
val downloadCount: Int = 0,
|
||||
val uploadCount: Int = 0,
|
||||
val downloadCountInBytes: Int = 0,
|
||||
val uploadCountInBytes: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>
|
||||
)
|
||||
val feedTypes: Set<FeedType>,
|
||||
val paidRelay: Boolean = false
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
|
||||
@Stable
|
||||
class Settings(
|
||||
var automaticallyShowImages: Boolean? = null,
|
||||
var automaticallyStartPlayback: Boolean? = null,
|
||||
var preferredLanguage: String? = null,
|
||||
var automaticallyShowUrlPreview: Boolean? = null
|
||||
)
|
||||
@@ -1,66 +1,79 @@
|
||||
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 kotlin.time.measureTimedValue
|
||||
|
||||
class ThreadAssembler {
|
||||
|
||||
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
|
||||
testedNotes.add(note)
|
||||
if (note.event is RepostEvent || note.event is GenericRepostEvent) return 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)
|
||||
testedNotes.add(note)
|
||||
|
||||
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
|
||||
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
|
||||
// recursive
|
||||
val roots = note.replyTo?.map {
|
||||
if (it !in testedNotes)
|
||||
searchRoot(it, testedNotes)
|
||||
else
|
||||
null
|
||||
}?.filterNotNull()
|
||||
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
|
||||
if (roots != null && roots.isNotEmpty()) {
|
||||
return roots[0]
|
||||
// recursive
|
||||
val roots = note.replyTo?.map {
|
||||
if (it !in testedNotes) {
|
||||
searchRoot(it, testedNotes)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}?.filterNotNull()
|
||||
|
||||
if (roots != null && roots.isNotEmpty()) {
|
||||
return roots[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun findThreadFor(noteId: String): Set<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun findThreadFor(noteId: String): Set<Note> {
|
||||
val (result, elapsed) = measureTimedValue {
|
||||
val note = LocalCache.getOrCreateNote(noteId)
|
||||
val (result, elapsed) = measureTimedValue {
|
||||
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
|
||||
|
||||
if (note.event != null) {
|
||||
val thread = mutableSetOf<Note>()
|
||||
if (note.event != null) {
|
||||
val thread = mutableSetOf<Note>()
|
||||
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
|
||||
loadDown(threadRoot, thread)
|
||||
loadDown(threadRoot, thread)
|
||||
// adds the replies of the note in case the search for Root
|
||||
// did not added them.
|
||||
note.replies.forEach {
|
||||
loadDown(it, thread)
|
||||
}
|
||||
|
||||
thread.toSet()
|
||||
} else {
|
||||
setOf(note)
|
||||
}
|
||||
thread.toSet()
|
||||
} else {
|
||||
setOf(note)
|
||||
}
|
||||
}
|
||||
|
||||
println("Model Refresh: Thread loaded in $elapsed")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
println("Model Refresh: Thread loaded in ${elapsed}")
|
||||
fun loadDown(note: Note, thread: MutableSet<Note>) {
|
||||
if (note !in thread) {
|
||||
thread.add(note)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun loadDown(note: Note, thread: MutableSet<Note>) {
|
||||
if (note !in thread) {
|
||||
thread.add(note)
|
||||
|
||||
note.replies.forEach {
|
||||
loadDown(it, thread)
|
||||
}
|
||||
note.replies.forEach {
|
||||
loadDown(it, thread)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,72 +1,58 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.baha.url.preview.BahaUrlPreview
|
||||
import com.baha.url.preview.IUrlPreviewCallback
|
||||
import com.baha.url.preview.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtension
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtension
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
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
|
||||
|
||||
@Stable
|
||||
object UrlCachedPreviewer {
|
||||
var cache = mapOf<String, UrlInfoItem>()
|
||||
private set
|
||||
var failures = mapOf<String, Throwable>()
|
||||
private set
|
||||
var cache = LruCache<String, UrlPreviewState>(100)
|
||||
private set
|
||||
|
||||
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
|
||||
cache[url]?.let {
|
||||
callback?.onComplete(it)
|
||||
return
|
||||
}
|
||||
fun previewInfo(url: String, onReady: (UrlPreviewState) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
failures[url]?.let {
|
||||
callback?.onFailed(it)
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
BahaUrlPreview(url, object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache = cache + Pair(url, urlInfo)
|
||||
callback?.onComplete(urlInfo)
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
failures = failures + Pair(url, throwable)
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
}).fetchUrlPreview()
|
||||
}
|
||||
}
|
||||
BahaUrlPreview(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fun findUrlsInMessage(message: String): List<String> {
|
||||
return message.split('\n').map { paragraph ->
|
||||
paragraph.split(' ').filter { word: String ->
|
||||
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
val state = if (urlInfo.allFetchComplete() && urlInfo.url == url) {
|
||||
UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
|
||||
fun preloadPreviewsFor(note: Note) {
|
||||
note.event?.content?.let {
|
||||
findUrlsInMessage(it).forEach {
|
||||
val removedParamsFromUrl = it.split("?")[0].toLowerCase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Preload Images? Isn't this too heavy?
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Do nothing for now.
|
||||
} else if (isValidURL(removedParamsFromUrl)) {
|
||||
previewInfo(it)
|
||||
} else {
|
||||
previewInfo("https://${it}")
|
||||
}
|
||||
}
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
}
|
||||
).fetchUrlPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
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 java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.MetadataEvent
|
||||
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) {
|
||||
var info: UserMetadata? = null
|
||||
|
||||
var updatedFollowsAt: Long = 0;
|
||||
var latestContactList: ContactListEvent? = null
|
||||
|
||||
var follows = setOf<User>()
|
||||
private set
|
||||
var followers = setOf<User>()
|
||||
private set
|
||||
|
||||
var notes = setOf<Note>()
|
||||
private set
|
||||
var taggedPosts = setOf<Note>()
|
||||
private set
|
||||
var latestBookmarkList: BookmarkListEvent? = null
|
||||
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
var latestReportTime: Long = 0
|
||||
|
||||
var latestEOSEs: Map<String, EOSETime> = emptyMap()
|
||||
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays: Map<String, ContactListEvent.ReadWrite>? = null
|
||||
private set
|
||||
|
||||
var relaysBeingUsed = mapOf<String, RelayInfo>()
|
||||
private set
|
||||
|
||||
@@ -58,8 +48,13 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
fun toNostrUri() = "nostr:${pubkeyNpub()}"
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
|
||||
}
|
||||
@@ -81,69 +76,32 @@ class User(val pubkeyHex: String) {
|
||||
return info?.picture
|
||||
}
|
||||
|
||||
fun follow(user: User, followedAt: Long) {
|
||||
follows = follows + user
|
||||
user.followers = user.followers + this
|
||||
fun updateBookmark(event: BookmarkListEvent) {
|
||||
if (event.id == latestBookmarkList?.id) return
|
||||
|
||||
liveSet?.follows?.invalidateData()
|
||||
user.liveSet?.follows?.invalidateData()
|
||||
latestBookmarkList = event
|
||||
liveSet?.bookmarks?.invalidateData()
|
||||
}
|
||||
|
||||
fun unfollow(user: User) {
|
||||
follows = follows - user
|
||||
user.followers = user.followers - this
|
||||
fun updateContactList(event: ContactListEvent) {
|
||||
if (event.id == latestContactList?.id) return
|
||||
|
||||
val oldContactListEvent = latestContactList
|
||||
latestContactList = event
|
||||
|
||||
// Update following of the current user
|
||||
liveSet?.follows?.invalidateData()
|
||||
user.liveSet?.follows?.invalidateData()
|
||||
}
|
||||
|
||||
fun follow(users: Set<User>, followedAt: Long) {
|
||||
follows = follows + users
|
||||
users.forEach {
|
||||
if (this !in it.followers && it.liveSet?.isInUse() == true) {
|
||||
it.followers = it.followers + this
|
||||
it.liveSet?.follows?.invalidateData()
|
||||
}
|
||||
// Update Followers of the past user list
|
||||
// Update Followers of the new contact list
|
||||
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
|
||||
}
|
||||
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
|
||||
}
|
||||
|
||||
liveSet?.follows?.invalidateData()
|
||||
}
|
||||
|
||||
fun unfollow(users: Set<User>) {
|
||||
follows = follows - users
|
||||
users.forEach {
|
||||
if (this in it.followers && it.liveSet?.isInUse() == true) {
|
||||
it.followers = it.followers - this
|
||||
it.liveSet?.follows?.invalidateData()
|
||||
}
|
||||
}
|
||||
liveSet?.follows?.invalidateData()
|
||||
}
|
||||
|
||||
fun addTaggedPost(note: Note) {
|
||||
if (note !in taggedPosts) {
|
||||
taggedPosts = taggedPosts + note
|
||||
// No need for Listener yet
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTaggedPost(note: Note) {
|
||||
taggedPosts = taggedPosts - note
|
||||
}
|
||||
|
||||
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?.relays?.invalidateData()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
@@ -156,17 +114,12 @@ class User(val pubkeyHex: String) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
liveSet?.reports?.invalidateData()
|
||||
}
|
||||
|
||||
val reportTime = note.event?.createdAt ?: 0
|
||||
if (reportTime > latestReportTime) {
|
||||
latestReportTime = reportTime
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReport(deleteNote: Note) {
|
||||
val author = deleteNote.author ?: return
|
||||
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) {
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.reports?.invalidateData()
|
||||
@@ -196,29 +149,49 @@ class User(val pubkeyHex: String) {
|
||||
return reports[user] ?: emptySet()
|
||||
}
|
||||
|
||||
fun reportAuthorsBy(users: Set<User>): List<User> {
|
||||
return reports.keys.filter { it in users }
|
||||
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
|
||||
return reports.keys.filter { it.pubkeyHex in users }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<User>): List<Note> {
|
||||
fun countReportAuthorsBy(users: Set<HexKey>): Int {
|
||||
return reports.keys.count { it.pubkeyHex in users }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<HexKey>): List<Note> {
|
||||
return reportAuthorsBy(users).mapNotNull {
|
||||
reports[it]
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
|
||||
checkNotInMainThread()
|
||||
|
||||
return privateChatrooms[user] ?: run {
|
||||
val privateChatroom = Chatroom(setOf<Note>())
|
||||
val privateChatroom = Chatroom()
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
privateChatroom
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
|
||||
}
|
||||
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.removeMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -236,29 +209,14 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
liveSet?.relayInfo?.invalidateData()
|
||||
}
|
||||
|
||||
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
|
||||
val toBeAdded = newFollows - follows
|
||||
val toBeRemoved = follows - newFollows
|
||||
|
||||
follow(toBeAdded, updateAt)
|
||||
unfollow(toBeRemoved)
|
||||
|
||||
updatedFollowsAt = updateAt
|
||||
}
|
||||
|
||||
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
|
||||
// no need to test if relays are different. The Account will check for us.
|
||||
relays = relayUse
|
||||
liveSet?.relays?.invalidateData()
|
||||
}
|
||||
|
||||
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
|
||||
info = newUserInfo
|
||||
info?.latestMetadata = latestMetadata
|
||||
info?.updatedMetadataAt = latestMetadata.createdAt
|
||||
info?.tags = latestMetadata.tags.toImmutableListOfLists()
|
||||
|
||||
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.toLowerCase()?.startsWith("lnurl") == true) {
|
||||
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
|
||||
try {
|
||||
val url = String(Bech32.decodeBytes(newUserInfo.lud06!!, false).second)
|
||||
|
||||
@@ -278,18 +236,68 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean {
|
||||
return follows.contains(user)
|
||||
return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
|
||||
}
|
||||
|
||||
fun isFollowingHashtag(tag: String): Boolean {
|
||||
return latestContactList?.isTaggedHash(tag) ?: false
|
||||
}
|
||||
|
||||
fun isFollowingHashtagCached(tag: String): Boolean {
|
||||
return latestContactList?.verifiedFollowTagSet?.let {
|
||||
return tag.lowercase() in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingCached(user: User): Boolean {
|
||||
return latestContactList?.verifiedFollowKeySet?.let {
|
||||
return user.pubkeyHex in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingCached(userHex: String): Boolean {
|
||||
return latestContactList?.verifiedFollowKeySet?.let {
|
||||
return userHex in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun transientFollowCount(): Int? {
|
||||
return latestContactList?.unverifiedFollowKeySet()?.size
|
||||
}
|
||||
|
||||
suspend fun transientFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun cachedFollowingKeySet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowKeySet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingTagSet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowTagSet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowingCommunitiesSet(): Set<HexKey> {
|
||||
return latestContactList?.verifiedFollowCommunitySet ?: emptySet()
|
||||
}
|
||||
|
||||
fun cachedFollowCount(): Int? {
|
||||
return latestContactList?.verifiedFollowKeySet?.size
|
||||
}
|
||||
|
||||
suspend fun cachedFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(user: User?): Boolean {
|
||||
val messagesToUser = privateChatrooms[user] ?: return false
|
||||
|
||||
return messagesToUser.roomMessages.any { this == it.author }
|
||||
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
|
||||
}
|
||||
|
||||
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
|
||||
return reports[loggedIn]?.firstOrNull() {
|
||||
it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor.any { it.reportType == type }
|
||||
it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
|
||||
} != null
|
||||
}
|
||||
|
||||
@@ -316,32 +324,58 @@ class User(val pubkeyHex: String) {
|
||||
class UserLiveSet(u: User) {
|
||||
// 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 bookmarks: UserLiveData = UserLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return follows.hasObservers()
|
||||
|| reports.hasObservers()
|
||||
|| messages.hasObservers()
|
||||
|| relays.hasObservers()
|
||||
|| relayInfo.hasObservers()
|
||||
|| metadata.hasObservers()
|
||||
|| zaps.hasObservers()
|
||||
return follows.hasObservers() ||
|
||||
followers.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
messages.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
relayInfo.hasObservers() ||
|
||||
metadata.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
bookmarks.hasObservers()
|
||||
}
|
||||
}
|
||||
|
||||
data class RelayInfo (
|
||||
@Immutable
|
||||
data class RelayInfo(
|
||||
val url: String,
|
||||
var lastEvent: Long,
|
||||
var counter: Long
|
||||
)
|
||||
|
||||
data class Chatroom(var roomMessages: Set<Note>)
|
||||
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
|
||||
@@ -365,40 +399,57 @@ class UserMetadata {
|
||||
var main_relay: String? = null
|
||||
var twitter: String? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0;
|
||||
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)
|
||||
.filter { it.startsWith(prefix, true) }.isNotEmpty()
|
||||
.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 UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
checkNotInMainThread()
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleUserDataSource.add(user)
|
||||
@@ -410,5 +461,5 @@ class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class UserState(val user: User)
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.withSign
|
||||
|
||||
object BlurHashDecoder {
|
||||
|
||||
// cache Math.cos() calculations to improve performance.
|
||||
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
|
||||
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
|
||||
private val cacheCosinesX = HashMap<Int, DoubleArray>()
|
||||
private val cacheCosinesY = HashMap<Int, DoubleArray>()
|
||||
|
||||
/**
|
||||
* Clear calculations stored in memory cache.
|
||||
* The cache is not big, but will increase when many image sizes are used,
|
||||
* if the app needs memory it is recommended to clear it.
|
||||
*/
|
||||
fun clearCache() {
|
||||
cacheCosinesX.clear()
|
||||
cacheCosinesY.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns width/height
|
||||
*/
|
||||
fun aspectRatio(blurHash: String?): Float? {
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83(blurHash, 0, 1)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return numCompX.toFloat() / numCompY.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a blur hash into a new bitmap.
|
||||
*
|
||||
* @param useCache use in memory cache for the calculated math, reused by images with same size.
|
||||
* if the cache does not exist yet it will be created and populated with new calculations.
|
||||
* By default it is true.
|
||||
*/
|
||||
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83(blurHash, 0, 1)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
return null
|
||||
}
|
||||
val maxAcEnc = decode83(blurHash, 1, 2)
|
||||
val maxAc = (maxAcEnc + 1) / 166f
|
||||
val colors = Array(numCompX * numCompY) { i ->
|
||||
if (i == 0) {
|
||||
val colorEnc = decode83(blurHash, 2, 6)
|
||||
decodeDc(colorEnc)
|
||||
} else {
|
||||
val from = 4 + i * 2
|
||||
val colorEnc = decode83(blurHash, from, from + 2)
|
||||
decodeAc(colorEnc, maxAc * punch)
|
||||
}
|
||||
}
|
||||
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
|
||||
}
|
||||
|
||||
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
|
||||
var result = 0
|
||||
for (i in from until to) {
|
||||
val index = charMap[str[i]] ?: -1
|
||||
if (index != -1) {
|
||||
result = result * 83 + index
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun decodeDc(colorEnc: Int): FloatArray {
|
||||
val r = colorEnc shr 16
|
||||
val g = (colorEnc shr 8) and 255
|
||||
val b = colorEnc and 255
|
||||
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
|
||||
}
|
||||
|
||||
private fun srgbToLinear(colorEnc: Int): Float {
|
||||
val v = colorEnc / 255f
|
||||
return if (v <= 0.04045f) {
|
||||
(v / 12.92f)
|
||||
} else {
|
||||
((v + 0.055f) / 1.055f).pow(2.4f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
|
||||
val r = value / (19 * 19)
|
||||
val g = (value / 19) % 19
|
||||
val b = value % 19
|
||||
return floatArrayOf(
|
||||
signedPow2((r - 9) / 9.0f) * maxAc,
|
||||
signedPow2((g - 9) / 9.0f) * maxAc,
|
||||
signedPow2((b - 9) / 9.0f) * maxAc
|
||||
)
|
||||
}
|
||||
|
||||
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
|
||||
|
||||
private fun composeBitmap(
|
||||
width: Int,
|
||||
height: Int,
|
||||
numCompX: Int,
|
||||
numCompY: Int,
|
||||
colors: Array<FloatArray>,
|
||||
useCache: Boolean
|
||||
): Bitmap {
|
||||
// use an array for better performance when writing pixel colors
|
||||
val imageArray = IntArray(width * height)
|
||||
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
|
||||
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
|
||||
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
|
||||
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
var r = 0f
|
||||
var g = 0f
|
||||
var b = 0f
|
||||
for (j in 0 until numCompY) {
|
||||
for (i in 0 until numCompX) {
|
||||
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
|
||||
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
|
||||
val basis = (cosX * cosY).toFloat()
|
||||
val color = colors[j * numCompX + i]
|
||||
r += color[0] * basis
|
||||
g += color[1] * basis
|
||||
b += color[2] * basis
|
||||
}
|
||||
}
|
||||
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
|
||||
calculate -> {
|
||||
DoubleArray(height * numCompY).also {
|
||||
cacheCosinesY[height * numCompY] = it
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
cacheCosinesY[height * numCompY]!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
|
||||
calculate -> {
|
||||
DoubleArray(width * numCompX).also {
|
||||
cacheCosinesX[width * numCompX] = it
|
||||
}
|
||||
}
|
||||
else -> cacheCosinesX[width * numCompX]!!
|
||||
}
|
||||
|
||||
private fun DoubleArray.getCos(
|
||||
calculate: Boolean,
|
||||
x: Int,
|
||||
numComp: Int,
|
||||
y: Int,
|
||||
size: Int
|
||||
): Double {
|
||||
if (calculate) {
|
||||
this[x + numComp * y] = cos(Math.PI * y * x / size)
|
||||
}
|
||||
return this[x + numComp * y]
|
||||
}
|
||||
|
||||
private fun linearToSrgb(value: Float): Int {
|
||||
val v = value.coerceIn(0f, 1f)
|
||||
return if (v <= 0.0031308f) {
|
||||
(v * 12.92f * 255f + 0.5f).toInt()
|
||||
} else {
|
||||
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private val charMap = listOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
|
||||
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
|
||||
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
|
||||
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
|
||||
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
|
||||
)
|
||||
.mapIndexed { i, c -> c to i }
|
||||
.toMap()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Stable
|
||||
class BlurHashFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val encodedHash = data.toString().removePrefix("bluehash:")
|
||||
val hash = URLDecoder.decode(encodedHash, "utf-8")
|
||||
|
||||
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
|
||||
|
||||
val preferredWidth = 100
|
||||
|
||||
val bitmap = BlurHashDecoder.decode(
|
||||
hash,
|
||||
preferredWidth,
|
||||
(preferredWidth * (1 / aspectRatio)).roundToInt()
|
||||
)
|
||||
|
||||
if (bitmap == null) {
|
||||
throw Exception("Unable to convert Bluehash $hash")
|
||||
}
|
||||
|
||||
return DrawableResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return BlurHashFetcher(options, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object BlurHashRequester {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("bluehash:$encodedMessage")
|
||||
.fetcherFactory(BlurHashFetcher.Factory)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
data class RichTextViewerState(
|
||||
val urlSet: ImmutableSet<String>,
|
||||
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
|
||||
val imageList: ImmutableList<ZoomableUrlContent>,
|
||||
val customEmoji: ImmutableMap<String, String>,
|
||||
val paragraphs: ImmutableList<ParagraphState>
|
||||
)
|
||||
|
||||
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
|
||||
|
||||
object CachedRichTextParser {
|
||||
val richTextCache = LruCache<String, RichTextViewerState>(200)
|
||||
|
||||
fun parseText(content: String, tags: ImmutableListOfLists<String>): RichTextViewerState {
|
||||
return if (richTextCache[content] != null) {
|
||||
richTextCache[content]
|
||||
} else {
|
||||
val newUrls = RichTextParser().parseText(content, tags)
|
||||
richTextCache.put(content, newUrls)
|
||||
newUrls
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group 1 = url, group 4 additional chars
|
||||
// val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
|
||||
// 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}])*\\/?)(.*)")
|
||||
} catch (e: Exception) {
|
||||
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
}
|
||||
|
||||
class RichTextParser() {
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>
|
||||
): RichTextViewerState {
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
|
||||
val urlSet = urls.mapNotNullTo(LinkedHashSet(urls.size)) {
|
||||
// removes e-mails
|
||||
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
|
||||
null
|
||||
} else if (isNumber(it.originalUrl)) {
|
||||
null
|
||||
} 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)) {
|
||||
it.originalUrl
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlImage(fullUrl)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlVideo(fullUrl)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
val emojiMap =
|
||||
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
|
||||
|
||||
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
|
||||
|
||||
return RichTextViewerState(
|
||||
urlSet.toImmutableSet(),
|
||||
imagesForPager.toImmutableMap(),
|
||||
imageList.toImmutableList(),
|
||||
emojiMap.toImmutableMap(),
|
||||
segments
|
||||
)
|
||||
}
|
||||
|
||||
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 isRTL = isArabic(paragraph)
|
||||
|
||||
val wordList = paragraph.split(' ')
|
||||
wordList.forEach { word ->
|
||||
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
|
||||
if (wordSegment !is RegularTextSegment) {
|
||||
isDirty = true
|
||||
}
|
||||
segments = segments.add(wordSegment)
|
||||
}
|
||||
|
||||
val newSegments = if (isDirty) {
|
||||
ParagraphState(segments, isRTL)
|
||||
} else {
|
||||
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
|
||||
}
|
||||
|
||||
paragraphSegments = paragraphSegments.add(newSegments)
|
||||
}
|
||||
|
||||
return paragraphSegments
|
||||
}
|
||||
|
||||
fun isNumber(word: String): Boolean {
|
||||
return numberPattern.matcher(word).matches()
|
||||
}
|
||||
|
||||
fun isDate(word: String): Boolean {
|
||||
return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches()
|
||||
}
|
||||
|
||||
private fun isArabic(text: String): Boolean {
|
||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
}
|
||||
|
||||
private fun wordIdentifier(word: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): Segment {
|
||||
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
|
||||
val phoneMatcher = Patterns.PHONE.matcher(word)
|
||||
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
|
||||
|
||||
return if (word.isEmpty()) {
|
||||
RegularTextSegment(word)
|
||||
} else if (images.contains(word)) {
|
||||
ImageSegment(word)
|
||||
} else if (urls.contains(word)) {
|
||||
LinkSegment(word)
|
||||
} else if (emojis.any { word.contains(it.key) }) {
|
||||
EmojiSegment(word)
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
InvoiceSegment(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
WithdrawSegment(word)
|
||||
} else if (word.startsWith("cashuA", true)) {
|
||||
CashuSegment(word)
|
||||
} else if (emailMatcher.matches()) {
|
||||
EmailSegment(word)
|
||||
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
|
||||
PhoneSegment(word)
|
||||
} else if (startsWithNIP19Scheme(word)) {
|
||||
BechSegment(word)
|
||||
} else if (word.startsWith("#")) {
|
||||
parseHash(word, tags)
|
||||
} else if (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)) {
|
||||
SchemelessUrlSegment(word, url, additionalChars)
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
}
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHash(word: String, tags: ImmutableListOfLists<String>): Segment {
|
||||
// First #[n]
|
||||
|
||||
val matcher = tagIndex.matcher(word)
|
||||
try {
|
||||
if (matcher.find()) {
|
||||
val index = matcher.group(1)?.toInt()
|
||||
val suffix = matcher.group(2)
|
||||
|
||||
if (index != null && index >= 0 && index < tags.lists.size) {
|
||||
val tag = tags.lists[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
return HashIndexUserSegment(word, tag[1], suffix)
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
return HashIndexEventSegment(word, tag[1], suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
}
|
||||
|
||||
// Second #Amethyst
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
|
||||
try {
|
||||
if (hashtagMatcher.find()) {
|
||||
val hashtag = hashtagMatcher.group(1)
|
||||
if (hashtag != null) {
|
||||
return HashTagSegment(word, hashtag, hashtagMatcher.group(2))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
}
|
||||
|
||||
return RegularTextSegment(word)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
|
||||
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
open class Segment(val segmentText: String)
|
||||
|
||||
@Immutable
|
||||
class ImageSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class LinkSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class EmojiSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class InvoiceSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class WithdrawSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class CashuSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class EmailSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class PhoneSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class BechSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class HashIndexUserSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashIndexEventSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class RegularTextSegment(segment: String) : Segment(segment)
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
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.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.Base64
|
||||
|
||||
@Immutable
|
||||
data class CashuToken(
|
||||
val token: String,
|
||||
val mint: String,
|
||||
val totalAmount: Long,
|
||||
val fees: Int,
|
||||
val redeemInvoiceAmount: Long,
|
||||
val proofs: JsonArray
|
||||
)
|
||||
|
||||
class CashuProcessor {
|
||||
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
|
||||
checkNotInMainThread()
|
||||
|
||||
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
|
||||
|
||||
var totalAmount = 0L
|
||||
for (proof in proofs) {
|
||||
totalAmount += proof.asJsonObject["amount"].asLong
|
||||
}
|
||||
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
|
||||
val redeemInvoiceAmount = totalAmount - fees
|
||||
|
||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
|
||||
} catch (e: Exception) {
|
||||
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
|
||||
}
|
||||
}
|
||||
|
||||
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
runCatching {
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
|
||||
message = "Redeem Cashu",
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, onSuccess, onError)
|
||||
},
|
||||
onProgress = {
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
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 mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val body = response.body.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
|
||||
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.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Token melt failure: " + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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 io.trbl.blurhash.BlurHash
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FileHeader(
|
||||
val url: String,
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
val description: String? = null,
|
||||
val sensitiveContent: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, description: 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)
|
||||
} else {
|
||||
onError()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
description: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
try {
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
val hash = sha256.digest(data).toHexKey()
|
||||
val size = data.size
|
||||
|
||||
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
|
||||
val opt = BitmapFactory.Options()
|
||||
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
|
||||
|
||||
val intArray = IntArray(mBitmap.width * mBitmap.height)
|
||||
mBitmap.getPixels(
|
||||
intArray,
|
||||
0,
|
||||
mBitmap.width,
|
||||
0,
|
||||
0,
|
||||
mBitmap.width,
|
||||
mBitmap.height
|
||||
)
|
||||
|
||||
val dim = "${mBitmap.width}x${mBitmap.height}"
|
||||
|
||||
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
|
||||
} else if (aspectRatio < 1) {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
|
||||
} else {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
|
||||
}
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description, sensitiveContent))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
object HttpClient {
|
||||
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.internalProxy = account?.proxy
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
val seconds = if (internalProxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(internalProxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
return internalProxy
|
||||
}
|
||||
|
||||
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
|
||||
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.os.Looper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
|
||||
fun checkNotInMainThread() {
|
||||
if (BuildConfig.DEBUG && isMainThread()) throw OnMainThreadException("It should not be in the MainThread")
|
||||
}
|
||||
|
||||
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
|
||||
|
||||
class OnMainThreadException(str: String) : RuntimeException(str)
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
class NIP30Parser {
|
||||
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
fun buildArray(input: String): List<String> {
|
||||
val matcher = customEmojiPattern.matcher(input)
|
||||
val list = mutableListOf<String>()
|
||||
while (matcher.find()) {
|
||||
list.add(matcher.group())
|
||||
}
|
||||
|
||||
if (list.isEmpty()) {
|
||||
return listOf(input)
|
||||
}
|
||||
|
||||
val regularChars = input.split(customEmojiPattern.toRegex())
|
||||
|
||||
val finalList = mutableListOf<String>()
|
||||
var index = 0
|
||||
for (e in regularChars) {
|
||||
finalList.add(e)
|
||||
if (index < list.size) {
|
||||
finalList.add(list[index])
|
||||
}
|
||||
index++
|
||||
}
|
||||
return finalList
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,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
|
||||
@@ -8,86 +9,108 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class Nip05Verifier {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
class Nip05Verifier() {
|
||||
fun assembleUrl(nip05address: String): String? {
|
||||
val parts = nip05address.trim().split("@")
|
||||
|
||||
fun assembleUrl(nip05address: String): String? {
|
||||
val parts = nip05address.trim().split("@")
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
|
||||
}
|
||||
if (parts.size == 1) {
|
||||
return "https://${parts[0]}/.well-known/nostr.json?name=_"
|
||||
}
|
||||
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchNip05JsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
|
||||
return
|
||||
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
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 ${nip05}. Error: ${it.code}. Check if the server up and if the address ${nip05} is correct")
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
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()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// check fails on tests
|
||||
checkNotInMainThread()
|
||||
|
||||
fetchNip05Json(nip05,
|
||||
onSuccess = {
|
||||
val nip05url = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
val user = nip05.split("@")[0]
|
||||
fetchNip05Json(
|
||||
nip05,
|
||||
onSuccess = {
|
||||
checkNotInMainThread()
|
||||
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
// NIP05 usernames are case insensitive, but JSON properties are not
|
||||
// converts the json to lowercase and then tries to access the username via a
|
||||
// lowercase version of the username.
|
||||
val nip05url = try {
|
||||
mapper.readTree(it.lowercase())
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
if (hexKey == null) {
|
||||
onError("Username not found in the NIP05 JSON")
|
||||
} else {
|
||||
onSuccess(hexKey)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
val parts = nip05.split("@")
|
||||
val user = if (parts.size == 2) {
|
||||
parts[0].lowercase()
|
||||
} else {
|
||||
"_"
|
||||
}
|
||||
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
|
||||
if (hexKey == null) {
|
||||
onError("Username not found in the NIP05 JSON")
|
||||
} else {
|
||||
onSuccess(hexKey)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.bechToBytes
|
||||
|
||||
class Nip19 {
|
||||
|
||||
enum class Type {
|
||||
USER, NOTE
|
||||
}
|
||||
data class Return(val type: Type, val hex: String)
|
||||
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
try {
|
||||
val key = uri?.removePrefix("nostr:")
|
||||
|
||||
if (key != null) {
|
||||
val bytes = key.bechToBytes()
|
||||
if (key.startsWith("npub")) {
|
||||
return Return(Type.USER, bytes.toHexKey())
|
||||
}
|
||||
if (key.startsWith("note")) {
|
||||
return Return(Type.NOTE, bytes.toHexKey())
|
||||
}
|
||||
if (key.startsWith("nprofile")) {
|
||||
val tlv = parseTLV(bytes)
|
||||
val hex = tlv.get(0)?.get(0)?.toHexKey()
|
||||
if (hex != null)
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
if (key.startsWith("nevent")) {
|
||||
val tlv = parseTLV(bytes)
|
||||
val hex = tlv.get(0)?.get(0)?.toHexKey()
|
||||
if (hex != null)
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
println("Trying to Decode NIP19: ${uri}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
|
||||
var 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.put(t, mutableListOf())
|
||||
}
|
||||
result.get(t)?.add(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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?)
|
||||
|
||||
// Rename to the corect nip number when ready.
|
||||
object Nip47 {
|
||||
fun parse(uri: String): Nip47URI {
|
||||
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
|
||||
|
||||
val url = Uri.parse(uri)
|
||||
|
||||
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
|
||||
throw IllegalArgumentException("Not a Wallet Connect QR Code")
|
||||
}
|
||||
|
||||
val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null")
|
||||
|
||||
val pubkeyHex = try {
|
||||
decodePublicKey(pubkey).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
|
||||
}
|
||||
|
||||
val relay = url.getQueryParameter("relay")
|
||||
val secret = url.getQueryParameter("secret")
|
||||
|
||||
return Nip47URI(pubkeyHex, relay, secret)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,145 @@
|
||||
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.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
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 nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrAccountDataSource: NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createAccountContactListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createAccountContactListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountMetadataFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind, EmojiPackSelectionEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 10
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountBookmarkListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountLastPostsListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 400
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapPaymentResponseEvent.kind,
|
||||
ChannelMessageEvent.kind,
|
||||
BadgeAwardEvent.kind
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 4000,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountMetadataFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
val accountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun updateChannelFilters() {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter(),
|
||||
createAccountLastPostsListFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, ChannelMessageEvent.kind
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
val accountChannel = requestNewChannel()
|
||||
if (this::account.isInitialized) {
|
||||
val event = account.createAuthEvent(relay, challenge)
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
if (event != null) {
|
||||
Client.send(
|
||||
event,
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,90 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
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 nostr.postr.JsonFilter
|
||||
|
||||
object NostrChannelDataSource: NostrDataSource("ChatroomFeed") {
|
||||
var channel: Channel? = null
|
||||
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
var account: Account? = null
|
||||
var channel: Channel? = null
|
||||
|
||||
fun loadMessagesBetween(channelId: String) {
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createMessagesToChannelFilter(): TypedFilter? {
|
||||
if (channel != null) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
fun loadMessagesBetween(account: Account, channel: Channel) {
|
||||
this.account = account
|
||||
this.channel = channel
|
||||
resetFilters()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val messagesChannel = requestNewChannel()
|
||||
fun clear() {
|
||||
account = null
|
||||
channel = null
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
fun createMessagesByMeToChannelFilter(): TypedFilter? {
|
||||
val myAccount = account ?: return null
|
||||
|
||||
if (channel is PublicChatChannel) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
authors = listOf(myAccount.userProfile().pubkeyHex),
|
||||
limit = 50
|
||||
)
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
|
||||
authors = listOf(myAccount.userProfile().pubkeyHex),
|
||||
limit = 50
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun createMessagesToChannelFilter(): TypedFilter? {
|
||||
if (channel is PublicChatChannel) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
|
||||
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val messagesChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
messagesChannel.typedFilters = listOfNotNull(
|
||||
createMessagesToChannelFilter(),
|
||||
createMessagesByMeToChannelFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,59 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
|
||||
object NostrChatroomDataSource: NostrDataSource("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var withUser: User? = null
|
||||
object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var withUser: User? = null
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, userId: String) {
|
||||
account = accountIn
|
||||
withUser = LocalCache.users[userId]
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createMessagesToMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
|
||||
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))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
fun loadMessagesBetween(accountIn: Account, user: User) {
|
||||
account = accountIn
|
||||
withUser = user
|
||||
resetFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun createMessagesFromMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
fun createMessagesToMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
|
||||
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))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val inandoutChannel = requestNewChannel()
|
||||
fun createMessagesFromMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val inandoutChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,85 +4,105 @@ 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 nostr.postr.JsonFilter
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
|
||||
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createMessagesToMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
)
|
||||
)
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val chatRoomList = "ChatroomList"
|
||||
|
||||
fun createMessagesFromMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
)
|
||||
|
||||
fun createChannelsCreatedbyMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList()
|
||||
)
|
||||
)
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
fun createMessagesToMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 1
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
TypedFilter(
|
||||
fun createMessagesFromMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
fun createChannelsCreatedbyMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 100 // Remember to consider spam that is being removed from the UI
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val chatroomListChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val list = listOf(
|
||||
createMessagesToMeFilter(),
|
||||
createMessagesFromMeFilter(),
|
||||
createMyChannelsFilter()
|
||||
)
|
||||
|
||||
chatroomListChannel.typedFilters = listOfNotNull(
|
||||
list,
|
||||
createLastChannelInfoFilter(),
|
||||
createLastMessageOfEachChannelFilter()
|
||||
).flatten().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
fun createMyChannelsFilter(): TypedFilter {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
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(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
|
||||
limit = 50 // Remember to consider spam that is being removed from the UI
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val chatroomListChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val list = listOf(
|
||||
createMessagesToMeFilter(),
|
||||
createMessagesFromMeFilter(),
|
||||
createMyChannelsFilter()
|
||||
)
|
||||
|
||||
chatroomListChannel.typedFilters = listOfNotNull(
|
||||
list,
|
||||
createLastChannelInfoFilter(),
|
||||
createLastMessageOfEachChannelFilter()
|
||||
).flatten().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
|
||||
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
|
||||
private var communityToWatch: AddressableNote? = null
|
||||
|
||||
private fun createLoadCommunityFilter(): TypedFilter? {
|
||||
val myCommunityToWatch = communityToWatch ?: return null
|
||||
|
||||
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
authors = community.moderators().map { it.key }.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
|
||||
tags = mapOf(
|
||||
"a" to listOf(myCommunityToWatch.address.toTag())
|
||||
),
|
||||
kinds = listOf(CommunityPostApprovalEvent.kind),
|
||||
limit = 500
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadCommunityChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadCommunity(note: AddressableNote?) {
|
||||
communityToWatch = note
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -1,220 +1,169 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
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.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.service.relays.hasValidSignature
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.DeletionEvent
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
import nostr.postr.events.RecommendRelayEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import java.util.UUID
|
||||
import kotlin.Error
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
data class Counter(var counter:Int)
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
data class Counter(var counter: Int)
|
||||
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
println("AAA Count ${it.key}: ${it.value.counter}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val clientListener = object : Client.Listener() {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
if (subscriptionId in subscriptions.keys) {
|
||||
if (!event.hasValidSignature()) return
|
||||
|
||||
val key = "${debugName} ${subscriptionId} ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
if (keyValue != null) {
|
||||
keyValue.counter++
|
||||
} else {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
Log.d("STATE DUMP", "Received Events ${it.key}: ${it.value.counter}")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is TextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event, relay)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
else -> when (event.kind) {
|
||||
RepostEvent.kind -> {
|
||||
val repostEvent = RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
|
||||
private val clientListener = object : Client.Listener() {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
if (subscriptionId in subscriptions.keys) {
|
||||
val key = "$debugName $subscriptionId ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
if (keyValue != null) {
|
||||
keyValue.counter++
|
||||
} else {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
}
|
||||
|
||||
repostEvent.containedPost?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(repostEvent)
|
||||
}
|
||||
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
|
||||
|
||||
LnZapEvent.kind -> {
|
||||
val zapEvent = LnZapEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
|
||||
|
||||
zapEvent.containedPost?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(zapEvent)
|
||||
}
|
||||
LnZapRequestEvent.kind -> LocalCache.consume(LnZapRequestEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
|
||||
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
|
||||
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
|
||||
//Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
|
||||
//Log.d("RELAY", "Relay ${relay.url} ${when (type) {
|
||||
// Relay.Type.CONNECT -> "connected."
|
||||
// Relay.Type.DISCONNECT -> "disconnected."
|
||||
// Relay.Type.DISCONNECTING -> "disconnecting."
|
||||
// Relay.Type.EOSE -> "sent all events it had stored."
|
||||
//}}")
|
||||
|
||||
if (type == Relay.Type.EOSE && channel != null) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[channel]?.updateEOSE(Date().time / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Start")
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
open fun stop() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Stop")
|
||||
subscriptions.values.forEach { channel ->
|
||||
Client.close(channel.id)
|
||||
channel.typedFilters = null
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription {
|
||||
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0,4), onEOSE)
|
||||
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
|
||||
return newSubscription
|
||||
}
|
||||
|
||||
fun dismissChannel(subscription: Subscription) {
|
||||
Client.close(subscription.id)
|
||||
subscriptions = subscriptions.minus(subscription.id)
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateFilters() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(200)
|
||||
resetFiltersSuspend()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
|
||||
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
|
||||
// Log.d("RELAY", "Relay ${relay.url} ${when (type) {
|
||||
// Relay.Type.CONNECT -> "connected."
|
||||
// Relay.Type.DISCONNECT -> "disconnected."
|
||||
// Relay.Type.DISCONNECTING -> "disconnecting."
|
||||
// Relay.Type.EOSE -> "sent all events it had stored."
|
||||
// }}")
|
||||
|
||||
fun resetFiltersSuspend() {
|
||||
// saves the channels that are currently active
|
||||
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
|
||||
// saves the current content to only update if it changes
|
||||
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
|
||||
|
||||
updateChannelFilters()
|
||||
|
||||
// Makes sure to only send an updated filter when it actually changes.
|
||||
subscriptions.values.forEach { updatedSubscription ->
|
||||
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
|
||||
|
||||
if (updatedSubscription.id in currentFilters.keys) {
|
||||
if (updatedSubscriotionNewFilters == null) {
|
||||
// was active and is not active anymore, just close.
|
||||
Client.close(updatedSubscription.id)
|
||||
} else {
|
||||
// was active and is still active, check if it has changed.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.close(updatedSubscription.id)
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
} else {
|
||||
// hasn't changed, does nothing.
|
||||
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
}
|
||||
if (type == Relay.Type.EOSE && channel != null) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[channel]?.updateEOSE(TimeUtils.now(), relay.url)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (updatedSubscriotionNewFilters == null) {
|
||||
// was not active and is still not active, does nothing
|
||||
} else {
|
||||
// was not active and becomes active, sends the filter.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
}
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
auth(relay, challenge)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("Init", "${this.javaClass.simpleName} Subscribe")
|
||||
Client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
stop()
|
||||
Client.unsubscribe(clientListener)
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Start")
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
open fun stop() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Stop")
|
||||
subscriptions.values.forEach { channel ->
|
||||
Client.close(channel.id)
|
||||
channel.typedFilters = null
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription {
|
||||
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE)
|
||||
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
|
||||
return newSubscription
|
||||
}
|
||||
|
||||
fun dismissChannel(subscription: Subscription) {
|
||||
Client.close(subscription.id)
|
||||
subscriptions = subscriptions.minus(subscription.id)
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateFilters() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFiltersSuspend() {
|
||||
checkNotInMainThread()
|
||||
|
||||
// saves the channels that are currently active
|
||||
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
|
||||
// saves the current content to only update if it changes
|
||||
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
|
||||
|
||||
updateChannelFilters()
|
||||
|
||||
// Makes sure to only send an updated filter when it actually changes.
|
||||
subscriptions.values.forEach { updatedSubscription ->
|
||||
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
|
||||
|
||||
if (updatedSubscription.id in currentFilters.keys) {
|
||||
if (updatedSubscriotionNewFilters == null) {
|
||||
// was active and is not active anymore, just close.
|
||||
Client.close(updatedSubscription.id)
|
||||
} else {
|
||||
// was active and is still active, check if it has changed.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.close(updatedSubscription.id)
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
} else {
|
||||
// hasn't changed, does nothing.
|
||||
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (updatedSubscriotionNewFilters == null) {
|
||||
// was not active and is still not active, does nothing
|
||||
} else {
|
||||
// was not active and becomes active, sends the filter.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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
|
||||
|
||||
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createLiveStreamFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
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(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
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(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 8)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatsTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(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(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" 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()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
val globalFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
|
||||
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
private var hashtagToWatch: String? = null
|
||||
|
||||
fun createLoadHashtagFilter(): TypedFilter? {
|
||||
val hashToLoad = hashtagToWatch ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
tags = mapOf(
|
||||
"t" to listOf(
|
||||
hashToLoad,
|
||||
hashToLoad.lowercase(),
|
||||
hashToLoad.uppercase(),
|
||||
hashToLoad.capitalize()
|
||||
)
|
||||
),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadHashtagChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadHashtag(tag: String?) {
|
||||
hashtagToWatch = tag
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -2,61 +2,139 @@ 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.ClassifiedsEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
|
||||
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 kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrHomeDataSource: NostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
invalidateFilters()
|
||||
}
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
override fun start() {
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.observeForever(cacheListener)
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.removeObserver(cacheListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.userProfile().follows
|
||||
|
||||
val followKeys = follows.map {
|
||||
it.pubkeyHex.substring(0, 6)
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun start() {
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.observeForever(cacheListener)
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind),
|
||||
authors = followSet,
|
||||
limit = 400
|
||||
)
|
||||
)
|
||||
}
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.removeObserver(cacheListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel()
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ClassifiedsEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind,
|
||||
LiveActivitiesChatMessageEvent.kind,
|
||||
LiveActivitiesEvent.kind
|
||||
),
|
||||
authors = followSet,
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(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, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" 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,
|
||||
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(), createFollowCommunitiesFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
|
||||
class NostrLnZapPaymentResponseDataSource(
|
||||
private val fromServiceHex: String,
|
||||
private val toUserHex: String,
|
||||
private val replyingToHex: String,
|
||||
private val authSigningKey: ByteArray
|
||||
) : NostrDataSource("LnZapPaymentResponseFeed") {
|
||||
|
||||
val feedTypes = setOf(FeedType.WALLET_CONNECT)
|
||||
|
||||
private fun createWalletConnectServiceWatcher(): TypedFilter {
|
||||
// downloads all the reactions to a given event.
|
||||
return TypedFilter(
|
||||
types = feedTypes,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapPaymentResponseEvent.kind),
|
||||
authors = listOf(fromServiceHex),
|
||||
tags = mapOf(
|
||||
"e" to listOf(replyingToHex),
|
||||
"p" to listOf(toUserHex)
|
||||
),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val channel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val wc = createWalletConnectServiceWatcher()
|
||||
|
||||
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
|
||||
}
|
||||
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
val event = RelayAuthEvent.create(relay.url, challenge, authSigningKey)
|
||||
Client.send(
|
||||
event,
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,78 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
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 nostr.postr.JsonFilter
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.toHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
|
||||
object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") {
|
||||
private var hexToWatch: String? = null
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var searchString: String? = null
|
||||
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
if (hexToWatch == null) {
|
||||
return null
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
val mySearchString = searchString
|
||||
if (mySearchString.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val hexToWatch = try {
|
||||
Nip19.uriToRoute(mySearchString)?.hex ?: Hex.decode(mySearchString).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return listOfNotNull(
|
||||
hexToWatch?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
)
|
||||
)
|
||||
},
|
||||
hexToWatch?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
)
|
||||
)
|
||||
},
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 100
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
search = mySearchString,
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
val searchChannel = requestNewChannel()
|
||||
|
||||
val searchChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
searchChannel.typedFilters = createAnythingWithIDFilter()
|
||||
}
|
||||
|
||||
fun search(eventId: String) {
|
||||
try {
|
||||
val hex = if (eventId.startsWith("npub") || eventId.startsWith("nsec")) {
|
||||
decodePublicKey(eventId).toHex()
|
||||
} else if (eventId.startsWith("note")) {
|
||||
eventId.bechToBytes().toHex()
|
||||
} else {
|
||||
eventId
|
||||
}
|
||||
hexToWatch = hex
|
||||
invalidateFilters()
|
||||
} catch (e: Exception) {
|
||||
// Usually when people add an incomplete npub or note.
|
||||
override fun updateChannelFilters() {
|
||||
searchChannel.typedFilters = createAnythingWithIDFilter()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
hexToWatch = null
|
||||
}
|
||||
}
|
||||
fun search(searchString: String) {
|
||||
this.searchString = searchString
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
searchString = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,102 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
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 nostr.postr.JsonFilter
|
||||
|
||||
object NostrSingleChannelDataSource: NostrDataSource("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<String>()
|
||||
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<String>()
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): TypedFilter? {
|
||||
val reactionsToWatch = channelsToWatch.map { it }
|
||||
private fun createRepliesAndReactionsFilter(): TypedFilter? {
|
||||
val reactionsToWatch = channelsToWatch.map { it }
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to reactionsToWatch)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to reactionsToWatch)
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() && it is PublicChatChannel }
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.map { LocalCache.getOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() }
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
// downloads linked events to this event.
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
|
||||
.filterIsInstance<LiveActivitiesChannel>()
|
||||
.filter { it.info == null }
|
||||
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
|
||||
}
|
||||
// downloads linked events to this event.
|
||||
return directEventsToLoad.map {
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
|
||||
fun remove(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
|
||||
|
||||
singleChannelChannel.typedFilters = (
|
||||
(listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun remove(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +1,189 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
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.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
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.relays.FeedType
|
||||
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 java.util.Date
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<String>()
|
||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
private var addressesToWatch = setOf<Note>()
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
|
||||
val reactionsToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
|
||||
private fun createTagToAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
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
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime,
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
private fun createAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = addressesToWatch.filter { it.event == null }
|
||||
|
||||
return reactionsToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
authors = listOf(aTag.pubKeyHex)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
|
||||
val reactionsToWatch = eventsToWatch
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return reactionsToWatch.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
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
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime,
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = eventsToWatch
|
||||
.filter { it.event == null }
|
||||
|
||||
val threadingEventsToLoad = eventsToWatch
|
||||
.mapNotNull { it.replyTo }
|
||||
.flatten()
|
||||
.filter { it !is AddressableNote && it.event == null }
|
||||
|
||||
val interestedEvents =
|
||||
(directEventsToLoad + threadingEventsToLoad)
|
||||
.map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = eventsToWatch
|
||||
.map { LocalCache.getOrCreateNote(it) }
|
||||
.filter { it.event == null }
|
||||
|
||||
val threadingEventsToLoad = eventsToWatch
|
||||
.map { LocalCache.getOrCreateNote(it) }
|
||||
.mapNotNull { it.replyTo }
|
||||
.flatten()
|
||||
.filter { it.event == null }
|
||||
|
||||
val interestedEvents =
|
||||
(directEventsToLoad + threadingEventsToLoad)
|
||||
.map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
val singleEventChannel = requestNewChannel { time, relayUrl ->
|
||||
checkNotInMainThread()
|
||||
|
||||
val singleEventChannel = requestNewChannel { time ->
|
||||
eventsToWatch.forEach {
|
||||
LocalCache.getOrCreateNote(it).lastReactionsDownloadTime = time
|
||||
eventsToWatch.forEach {
|
||||
val eose = it.lastReactionsDownloadTime[relayUrl]
|
||||
if (eose == null) {
|
||||
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val addresses = createAddressFilter()
|
||||
val addressReactions = createTagToAddressFilter()
|
||||
|
||||
singleEventChannel.typedFilters = listOfNotNull(reactions, missing).flatten().ifEmpty { null }
|
||||
}
|
||||
singleEventChannel.typedFilters = listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
eventsToWatch = eventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
fun add(eventId: Note) {
|
||||
if (!eventsToWatch.contains(eventId)) {
|
||||
eventsToWatch = eventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(eventId: String) {
|
||||
eventsToWatch = eventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
fun remove(eventId: Note) {
|
||||
if (eventsToWatch.contains(eventId)) {
|
||||
eventsToWatch = eventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun addAddress(addressableNote: Note) {
|
||||
if (!addressesToWatch.contains(addressableNote)) {
|
||||
addressesToWatch = addressesToWatch.plus(addressableNote)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAddress(addressableNote: Note) {
|
||||
if (addressesToWatch.contains(addressableNote)) {
|
||||
addressesToWatch = addressesToWatch.minus(addressableNote)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,79 @@
|
||||
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.FeedType
|
||||
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 nostr.postr.JsonFilter
|
||||
import nostr.postr.events.MetadataEvent
|
||||
|
||||
object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
|
||||
var usersToWatch = setOf<User>()
|
||||
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
var usersToWatch = setOf<User>()
|
||||
|
||||
fun createUserFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
fun createUserFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createUserReportFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
fun createUserReportFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
since = it.latestReportTime
|
||||
)
|
||||
)
|
||||
return usersToWatch.map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
since = it.latestEOSEs
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val userChannel = requestNewChannel(){
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
val userChannel = requestNewChannel() { time, relayUrl ->
|
||||
usersToWatch.forEach {
|
||||
val eose = it.latestEOSEs[relayUrl]
|
||||
if (eose == null) {
|
||||
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
|
||||
val userChannelOnce = requestNewChannel()
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
|
||||
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
|
||||
}
|
||||
val userChannelOnce = requestNewChannel()
|
||||
|
||||
fun add(user: User) {
|
||||
usersToWatch = usersToWatch.plus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
override fun updateChannelFilters() {
|
||||
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
|
||||
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun remove(user: User) {
|
||||
usersToWatch = usersToWatch.minus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
fun add(user: User) {
|
||||
if (!usersToWatch.contains(user)) {
|
||||
usersToWatch = usersToWatch.plus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(user: User) {
|
||||
if (usersToWatch.contains(user)) {
|
||||
usersToWatch = usersToWatch.minus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
|
||||
object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
|
||||
private var eventToWatch: String? = null
|
||||
object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
||||
private var eventToWatch: String? = null
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val threadToLoad = eventToWatch ?: return null
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val threadToLoad = eventToWatch ?: return null
|
||||
|
||||
val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad)
|
||||
.filter { it.event == null }
|
||||
.map { it.idHex }
|
||||
.toSet()
|
||||
.ifEmpty { null } ?: return null
|
||||
val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad)
|
||||
.filter { it.event == null }
|
||||
.map { it.idHex }
|
||||
.toSet()
|
||||
.ifEmpty { null } ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
ids = eventsToLoad.map { it.substring(0, 8) }
|
||||
)
|
||||
)
|
||||
}
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
ids = eventsToLoad.map { it.substring(0, 8) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadEventsChannel = requestNewChannel(){
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
val loadEventsChannel = requestNewChannel() { _, _ ->
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
|
||||
}
|
||||
override fun updateChannelFilters() {
|
||||
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String?) {
|
||||
eventToWatch = noteId
|
||||
fun loadThread(noteId: String?) {
|
||||
eventToWatch = noteId
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,125 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
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 nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String?) {
|
||||
if (userId != null) {
|
||||
user = LocalCache.getOrCreateUser(userId)
|
||||
} else {
|
||||
user = null
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
}
|
||||
|
||||
resetFilters()
|
||||
}
|
||||
fun createUserInfoFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserInfoFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createUserPostsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
RepostEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind
|
||||
),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserPostsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createUserReceivedZapsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserReceivedZapsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createFollowFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createFollowersFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowersFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createAcceptedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val userInfoChannel = requestNewChannel()
|
||||
fun createBookmarksFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, AppRecommendationEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userInfoChannel.typedFilters = listOfNotNull(
|
||||
createUserInfoFilter(),
|
||||
createUserPostsFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter(),
|
||||
createUserReceivedZapsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
fun createReceivedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeAwardEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 20
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val userInfoChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userInfoChannel.typedFilters = listOfNotNull(
|
||||
createUserInfoFilter(),
|
||||
createUserPostsFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter(),
|
||||
createUserReceivedZapsFilter(),
|
||||
createAcceptedAwardsFilter(),
|
||||
createReceivedAwardsFilter(),
|
||||
createBookmarksFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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
|
||||
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" 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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.Request
|
||||
|
||||
@Immutable
|
||||
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
|
||||
|
||||
object OnlineChecker {
|
||||
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
|
||||
val fiveMinutes = 1000 * 60 * 5
|
||||
|
||||
fun isOnline(url: String?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (url.isNullOrBlank()) return false
|
||||
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
|
||||
return checkOnlineCache.get(url).online
|
||||
}
|
||||
|
||||
return try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
|
||||
Log.e("LiveActivities", "Failed to check streaming url $url", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
|
||||
fun String.isUTF16Char(pos: Int): Boolean {
|
||||
println("Test $pos ${Character.charCount(this.codePointAt(pos))}")
|
||||
return Character.charCount(this.codePointAt(pos)) == 2
|
||||
}
|
||||
|
||||
fun String.firstFullCharOld(): String {
|
||||
return when (this.length) {
|
||||
0, 1 -> return this
|
||||
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
|
||||
else -> {
|
||||
val first = isUTF16Char(0)
|
||||
val second = isUTF16Char(2)
|
||||
if (first && second) {
|
||||
this.take(4)
|
||||
} else if (first) {
|
||||
this.take(2)
|
||||
} else {
|
||||
this.take(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.firstFullChar(): String {
|
||||
var isInJoin = false
|
||||
var hasHadSecondChance = false
|
||||
var start = 0
|
||||
var previousCharLength = 0
|
||||
var next: Int
|
||||
var codePoint: Int
|
||||
|
||||
var i = 0
|
||||
|
||||
while (i < this.length) {
|
||||
codePoint = codePointAt(i)
|
||||
|
||||
// Skips if it starts with the join char 0x200D
|
||||
if (codePoint == 0x200D && previousCharLength == 0) {
|
||||
next = offsetByCodePoints(i, 1)
|
||||
start = next
|
||||
} else {
|
||||
// If join, searches for the next char
|
||||
if (codePoint == 0xFE0F) {
|
||||
} else if (codePoint == 0x200D) {
|
||||
isInJoin = true
|
||||
} else {
|
||||
// stops when two chars are not joined together
|
||||
if (previousCharLength > 0 && !isInJoin) {
|
||||
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
|
||||
break
|
||||
} else {
|
||||
hasHadSecondChance = true
|
||||
}
|
||||
} else {
|
||||
hasHadSecondChance = false
|
||||
}
|
||||
|
||||
isInJoin = false
|
||||
}
|
||||
|
||||
// next char to evaluate
|
||||
next = offsetByCodePoints(i, 1)
|
||||
previousCharLength += (next - i)
|
||||
}
|
||||
|
||||
i = next
|
||||
}
|
||||
|
||||
// if ends in join, then seachers backwards until a char is found.
|
||||
if (isInJoin) {
|
||||
i = previousCharLength - 1
|
||||
while (i > 0) {
|
||||
if (this[i].code == 0x200D) {
|
||||
previousCharLength -= 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
i -= 1
|
||||
}
|
||||
}
|
||||
|
||||
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,22 @@
|
||||
package com.vitorpamplona.amethyst.service.connectivitystatus
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
||||
object ConnectivityStatus {
|
||||
private val onMobileData = mutableStateOf(false)
|
||||
val isOnMobileData: MutableState<Boolean> = onMobileData
|
||||
|
||||
private val onWifi = mutableStateOf(false)
|
||||
val isOnWifi: MutableState<Boolean> = onWifi
|
||||
|
||||
fun updateConnectivityStatus(isOnMobileData: Boolean, isOnWifi: Boolean) {
|
||||
if (onMobileData.value != isOnMobileData) {
|
||||
onMobileData.value = isOnMobileData
|
||||
}
|
||||
|
||||
if (onWifi.value != isOnWifi) {
|
||||
onWifi.value = isOnWifi
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.lang
|
||||
|
||||
import android.util.LruCache
|
||||
import com.google.android.gms.tasks.Task
|
||||
import com.google.android.gms.tasks.Tasks
|
||||
import com.google.mlkit.nl.languageid.LanguageIdentification
|
||||
import com.google.mlkit.nl.translate.TranslateLanguage
|
||||
import com.google.mlkit.nl.translate.Translation
|
||||
import com.google.mlkit.nl.translate.Translator
|
||||
import com.google.mlkit.nl.translate.TranslatorOptions
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class ResultOrError(
|
||||
var result: String?,
|
||||
var sourceLang: String?,
|
||||
var targetLang: String?,
|
||||
var error: Exception?
|
||||
)
|
||||
|
||||
object LanguageTranslatorService {
|
||||
private val languageIdentification = LanguageIdentification.getClient()
|
||||
val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b")
|
||||
|
||||
private val translators =
|
||||
object : LruCache<TranslatorOptions, Translator>(10) {
|
||||
override fun create(options: TranslatorOptions): Translator {
|
||||
return Translation.getClient(options)
|
||||
}
|
||||
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: TranslatorOptions,
|
||||
oldValue: Translator,
|
||||
newValue: Translator?
|
||||
) {
|
||||
oldValue.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun identifyLanguage(text: String): Task<String> {
|
||||
return languageIdentification.identifyLanguage(text)
|
||||
}
|
||||
|
||||
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
|
||||
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
|
||||
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
|
||||
|
||||
if (sourceLangCode == null || targetLangCode == null) {
|
||||
return Tasks.forCanceled()
|
||||
}
|
||||
|
||||
val options = TranslatorOptions.Builder()
|
||||
.setSourceLanguage(sourceLangCode)
|
||||
.setTargetLanguage(targetLangCode)
|
||||
.build()
|
||||
|
||||
val translator = translators[options]
|
||||
|
||||
return translator.downloadModelIfNeeded().onSuccessTask {
|
||||
val tasks = mutableListOf<Task<String>>()
|
||||
|
||||
val dict = lnDictionary(text) + urlDictionary(text)
|
||||
|
||||
for (paragraph in encodeDictionary(text, dict).split("\n")) {
|
||||
tasks.add(translator.translate(paragraph))
|
||||
}
|
||||
|
||||
Tasks.whenAll(tasks).continueWith {
|
||||
val results: MutableList<String> = ArrayList()
|
||||
for (task in tasks) {
|
||||
var fixedText = task.result.replace("# [","#[") // fixes tags that always return with a space
|
||||
results.add(decodeDictionary(fixedText, dict))
|
||||
}
|
||||
ResultOrError(results.joinToString("\n"), source, target, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeDictionary(text: String, dict: Map<String, String>): String {
|
||||
var newText = text
|
||||
for (pair in dict) {
|
||||
newText = newText.replace(pair.value, pair.key, true)
|
||||
}
|
||||
return newText
|
||||
}
|
||||
|
||||
private fun decodeDictionary(text: String, dict: Map<String, String>): String {
|
||||
var newText = text
|
||||
for (pair in dict) {
|
||||
newText = newText.replace(pair.key, pair.value, true)
|
||||
}
|
||||
return newText
|
||||
}
|
||||
|
||||
private fun lnDictionary(text: String): Map<String, String> {
|
||||
val matcher = lnRegex.matcher(text)
|
||||
val returningList = mutableMapOf<String, String>()
|
||||
val counter = 0
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val lnInvoice = matcher.group()
|
||||
val short = "Amethystlnindexer${counter}"
|
||||
returningList.put(short, lnInvoice)
|
||||
} catch (e: Exception) {
|
||||
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
private fun urlDictionary(text: String): Map<String, String> {
|
||||
val parser = UrlDetector(text, UrlDetectorOptions.Default)
|
||||
val urlsInText = parser.detect()
|
||||
|
||||
val counter = 0
|
||||
|
||||
return urlsInText.filter { !it.originalUrl.contains(",") || !it.originalUrl.contains("。") }.associate {
|
||||
"Amethysturlindexer${counter}" to it.originalUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun autoTranslate(text: String, dontTranslateFrom: Set<String>, translateTo: String): Task<ResultOrError> {
|
||||
return identifyLanguage(text).onSuccessTask {
|
||||
if (it == translateTo) {
|
||||
Tasks.forCanceled()
|
||||
} else if (it != "und" && !dontTranslateFrom.contains(it)) {
|
||||
translate(text, it, translateTo)
|
||||
} else {
|
||||
Tasks.forCanceled()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import java.net.URLEncoder
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -10,154 +12,186 @@ import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Bech32
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.math.BigDecimal
|
||||
import java.net.URLEncoder
|
||||
|
||||
class LightningAddressResolver {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
class LightningAddressResolver() {
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
|
||||
}
|
||||
|
||||
if (lnaddress.toLowerCase().startsWith("lnurl")) {
|
||||
return try {
|
||||
String(Bech32.decodeBytes(lnaddress, false).second)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
val url = assembleUrl(lnaddress)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder().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")
|
||||
}
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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().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")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fetchLightningAddressJson(lnaddress,
|
||||
onSuccess = {
|
||||
onSuccess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32))
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchLightningAddressJson(lnaddress,
|
||||
onSuccess = {
|
||||
val lnurlp = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("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")
|
||||
}
|
||||
|
||||
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
|
||||
|
||||
callback?.let { callback ->
|
||||
fetchLightningInvoice(callback, milliSats, message, if (allowsNostr) nostrRequest else null,
|
||||
onSuccess = {
|
||||
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")
|
||||
if (lnaddress.lowercase().startsWith("lnurl")) {
|
||||
return try {
|
||||
String(Bech32.decodeBytes(lnaddress, false).second)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.let { pr ->
|
||||
onSuccess(pr)
|
||||
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
|
||||
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) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(lnaddress)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = {
|
||||
onSuccess(Bech32.encodeBytes("lnurl", it.toByteArray(), Bech32.Encoding.Bech32))
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = { lnAddressJson ->
|
||||
onProgress(0.4f)
|
||||
|
||||
val lnurlp = try {
|
||||
mapper.readTree(lnAddressJson)
|
||||
} catch (t: Throwable) {
|
||||
onError("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")
|
||||
}
|
||||
|
||||
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
|
||||
|
||||
callback?.let { cb ->
|
||||
fetchLightningInvoice(
|
||||
cb,
|
||||
milliSats,
|
||||
message,
|
||||
if (allowsNostr) nostrRequest else null,
|
||||
onSuccess = {
|
||||
onProgress(0.6f)
|
||||
|
||||
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")
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
|
||||
// Forces LN Invoice amount to be the requested amount.
|
||||
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
|
||||
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
|
||||
onProgress(0.7f)
|
||||
onSuccess(pr)
|
||||
} else {
|
||||
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server")
|
||||
}
|
||||
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,156 +1,176 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/** based on litecoinj */
|
||||
object LnInvoiceUtil {
|
||||
private val invoicePattern = Pattern.compile("lnbc((?<amount>\\d+)(?<multiplier>[munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE)
|
||||
private val invoicePattern = Pattern.compile("lnbc((?<amount>\\d+)(?<multiplier>[munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
/** The Bech32 character set for encoding. */
|
||||
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
/** The Bech32 character set for encoding. */
|
||||
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
/** The Bech32 character set for decoding. */
|
||||
private val CHARSET_REV = byteArrayOf(
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1,
|
||||
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
|
||||
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1,
|
||||
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
|
||||
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1
|
||||
)
|
||||
/** The Bech32 character set for decoding. */
|
||||
private val CHARSET_REV = byteArrayOf(
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1,
|
||||
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
|
||||
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1,
|
||||
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
|
||||
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1
|
||||
)
|
||||
|
||||
|
||||
/** Find the polynomial with value coefficients mod the generator as 30-bit. */
|
||||
private fun polymod(values: ByteArray): Int {
|
||||
var c = 1
|
||||
for (v_i in values) {
|
||||
val c0 = c ushr 25 and 0xff
|
||||
c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff)
|
||||
if (c0 and 1 != 0) c = c xor 0x3b6a57b2
|
||||
if (c0 and 2 != 0) c = c xor 0x26508e6d
|
||||
if (c0 and 4 != 0) c = c xor 0x1ea119fa
|
||||
if (c0 and 8 != 0) c = c xor 0x3d4233dd
|
||||
if (c0 and 16 != 0) c = c xor 0x2a1462b3
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
/** Expand a HRP for use in checksum computation. */
|
||||
private fun expandHrp(hrp: String): ByteArray {
|
||||
val hrpLength = hrp.length
|
||||
val ret = ByteArray(hrpLength * 2 + 1)
|
||||
for (i in 0 until hrpLength) {
|
||||
val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII
|
||||
ret[i] = (c ushr 5 and 0x07).toByte()
|
||||
ret[i + hrpLength + 1] = (c and 0x1f).toByte()
|
||||
}
|
||||
ret[hrpLength] = 0
|
||||
return ret
|
||||
}
|
||||
|
||||
/** Verify a checksum. */
|
||||
private fun verifyChecksum(hrp: String, values: ByteArray): Boolean {
|
||||
val hrpExpanded: ByteArray = expandHrp(hrp)
|
||||
val combined = ByteArray(hrpExpanded.size + values.size)
|
||||
System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size)
|
||||
System.arraycopy(values, 0, combined, hrpExpanded.size, values.size)
|
||||
return polymod(combined) == 1
|
||||
}
|
||||
|
||||
class AddressFormatException(message: String): Exception(message) {
|
||||
|
||||
}
|
||||
|
||||
fun decodeUnlimitedLength(invoice: String): Boolean {
|
||||
var lower = false
|
||||
var upper = false
|
||||
for (i in 0 until invoice.length) {
|
||||
val c = invoice[i]
|
||||
if (c.code < 33 || c.code > 126) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
if (c in 'a'..'z') {
|
||||
if (upper) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
lower = true
|
||||
}
|
||||
if (c in 'A'..'Z') {
|
||||
if (lower) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
upper = true
|
||||
}
|
||||
}
|
||||
val pos = invoice.lastIndexOf('1')
|
||||
if (pos < 1) throw AddressFormatException("Missing human-readable part")
|
||||
val dataPartLength = invoice.length - 1 - pos
|
||||
if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength")
|
||||
val values = ByteArray(dataPartLength)
|
||||
for (i in 0 until dataPartLength) {
|
||||
val c = invoice[i + pos + 1]
|
||||
if (CHARSET_REV.get(c.code).toInt() == -1) throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1))
|
||||
values[i] = CHARSET_REV.get(c.code)
|
||||
}
|
||||
val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT)
|
||||
if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses invoice amount according to
|
||||
* https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part
|
||||
* @return invoice amount in bitcoins, zero if the invoice has no amount
|
||||
* @throws RuntimeException if invoice format is incorrect
|
||||
*/
|
||||
fun getAmount(invoice: String): BigDecimal {
|
||||
try {
|
||||
decodeUnlimitedLength(invoice) // checksum must match
|
||||
} catch (e: AddressFormatException) {
|
||||
throw IllegalArgumentException("Cannot decode invoice", e)
|
||||
/** Find the polynomial with value coefficients mod the generator as 30-bit. */
|
||||
private fun polymod(values: ByteArray): Int {
|
||||
var c = 1
|
||||
for (v_i in values) {
|
||||
val c0 = c ushr 25 and 0xff
|
||||
c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff)
|
||||
if (c0 and 1 != 0) c = c xor 0x3b6a57b2
|
||||
if (c0 and 2 != 0) c = c xor 0x26508e6d
|
||||
if (c0 and 4 != 0) c = c xor 0x1ea119fa
|
||||
if (c0 and 8 != 0) c = c xor 0x3d4233dd
|
||||
if (c0 and 16 != 0) c = c xor 0x2a1462b3
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
val matcher = invoicePattern.matcher(invoice)
|
||||
require(matcher.matches()) { "Failed to match HRP pattern" }
|
||||
val amountGroup = matcher.group("amount")
|
||||
val multiplierGroup = matcher.group("multiplier")
|
||||
if (amountGroup == null) {
|
||||
return BigDecimal.ZERO
|
||||
/** Expand a HRP for use in checksum computation. */
|
||||
private fun expandHrp(hrp: String): ByteArray {
|
||||
val hrpLength = hrp.length
|
||||
val ret = ByteArray(hrpLength * 2 + 1)
|
||||
for (i in 0 until hrpLength) {
|
||||
val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII
|
||||
ret[i] = (c ushr 5 and 0x07).toByte()
|
||||
ret[i + hrpLength + 1] = (c and 0x1f).toByte()
|
||||
}
|
||||
ret[hrpLength] = 0
|
||||
return ret
|
||||
}
|
||||
val amount = BigDecimal(amountGroup)
|
||||
if (multiplierGroup == null) {
|
||||
return amount
|
||||
}
|
||||
require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { "sub-millisatoshi amount" }
|
||||
return amount.multiply(multiplier(multiplierGroup))
|
||||
}
|
||||
|
||||
fun getAmountInSats(invoice: String): BigDecimal {
|
||||
return getAmount(invoice).multiply(BigDecimal(100000000))
|
||||
}
|
||||
|
||||
private fun multiplier(multiplier: String): BigDecimal {
|
||||
return when (multiplier.toLowerCase()) {
|
||||
"m" -> BigDecimal("0.001")
|
||||
"u" -> BigDecimal("0.000001")
|
||||
"n" -> BigDecimal("0.000000001")
|
||||
"p" -> BigDecimal("0.000000000001")
|
||||
else -> throw IllegalArgumentException("Invalid multiplier: $multiplier")
|
||||
/** Verify a checksum. */
|
||||
private fun verifyChecksum(hrp: String, values: ByteArray): Boolean {
|
||||
val hrpExpanded: ByteArray = expandHrp(hrp)
|
||||
val combined = ByteArray(hrpExpanded.size + values.size)
|
||||
System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size)
|
||||
System.arraycopy(values, 0, combined, hrpExpanded.size, values.size)
|
||||
return polymod(combined) == 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds LN invoice in the provided input string and returns it.
|
||||
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
|
||||
* It will only return the first invoice found in the input.
|
||||
*
|
||||
* @return the invoice if it was found. null for null input or if no invoice is found
|
||||
*/
|
||||
fun findInvoice(input: String?): String? {
|
||||
if (input == null) {
|
||||
return null
|
||||
class AddressFormatException(message: String) : Exception(message)
|
||||
|
||||
fun decodeUnlimitedLength(invoice: String): Boolean {
|
||||
var lower = false
|
||||
var upper = false
|
||||
for (i in 0 until invoice.length) {
|
||||
val c = invoice[i]
|
||||
if (c.code < 33 || c.code > 126) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
if (c in 'a'..'z') {
|
||||
if (upper) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
lower = true
|
||||
}
|
||||
if (c in 'A'..'Z') {
|
||||
if (lower) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
upper = true
|
||||
}
|
||||
}
|
||||
val pos = invoice.lastIndexOf('1')
|
||||
if (pos < 1) throw AddressFormatException("Missing human-readable part")
|
||||
val dataPartLength = invoice.length - 1 - pos
|
||||
if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength")
|
||||
val values = ByteArray(dataPartLength)
|
||||
for (i in 0 until dataPartLength) {
|
||||
val c = invoice[i + pos + 1]
|
||||
if (CHARSET_REV.get(c.code).toInt() == -1) throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1))
|
||||
values[i] = CHARSET_REV.get(c.code)
|
||||
}
|
||||
val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT)
|
||||
if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum")
|
||||
return true
|
||||
}
|
||||
val matcher = invoicePattern.matcher(input)
|
||||
return if (matcher.find()) {
|
||||
matcher.group()
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses invoice amount according to
|
||||
* https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part
|
||||
* @return invoice amount in bitcoins, zero if the invoice has no amount
|
||||
* @throws RuntimeException if invoice format is incorrect
|
||||
*/
|
||||
private fun getAmount(invoice: String): BigDecimal {
|
||||
try {
|
||||
decodeUnlimitedLength(invoice) // checksum must match
|
||||
} catch (e: AddressFormatException) {
|
||||
throw IllegalArgumentException("Cannot decode invoice: $invoice", e)
|
||||
}
|
||||
|
||||
val matcher = invoicePattern.matcher(invoice)
|
||||
require(matcher.matches()) { "Failed to match HRP pattern" }
|
||||
val amountGroup = matcher.group("amount")
|
||||
val multiplierGroup = matcher.group("multiplier")
|
||||
if (amountGroup == null) {
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
val amount = BigDecimal(amountGroup)
|
||||
if (multiplierGroup == null) {
|
||||
return amount
|
||||
}
|
||||
require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { "sub-millisatoshi amount" }
|
||||
return amount.multiply(multiplier(multiplierGroup))
|
||||
}
|
||||
|
||||
fun getAmountInSats(invoice: String): BigDecimal {
|
||||
checkNotInMainThread()
|
||||
|
||||
return getAmount(invoice).multiply(BigDecimal(100000000))
|
||||
}
|
||||
|
||||
private fun multiplier(multiplier: String): BigDecimal {
|
||||
return when (multiplier.lowercase()) {
|
||||
"m" -> BigDecimal("0.001")
|
||||
"u" -> BigDecimal("0.000001")
|
||||
"n" -> BigDecimal("0.000000001")
|
||||
"p" -> BigDecimal("0.000000000001")
|
||||
else -> throw IllegalArgumentException("Invalid multiplier: $multiplier")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds LN invoice in the provided input string and returns it.
|
||||
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
|
||||
* It will only return the first invoice found in the input.
|
||||
*
|
||||
* @return the invoice if it was found. null for null input or if no invoice is found
|
||||
*/
|
||||
fun findInvoice(input: String?): String? {
|
||||
if (input == null) {
|
||||
return null
|
||||
}
|
||||
val matcher = invoicePattern.matcher(input)
|
||||
return if (matcher.find()) {
|
||||
matcher.group()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the string contains an LN invoice, returns a Pair of the start and end
|
||||
* positions of the invoice in the string. Otherwise, returns (0, 0). This is
|
||||
* used to ensure we don't accidentally cut an invoice in the middle when taking
|
||||
* only a portion of the available text.
|
||||
*/
|
||||
fun locateInvoice(input: String?): Pair<Int, Int> {
|
||||
if (input == null) {
|
||||
return Pair(0, 0)
|
||||
}
|
||||
val matcher = invoicePattern.matcher(input)
|
||||
return if (matcher.find()) {
|
||||
Pair(matcher.start(), matcher.end())
|
||||
} else {
|
||||
Pair(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object LnWithdrawalUtil {
|
||||
private val withdrawalPattern = Pattern.compile(
|
||||
"lnurl.+",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
)
|
||||
|
||||
/**
|
||||
* Finds LN withdrawal in the provided input string and returns it.
|
||||
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
|
||||
* It will only return the first withdrawal found in the input.
|
||||
*
|
||||
* @return the invoice if it was found. null for null input or if no invoice is found
|
||||
*/
|
||||
fun findWithdrawal(input: String?): String? {
|
||||
if (input == null) {
|
||||
return null
|
||||
}
|
||||
val matcher = withdrawalPattern.matcher(input)
|
||||
return if (matcher.find()) {
|
||||
matcher.group()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.nip19.Tlv
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toByteArray
|
||||
|
||||
@Immutable
|
||||
data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) {
|
||||
fun toTag() = "$kind:$pubKeyHex:$dTag"
|
||||
|
||||
fun toNAddr(): String {
|
||||
val kind = kind.toByteArray()
|
||||
val author = pubKeyHex.hexToByteArray()
|
||||
val dTag = dTag.toByteArray(Charsets.UTF_8)
|
||||
val relay = relay?.toByteArray(Charsets.UTF_8)
|
||||
|
||||
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, dTag.size.toByte()) + dTag
|
||||
|
||||
if (relay != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
|
||||
}
|
||||
|
||||
fullArray = fullArray +
|
||||
byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author +
|
||||
byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
|
||||
|
||||
return Bech32.encodeBytes(hrp = "naddr", fullArray, Bech32.Encoding.Bech32)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun isATag(key: String): Boolean {
|
||||
return key.startsWith("naddr1") || key.contains(":")
|
||||
}
|
||||
|
||||
fun parse(address: String, relay: String?): ATag? {
|
||||
return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) {
|
||||
parseNAddr(address)
|
||||
} else {
|
||||
parseAtag(address, relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseAtag(atag: String, relay: String?): ATag? {
|
||||
return try {
|
||||
val parts = atag.split(":")
|
||||
Hex.decode(parts[1])
|
||||
ATag(parts[0].toInt(), parts[1], parts[2], relay)
|
||||
} catch (t: Throwable) {
|
||||
Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun parseNAddr(naddr: String): ATag? {
|
||||
try {
|
||||
val key = naddr.removePrefix("nostr:")
|
||||
|
||||
if (key.startsWith("naddr")) {
|
||||
val tlv = Tlv.parse(key.bechToBytes())
|
||||
val d = tlv.get(Tlv.Type.SPECIAL.id)?.get(0)?.toString(Charsets.UTF_8) ?: ""
|
||||
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) }
|
||||
|
||||
if (kind != null && author != null) {
|
||||
return ATag(kind, author, d, relay)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w("ATag", "Issue trying to Decode NIP19 $this: ${e.message}")
|
||||
// e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
@Immutable
|
||||
class AppRecommendationEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
fun recommendations() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
ATag.parse(it[1], it.getOrNull(2))
|
||||
}
|
||||
|
||||
fun forKind() = runCatching { dTag().toInt() }.getOrNull()
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
companion object {
|
||||
const val kind = 31989
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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 AudioTrackEvent(
|
||||
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 participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
|
||||
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
|
||||
fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1)
|
||||
fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1)
|
||||
fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1)
|
||||
fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 31337
|
||||
|
||||
private const val TYPE = "c"
|
||||
private const val PRICE = "price"
|
||||
private const val COVER = "cover"
|
||||
private const val SUBJECT = "subject"
|
||||
private const val MEDIA = "media"
|
||||
|
||||
fun create(
|
||||
type: String,
|
||||
media: String,
|
||||
price: String? = null,
|
||||
cover: String? = null,
|
||||
subject: String? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
): AudioTrackEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf(MEDIA, media),
|
||||
listOf(TYPE, type),
|
||||
price?.let { listOf(PRICE, it) },
|
||||
cover?.let { listOf(COVER, it) },
|
||||
subject?.let { listOf(SUBJECT, it) }
|
||||
)
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return AudioTrackEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Participant(val key: String, val role: String?)
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
@Immutable
|
||||
class BadgeAwardEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun awardees() = taggedUsers()
|
||||
|
||||
fun awardDefinition() = taggedAddresses()
|
||||
|
||||
companion object {
|
||||
const val kind = 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
@Immutable
|
||||
class BadgeDefinitionEvent(
|
||||
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 name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
|
||||
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
|
||||
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
|
||||
fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 30009
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
@Immutable
|
||||
class BadgeProfilesEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
companion object {
|
||||
const val kind = 30008
|
||||
const val standardDTAg = "profile_badges"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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.tagSearch
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
|
||||
|
||||
@Immutable
|
||||
open class BaseTextNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun mentions() = taggedUsers()
|
||||
open fun replyTos() = taggedEvents()
|
||||
|
||||
private var citedUsersCache: Set<HexKey>? = null
|
||||
private var citedNotesCache: Set<HexKey>? = null
|
||||
|
||||
fun citedUsers(): Set<HexKey> {
|
||||
citedUsersCache?.let { return it }
|
||||
|
||||
val matcher = tagSearch.matcher(content)
|
||||
val returningList = mutableSetOf<String>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { tags[it.toInt()] }
|
||||
if (tag != null && tag.size > 1 && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
val matcher2 = nip19regex.matcher(content)
|
||||
while (matcher2.find()) {
|
||||
val uriScheme = matcher2.group(1) // nostr:
|
||||
val type = matcher2.group(2) // npub1
|
||||
val key = matcher2.group(3) // bech32
|
||||
val additionalChars = matcher2.group(4) // additional chars
|
||||
|
||||
try {
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
if (tag != null && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Unable to parse cited users that matched a NIP19 regex", e)
|
||||
}
|
||||
}
|
||||
|
||||
citedUsersCache = returningList
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun findCitations(): Set<HexKey> {
|
||||
citedNotesCache?.let { return it }
|
||||
|
||||
val citations = mutableSetOf<HexKey>()
|
||||
// Removes citations from replies:
|
||||
val matcher = tagSearch.matcher(content)
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { tags[it.toInt()] }
|
||||
if (tag != null && tag.size > 1 && tag[0] == "e") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
if (tag != null && tag.size > 1 && tag[0] == "a") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
val matcher2 = nip19regex.matcher(content)
|
||||
while (matcher2.find()) {
|
||||
val uriScheme = matcher2.group(1) // nostr:
|
||||
val type = matcher2.group(2) // npub1
|
||||
val key = matcher2.group(3) // bech32
|
||||
val additionalChars = matcher2.group(4) // additional chars
|
||||
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
try {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
if (tag != null && tag[0] == "e") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
if (tag != null && tag[0] == "a") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
citedNotesCache = citations
|
||||
return citations
|
||||
}
|
||||
|
||||
fun tagsWithoutCitations(): List<String> {
|
||||
val repliesTo = replyTos()
|
||||
val tagAddresses = taggedAddresses().filter { it.kind != CommunityDefinitionEvent.kind }.map { it.toTag() }
|
||||
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
|
||||
|
||||
val citations = findCitations()
|
||||
|
||||
return if (citations.isEmpty()) {
|
||||
repliesTo + tagAddresses
|
||||
} else {
|
||||
repliesTo.filter { it !in citations }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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,51 +1,51 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import java.util.Date
|
||||
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
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
|
||||
class ChannelCreateEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val channelInfo: ChannelData
|
||||
|
||||
init {
|
||||
channelInfo = try {
|
||||
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
|
||||
@Immutable
|
||||
class ChannelCreateEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun channelInfo(): ChannelData = try {
|
||||
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
|
||||
ChannelData(null, null, null)
|
||||
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
|
||||
ChannelData(null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 40
|
||||
companion object {
|
||||
const val kind = 40
|
||||
|
||||
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
|
||||
val content = try {
|
||||
if (channelInfo != null)
|
||||
gson.toJson(channelInfo)
|
||||
else
|
||||
""
|
||||
} catch (t: Throwable) {
|
||||
Log.e("ChannelCreateEvent", "Couldn't parse channel information", t)
|
||||
""
|
||||
}
|
||||
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelCreateEvent {
|
||||
val content = try {
|
||||
if (channelInfo != null) {
|
||||
gson.toJson(channelInfo)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.e("ChannelCreateEvent", "Couldn't parse channel information", t)
|
||||
""
|
||||
}
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags = emptyList<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = emptyList<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChannelData(var name: String?, var about: String?, var picture: String?)
|
||||
}
|
||||
@Immutable
|
||||
data class ChannelData(val name: String?, val about: String?, val picture: String?)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user