mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
2526 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
501fa25e98 | ||
|
|
b2516bbae9 | ||
|
|
fcd56fdb3a | ||
|
|
8db90728aa | ||
|
|
35146cc794 | ||
|
|
b6cd8d7b05 | ||
|
|
822edf1273 | ||
|
|
2f2919090e | ||
|
|
edf5b28e06 | ||
|
|
c828299793 | ||
|
|
cf13d24c93 | ||
|
|
56f93170e5 | ||
|
|
ed4839bc4d | ||
|
|
b8c39ca5b2 | ||
|
|
5397e4e22e | ||
|
|
ea7a2f3426 | ||
|
|
f2ae8afe3b | ||
|
|
ee235ce682 | ||
|
|
408a9725e0 | ||
|
|
1112fa96aa | ||
|
|
7da309c9cf | ||
|
|
caf4b5ca0b | ||
|
|
c8d6a46444 | ||
|
|
dd5ea51575 | ||
|
|
9d4c690760 | ||
|
|
d7a3cf94b7 | ||
|
|
a063107a15 | ||
|
|
f06fc8a9cb | ||
|
|
0c386811e0 | ||
|
|
4c34c6b8e3 | ||
|
|
4c43399cce | ||
|
|
b8ffb01073 | ||
|
|
50275548b8 | ||
|
|
64df3236ce | ||
|
|
c5a7293120 | ||
|
|
4a969cb77c | ||
|
|
8fffe3f662 | ||
|
|
f5722eb963 | ||
|
|
f5289cb3d3 | ||
|
|
0064a4c596 | ||
|
|
e9d9ef197f | ||
|
|
a5a6c875fa | ||
|
|
8e8521fd6d | ||
|
|
e387e04b6d | ||
|
|
001167ff23 | ||
|
|
449a3f541e | ||
|
|
3b59b11e8b | ||
|
|
aaae806505 | ||
|
|
458a7c7377 | ||
|
|
c95ab2d6b7 | ||
|
|
6a3aae7dbc | ||
|
|
9179e18b06 | ||
|
|
ecf530d512 | ||
|
|
51f7081214 | ||
|
|
7f7debbb73 | ||
|
|
60377c0c35 | ||
|
|
a594d741b1 | ||
|
|
3dd791fbbe | ||
|
|
87afb7f491 | ||
|
|
5f8519d737 | ||
|
|
f83166e576 | ||
|
|
df85c3969c | ||
|
|
e4311ddfa3 | ||
|
|
3a9fd925e1 | ||
|
|
dbcca9cbd2 | ||
|
|
ef250eb0f1 | ||
|
|
8c26af7d40 | ||
|
|
0fe01696a5 | ||
|
|
624bef21b1 | ||
|
|
8493c95e07 | ||
|
|
f7eb043aac | ||
|
|
b80685dcfb | ||
|
|
0dc18cc98c | ||
|
|
c366384a83 | ||
|
|
c298147ad9 | ||
|
|
16e9d43da8 | ||
|
|
da5816a6c6 | ||
|
|
4be1a143e6 | ||
|
|
85ee48e003 | ||
|
|
f536f8e88e | ||
|
|
918a575cfd | ||
|
|
56d8a83ebe | ||
|
|
5f33fb2cea | ||
|
|
170b6cfe69 | ||
|
|
ce991a8aea | ||
|
|
bd5b61ee64 | ||
|
|
494d7edc1f | ||
|
|
8e2d684cb1 | ||
|
|
e994839468 | ||
|
|
76d97d6d16 | ||
|
|
442f75fc41 | ||
|
|
d2214889b6 | ||
|
|
d4da14e4fa | ||
|
|
56dec11901 | ||
|
|
8624dcf2d4 | ||
|
|
7edccdb236 | ||
|
|
d8fb49bd52 | ||
|
|
108d8a2e57 | ||
|
|
7b6bd3579c | ||
|
|
9f398b5ef8 | ||
|
|
568a175735 | ||
|
|
aa80d1b84a | ||
|
|
fd752a4997 | ||
|
|
7302d260d9 | ||
|
|
823540160a | ||
|
|
0187529f8d | ||
|
|
98453c7b65 | ||
|
|
f43e88eee7 | ||
|
|
4baa5bf659 | ||
|
|
ce1db52abb | ||
|
|
cc6a9f1818 | ||
|
|
f11ab08f92 | ||
|
|
c93cc04409 | ||
|
|
56f299569e | ||
|
|
4006aebf35 | ||
|
|
b0aa2a8c31 | ||
|
|
03483c932c | ||
|
|
f3f8beeebe | ||
|
|
0381e22198 | ||
|
|
d90618f7f3 | ||
|
|
41ca6d281e | ||
|
|
1f4f1d4822 | ||
|
|
83870cd06b | ||
|
|
0d4689b064 | ||
|
|
d2e7e171a1 | ||
|
|
aea8573693 | ||
|
|
36b14f93cb | ||
|
|
356a805681 | ||
|
|
c9e5646df2 | ||
|
|
f50a3c5bbb | ||
|
|
46fe3c2f4a | ||
|
|
5ed0bf48a6 | ||
|
|
0f59befb03 | ||
|
|
c1d8aac030 | ||
|
|
0d3c60b9c2 | ||
|
|
fa80912db2 | ||
|
|
f9ea1270fa | ||
|
|
e0ffa6d4fb | ||
|
|
3df7129f00 | ||
|
|
692f77dcd1 | ||
|
|
273186ef35 | ||
|
|
b64d6c24c8 | ||
|
|
1f2a153f47 | ||
|
|
a36f2cc93f | ||
|
|
499316687f | ||
|
|
693a75b9ec | ||
|
|
d0b3c9b019 | ||
|
|
d810aaa2d0 | ||
|
|
965db8b91b | ||
|
|
5f7088b7bb | ||
|
|
d7c80ca138 | ||
|
|
75da18a30b | ||
|
|
897e9d84cb | ||
|
|
fe141d1ee3 | ||
|
|
4a7a09c43a | ||
|
|
fd26ae5543 | ||
|
|
631270fc37 | ||
|
|
816de5bf03 | ||
|
|
1ef9902ece | ||
|
|
704a08e7bb | ||
|
|
ffbf2b4024 | ||
|
|
a33ab5c1e9 | ||
|
|
b699b30954 | ||
|
|
1d1ee8670e | ||
|
|
bcdf6e5ff7 | ||
|
|
fbb50f4147 | ||
|
|
4e13c5ab9c | ||
|
|
e1a5b9c079 | ||
|
|
a27acd9dc6 | ||
|
|
2f82233b25 | ||
|
|
b232f484bf | ||
|
|
679f9b66ed | ||
|
|
da015e3298 | ||
|
|
4065adac83 | ||
|
|
7339f0ea3b | ||
|
|
1bbc67fed7 | ||
|
|
64f46a55f9 | ||
|
|
5662eda54c | ||
|
|
f56d927f72 | ||
|
|
5685f227f9 | ||
|
|
d539eca8bb | ||
|
|
415bf9f522 | ||
|
|
667c15b3a1 | ||
|
|
f31fa4a9c6 | ||
|
|
3bf6e80650 | ||
|
|
3bf2f858f7 | ||
|
|
5c0d71f034 | ||
|
|
d4d60cb1ff | ||
|
|
b2932cd7e3 | ||
|
|
9d9b5f03db | ||
|
|
e076f97714 | ||
|
|
f5247a5443 | ||
|
|
92e9978859 | ||
|
|
3520fe89ae | ||
|
|
01ed0f3fc1 | ||
|
|
41545a156b | ||
|
|
b38e9247ce | ||
|
|
45ec9e3a62 | ||
|
|
c1004dc643 | ||
|
|
a72fff5f8f | ||
|
|
fca4aee5f5 | ||
|
|
a82f4aacbe | ||
|
|
f21c7bd258 | ||
|
|
533d8b18f0 | ||
|
|
b069f283ac | ||
|
|
eab2f96f02 | ||
|
|
c4b71a0aea | ||
|
|
88933fba8d | ||
|
|
ef049e88ba | ||
|
|
6f27c96aed | ||
|
|
5b605700a3 | ||
|
|
e867571ea6 | ||
|
|
f4759c283e | ||
|
|
4887e6f3a1 | ||
|
|
1392833179 | ||
|
|
529a1f403e | ||
|
|
c9b16309e2 | ||
|
|
81db455bfb | ||
|
|
c017b9cace | ||
|
|
25f5551a98 | ||
|
|
d8461c2a74 | ||
|
|
25dec49e97 | ||
|
|
424d5ccc9c | ||
|
|
422a018a1f | ||
|
|
5b8afb6f60 | ||
|
|
38827a4db6 | ||
|
|
97bad5707f | ||
|
|
58c8ff646f | ||
|
|
613a3b4912 | ||
|
|
a67155ac25 | ||
|
|
ba27b68c14 | ||
|
|
dfe5f86a23 | ||
|
|
abf0c4fd70 | ||
|
|
ed675a4973 | ||
|
|
6514bacf69 | ||
|
|
e682a95822 | ||
|
|
d367a1f281 | ||
|
|
f5881d4248 | ||
|
|
4c0f223673 | ||
|
|
aae520d2e4 | ||
|
|
251e5535d0 | ||
|
|
a034b495e8 | ||
|
|
41c2097f3c | ||
|
|
22dc037bf7 | ||
|
|
346fb61507 | ||
|
|
c17d66b7e9 | ||
|
|
478b2d12eb | ||
|
|
c14d976ad1 | ||
|
|
d605eb3b0a | ||
|
|
25aaed80b0 | ||
|
|
14582fe9b6 | ||
|
|
7a38269449 | ||
|
|
77bb34eaf8 | ||
|
|
d0f3dde08b | ||
|
|
d5b9d5e80b | ||
|
|
549074ef13 | ||
|
|
2315051504 | ||
|
|
8ba96ca527 | ||
|
|
10c5db8281 | ||
|
|
86b4957067 | ||
|
|
fe19b8b4af | ||
|
|
3532a0cadd | ||
|
|
58277d9697 | ||
|
|
cf76ab9f0e | ||
|
|
51d7fa3aac | ||
|
|
10832e10c8 | ||
|
|
1fce054c9f | ||
|
|
f7b3dada05 | ||
|
|
f35376a7da | ||
|
|
d0f3630abb | ||
|
|
d22fc2874b | ||
|
|
23b3ff9fe8 | ||
|
|
52ac0c184d | ||
|
|
923ddf1c83 | ||
|
|
cc186ef91b | ||
|
|
02def3e0b1 | ||
|
|
036b385f38 | ||
|
|
fdff92dff9 | ||
|
|
8c854c43be | ||
|
|
d707a951c3 | ||
|
|
b1566d8f8b | ||
|
|
481db7ebeb | ||
|
|
a18c6c0bc8 | ||
|
|
4d6f6d0ed5 | ||
|
|
1b043603f9 | ||
|
|
dddc9e1346 | ||
|
|
b5662fd05a | ||
|
|
726f9e2f4f | ||
|
|
ca55472010 | ||
|
|
71bbae2f9b | ||
|
|
1971956443 | ||
|
|
282935c5f3 | ||
|
|
d4d7d58378 | ||
|
|
5d1e3e804f | ||
|
|
9475978612 | ||
|
|
c32a64fbfe | ||
|
|
da40de49cd | ||
|
|
e237853a8f | ||
|
|
63f62167a4 | ||
|
|
4675212e1d | ||
|
|
f1b15cc3c5 | ||
|
|
a747f25906 | ||
|
|
e72ae565d3 | ||
|
|
be41627c2b | ||
|
|
426378a7ee | ||
|
|
f9efc0ae2d | ||
|
|
09dfa79dea | ||
|
|
8af955ce20 | ||
|
|
5b0fccb9e0 | ||
|
|
bef607ec41 | ||
|
|
bfbdc65bef | ||
|
|
cfb2f98f66 | ||
|
|
fb0597b2f4 | ||
|
|
c663fd0f5a | ||
|
|
2a8b258f80 | ||
|
|
9c0663db69 | ||
|
|
8d8edbaf90 | ||
|
|
5420ddd338 | ||
|
|
fa80cb297d | ||
|
|
cdfb1db253 | ||
|
|
52b87fa17a | ||
|
|
f209b17811 | ||
|
|
a3d38aab1b | ||
|
|
52df3cba02 | ||
|
|
bb8a71f47b | ||
|
|
d78d3aeab3 | ||
|
|
7f6435ba57 | ||
|
|
1a58603477 | ||
|
|
4b36da59ec | ||
|
|
9257305820 | ||
|
|
e414c4be98 | ||
|
|
340760513f | ||
|
|
eed4350bca | ||
|
|
6b946a57c2 | ||
|
|
26cadfee88 | ||
|
|
4cd24e5363 | ||
|
|
e715a74bc4 | ||
|
|
42738e72f7 | ||
|
|
add8f8eba2 | ||
|
|
e82c09e1c3 | ||
|
|
e1b5f0f7b5 | ||
|
|
7e9d5d6063 | ||
|
|
141f0ed63b | ||
|
|
c5645b0ae7 | ||
|
|
fe8f519dbf | ||
|
|
14e860928b | ||
|
|
ba460e011e | ||
|
|
12f793ad51 | ||
|
|
504d582d54 | ||
|
|
a59cd590bc | ||
|
|
2e9696745a | ||
|
|
5d4b16641e | ||
|
|
1ddc9c216b | ||
|
|
349dfe3d42 | ||
|
|
521a41d81b | ||
|
|
c6e103ff87 | ||
|
|
e70f1fedc6 | ||
|
|
651ca9a3ef | ||
|
|
bd7dba1a31 | ||
|
|
cf1d66bc01 | ||
|
|
414e933686 | ||
|
|
fa53ff2c78 | ||
|
|
2c6a084808 | ||
|
|
8abba9246c | ||
|
|
c9de32c3fa | ||
|
|
35a0b04020 | ||
|
|
4cc5aaa49d | ||
|
|
bbf6e02609 | ||
|
|
7ba2810423 | ||
|
|
88f9beafd3 | ||
|
|
0116bc30e4 | ||
|
|
a59fe59657 | ||
|
|
e6dfa07526 | ||
|
|
8e3594cd75 | ||
|
|
d5bbf4b00b | ||
|
|
dc68d6025e | ||
|
|
f8f59abdf1 | ||
|
|
3a37d5e42d | ||
|
|
ca9777be83 | ||
|
|
e7a9890eb1 | ||
|
|
39152d4e90 | ||
|
|
96e26cfb7d | ||
|
|
8844bb73ea | ||
|
|
180a1eb1db | ||
|
|
b77127df77 | ||
|
|
803b231aaa | ||
|
|
b27a12b9b5 | ||
|
|
e7f04ad38d | ||
|
|
25e92666e0 | ||
|
|
e76bffd048 | ||
|
|
558a060994 | ||
|
|
14055894dc | ||
|
|
a494257a27 | ||
|
|
1084d2a063 | ||
|
|
57e4d76da0 | ||
|
|
5ac4314e2a | ||
|
|
a7dbd787f4 | ||
|
|
3a0d7ddac1 | ||
|
|
c221506e78 | ||
|
|
064a732ec8 | ||
|
|
95f463b0aa | ||
|
|
6d4708bbeb | ||
|
|
1d9b2cffa2 | ||
|
|
6c77bf94a8 | ||
|
|
37ff0070ee | ||
|
|
0f13202f84 | ||
|
|
a3ec4d4260 | ||
|
|
e6b5d51cda | ||
|
|
8fe13717dd | ||
|
|
c7e81d1378 | ||
|
|
8e6d71e422 | ||
|
|
95c6dcce5e | ||
|
|
6066c7d06d | ||
|
|
1c5acc00f3 | ||
|
|
d8fd9f127d | ||
|
|
0037d642b0 | ||
|
|
3b0c1b6d4f | ||
|
|
4855e36e4c | ||
|
|
8a3bb6e1ae | ||
|
|
13ce0e7311 | ||
|
|
ee67f2ded9 | ||
|
|
19327b682c | ||
|
|
85dd9dd5a4 | ||
|
|
9d9963d324 | ||
|
|
58e353fb40 | ||
|
|
7bdb3a1a18 | ||
|
|
850277fdc1 | ||
|
|
597fec5c43 | ||
|
|
909eb62db6 | ||
|
|
510583e72e | ||
|
|
f93b08a272 | ||
|
|
bb5ae5d09e | ||
|
|
02a9563de6 | ||
|
|
15efd671ec | ||
|
|
e37d6ebd8b | ||
|
|
2ecfeb6e7f | ||
|
|
a94c8673d5 | ||
|
|
e3acf4c250 | ||
|
|
dc61c5a8e4 | ||
|
|
f3e08bd297 | ||
|
|
62253d8601 | ||
|
|
5daaa6364d | ||
|
|
a706005cfd | ||
|
|
4eb348654f | ||
|
|
191f1344db | ||
|
|
a207d80d55 | ||
|
|
94a7d9cf14 | ||
|
|
f1d39b8517 | ||
|
|
7a14d95973 | ||
|
|
8dbb1a889e | ||
|
|
ca41d4e10a | ||
|
|
ee8ffcd03d | ||
|
|
f9189e9ec1 | ||
|
|
ec985d58e7 | ||
|
|
a2d16c173c | ||
|
|
123803edd3 | ||
|
|
35005dddad | ||
|
|
accc8d4439 | ||
|
|
267d4c505a | ||
|
|
b502ab7de8 | ||
|
|
7eb39b3c4c | ||
|
|
17e33c61e7 | ||
|
|
6851b1bb51 | ||
|
|
b12e675b3a | ||
|
|
cc02806390 | ||
|
|
2ee1285ea1 | ||
|
|
c5ed0cfa60 | ||
|
|
4084a72178 | ||
|
|
2c3e641639 | ||
|
|
8e0b94646a | ||
|
|
8c4ec1dc7d | ||
|
|
e89a04eb56 | ||
|
|
8d14f3615d | ||
|
|
dbb9ee5379 | ||
|
|
0be9447bee | ||
|
|
0d174aae30 | ||
|
|
994363602d | ||
|
|
296fa201f7 | ||
|
|
a05b2681ad | ||
|
|
1b86c3d85b | ||
|
|
2c90aa4b1e | ||
|
|
0fe8f6c78c | ||
|
|
b800b5a664 | ||
|
|
d084714a21 | ||
|
|
ed2d7f57c5 | ||
|
|
eb5d113298 | ||
|
|
cf48516602 | ||
|
|
28d182cc55 | ||
|
|
7eeb2ed66a | ||
|
|
1e8ca6c5f0 | ||
|
|
9e72f5d55e | ||
|
|
1db628719b | ||
|
|
e4ebca29f4 | ||
|
|
67ae61e5d4 | ||
|
|
64bad2b476 | ||
|
|
cfbf113910 | ||
|
|
47e69ce045 | ||
|
|
da3dbb0321 | ||
|
|
1f4ad32d23 | ||
|
|
69db9bdcd5 | ||
|
|
5bcaf065a3 | ||
|
|
1fa2592563 | ||
|
|
ad481e1928 | ||
|
|
5525bc4bd9 | ||
|
|
0b102df117 | ||
|
|
826718d688 | ||
|
|
3b260d98ad | ||
|
|
e499410ddb | ||
|
|
11d9af41bd | ||
|
|
67dc396588 | ||
|
|
d0e66504e4 | ||
|
|
8c11ae5c87 | ||
|
|
abd895480d | ||
|
|
a1a2e86c94 | ||
|
|
83899bda5a | ||
|
|
29b70a52d4 | ||
|
|
dd5c2ca704 | ||
|
|
ce7965d00e | ||
|
|
bb55aea4a4 | ||
|
|
546d238db3 | ||
|
|
774ee20a20 | ||
|
|
1c4b2e9d1a | ||
|
|
b236938ef5 | ||
|
|
1615337a2c | ||
|
|
936b17f54a | ||
|
|
0cfcfaf899 | ||
|
|
f788d031c9 | ||
|
|
ddc50f379d | ||
|
|
bfe5fcf0b1 | ||
|
|
fb9c070ee6 | ||
|
|
285603cc0f | ||
|
|
65cca8aa34 | ||
|
|
6015ea04ce | ||
|
|
fdf11d3ca3 | ||
|
|
5620eb023b | ||
|
|
759ef44440 | ||
|
|
f0670971ef | ||
|
|
c59086068b | ||
|
|
3524f7e412 | ||
|
|
2e2a9bb917 | ||
|
|
a5c370133c | ||
|
|
0495dc88fc | ||
|
|
f536abb22a | ||
|
|
0bae8adebc | ||
|
|
3270254f47 | ||
|
|
dacc9f236b | ||
|
|
cd316fb52f | ||
|
|
bf7638eaa9 | ||
|
|
3f049a0536 | ||
|
|
86bfca99e6 | ||
|
|
e7dec1702d | ||
|
|
4a848b3296 | ||
|
|
2d9348b640 | ||
|
|
44108c7410 | ||
|
|
9e58c2d3b9 | ||
|
|
acf426ccdc | ||
|
|
2889c5dae5 | ||
|
|
8276156f0a | ||
|
|
6a544340cb | ||
|
|
5ee64b31d2 | ||
|
|
ec1dd3cc6e | ||
|
|
ef6313696d | ||
|
|
ffeeebfe1e | ||
|
|
fcde05c91e | ||
|
|
6b1662da24 | ||
|
|
5925a91e62 | ||
|
|
d83e8007dc | ||
|
|
173a27f085 | ||
|
|
a970080478 | ||
|
|
293bd2557b | ||
|
|
dac1c3717a | ||
|
|
e10332c957 | ||
|
|
de881c23ec | ||
|
|
fcfa2bb05a | ||
|
|
00048606fa | ||
|
|
65bab50a6d | ||
|
|
6d0b05e34d | ||
|
|
195e268865 | ||
|
|
bb1dfee35b | ||
|
|
63340d79e2 | ||
|
|
041aa1c461 | ||
|
|
1594971a60 | ||
|
|
12b7cf410d | ||
|
|
a5e8bdd43a | ||
|
|
ba71c95bec | ||
|
|
e5230d734c | ||
|
|
1a45e26eb8 | ||
|
|
1b81dfd832 | ||
|
|
7ee2a02227 | ||
|
|
8a59904bfc | ||
|
|
049e1d15ce | ||
|
|
203b5e5f39 | ||
|
|
0bb8349a46 | ||
|
|
725fb11ec5 | ||
|
|
c429a97bb4 | ||
|
|
ad12f80679 | ||
|
|
d35e3f8daf | ||
|
|
780a1ef65e | ||
|
|
c413659c87 | ||
|
|
23f42a025a | ||
|
|
ef1ac76839 | ||
|
|
141713bd35 | ||
|
|
b5a1b84fa8 | ||
|
|
1a681e1b53 | ||
|
|
07434eb4b6 | ||
|
|
ac7084ce84 | ||
|
|
dc6f982ae9 | ||
|
|
ea9d25d13b | ||
|
|
afe04969ee | ||
|
|
3823acef0b | ||
|
|
12ac1c48bb | ||
|
|
b345eca8ad | ||
|
|
c3c8b56a39 | ||
|
|
42e20efaa5 | ||
|
|
2ef1cb5fea | ||
|
|
df2f0feef8 | ||
|
|
700813dab7 | ||
|
|
b925b12278 | ||
|
|
6367e9ddc9 | ||
|
|
dfdee1ae96 | ||
|
|
c0ecda94a3 | ||
|
|
acf944ebac | ||
|
|
3c5b9dbca6 | ||
|
|
e0449a9d44 | ||
|
|
4ae86861f8 | ||
|
|
9e13e88ae9 | ||
|
|
1ed9aa64bf | ||
|
|
803decf5ff | ||
|
|
6a91b68b4b | ||
|
|
0f018140f0 | ||
|
|
979e02e7fa | ||
|
|
4fc2d3a7d1 | ||
|
|
d0f138e992 | ||
|
|
b7ac32d768 | ||
|
|
3b5d12b477 | ||
|
|
6f7f8e296b | ||
|
|
d6ee09ecdb | ||
|
|
61970b8ce5 | ||
|
|
b045d0e122 | ||
|
|
47de821f6d | ||
|
|
2c06df7e50 | ||
|
|
52d31502e6 | ||
|
|
d7d8f02a80 | ||
|
|
f3e2327676 | ||
|
|
dba51acbd5 | ||
|
|
20c775913a | ||
|
|
a42afdc78b | ||
|
|
6ecb25b8e1 | ||
|
|
0e68031227 | ||
|
|
466f52245b | ||
|
|
09946c19b9 | ||
|
|
4279ad004c | ||
|
|
50cde1bc3e | ||
|
|
2d5732bcce | ||
|
|
a848ea6135 | ||
|
|
095d054d62 | ||
|
|
2e4c9f77e6 | ||
|
|
7efec03056 | ||
|
|
91b6bf6e88 | ||
|
|
35001c0b00 | ||
|
|
ce96ab873f | ||
|
|
8c1888b4d5 | ||
|
|
0192a4eb20 | ||
|
|
fee85a6111 | ||
|
|
13ee0a23de | ||
|
|
ebf1571b92 | ||
|
|
a5c63dd311 | ||
|
|
245b767dc8 | ||
|
|
be62d5ad7f | ||
|
|
dc132de18d | ||
|
|
5d1515659e | ||
|
|
6206d550e6 | ||
|
|
d7055062f3 | ||
|
|
bddd8914ae | ||
|
|
2fc7c37270 | ||
|
|
bc22b78f19 | ||
|
|
e6b0b41e80 | ||
|
|
8a7ce8dc07 | ||
|
|
cb4e57d6f6 | ||
|
|
a54835e4f9 | ||
|
|
be0a559b7d | ||
|
|
b801125ff5 | ||
|
|
4ea9d2db7d | ||
|
|
54532de88a | ||
|
|
ba6a7bb07f | ||
|
|
fcd70001bf | ||
|
|
80d4971548 | ||
|
|
fbe20029f7 | ||
|
|
a05a465f69 | ||
|
|
49c131a5cd | ||
|
|
1c23600c57 | ||
|
|
7a8766551f | ||
|
|
7accb5e2b9 | ||
|
|
504f365ab3 | ||
|
|
d9b784bee5 | ||
|
|
aff95853d8 | ||
|
|
adcbbec5be | ||
|
|
ceabf8184a | ||
|
|
7aa71fad3f | ||
|
|
15dfbb2f31 | ||
|
|
0d53a30371 | ||
|
|
38bb39a407 | ||
|
|
560a9ba471 | ||
|
|
38d3194e87 | ||
|
|
462fda1b5c | ||
|
|
63a009f36d | ||
|
|
c3dc8104e9 | ||
|
|
23fbb53c96 | ||
|
|
1fe55ece5d | ||
|
|
bb9435e637 | ||
|
|
67e3a92d68 | ||
|
|
32439b5feb | ||
|
|
136a4061d7 | ||
|
|
b06e53f59d | ||
|
|
9c79ef08c7 | ||
|
|
43b761c98c | ||
|
|
626bfc95cb | ||
|
|
9df0f6c368 | ||
|
|
7c68ffaf6e | ||
|
|
ee1d37dd5b | ||
|
|
ed6d0f6c0f | ||
|
|
533e7d661f | ||
|
|
932664c9f9 | ||
|
|
3d89ed567b | ||
|
|
62e2e2ac65 | ||
|
|
1c8a6d1234 | ||
|
|
c07592f399 | ||
|
|
96b1565de7 | ||
|
|
ee675d06f6 | ||
|
|
7c02b5b58f | ||
|
|
4c7ef8ecff | ||
|
|
99cef643e1 | ||
|
|
08025f7df7 | ||
|
|
92eff02f0a | ||
|
|
539d20b397 | ||
|
|
b51e5f4383 | ||
|
|
0c54f9aa10 | ||
|
|
c98e23add7 | ||
|
|
e4cc65969a | ||
|
|
aef7d91a6b | ||
|
|
d874e798c6 | ||
|
|
390e0615d1 | ||
|
|
e40ca09f7c | ||
|
|
96436912bb | ||
|
|
97290cea48 | ||
|
|
ad2387f8e9 | ||
|
|
d25e89f41d | ||
|
|
04712468aa | ||
|
|
354fc34fbb | ||
|
|
026943439a | ||
|
|
46cb7eb640 | ||
|
|
f465844009 | ||
|
|
eebbda547d | ||
|
|
122603080e | ||
|
|
aff0369b04 | ||
|
|
1c1db6fa1e | ||
|
|
187d77c138 | ||
|
|
950d0bcc93 | ||
|
|
0d66ceb1a4 | ||
|
|
8e0563ed3a | ||
|
|
02870d0cd3 | ||
|
|
9d3a6d3f1d | ||
|
|
2c85260ab1 | ||
|
|
ecebc68e11 | ||
|
|
07c3a3ffe5 | ||
|
|
c0f486db82 | ||
|
|
e0990ae38e | ||
|
|
7005158821 | ||
|
|
9922fa117f | ||
|
|
723b4b5e9f | ||
|
|
2d64eb0cb3 | ||
|
|
6a91ede54c | ||
|
|
8d91b97962 | ||
|
|
bf47c1b431 | ||
|
|
f5a504077c | ||
|
|
95afed5045 | ||
|
|
dcccc1de3a | ||
|
|
5095c9f480 | ||
|
|
30c4e3c646 | ||
|
|
d04f6c977e | ||
|
|
7d97b73356 | ||
|
|
be3eb4314b | ||
|
|
f7abc6e1ee | ||
|
|
c16b93c06a | ||
|
|
82af2644a6 | ||
|
|
7d2b2ee674 | ||
|
|
fdc95a656f | ||
|
|
7b040933d5 | ||
|
|
067f353e50 | ||
|
|
2f3c282ef5 | ||
|
|
0c8564157b | ||
|
|
d6160ea6c2 | ||
|
|
0fc04490fd | ||
|
|
e375b09a24 | ||
|
|
802e04445d | ||
|
|
ca8faa062f | ||
|
|
d6d34b2a88 | ||
|
|
a84371a0b3 | ||
|
|
411b49d68f | ||
|
|
f8fd935a4e | ||
|
|
62be469cd8 | ||
|
|
b42d05ae7a | ||
|
|
e007a02d84 | ||
|
|
cbd5a8a7ee | ||
|
|
fd1c5d5887 | ||
|
|
043196be10 | ||
|
|
66927ca70c | ||
|
|
faf0f6dc1a | ||
|
|
5abb5f2f80 | ||
|
|
37b84108a7 | ||
|
|
5edc945341 | ||
|
|
3833f5f822 | ||
|
|
652373bd01 | ||
|
|
e883a46f53 | ||
|
|
2b7cfa5560 | ||
|
|
ed860454a7 | ||
|
|
ed0435061c | ||
|
|
223865827f | ||
|
|
ec25ef3f82 | ||
|
|
7c4911f2dc | ||
|
|
4ac046fe39 | ||
|
|
bfd5845022 | ||
|
|
d288010a83 | ||
|
|
834efe02bc | ||
|
|
1bd32c854c | ||
|
|
d08998736f | ||
|
|
a5f9b865ec | ||
|
|
375931d4f9 | ||
|
|
7e3f64fc61 | ||
|
|
09024fbc61 | ||
|
|
a530ced594 | ||
|
|
3089524a1f | ||
|
|
5aa2aa4d54 | ||
|
|
12235216ff | ||
|
|
35b9648bab | ||
|
|
cf2959d42e | ||
|
|
b5164d5dd0 | ||
|
|
05cb055b1d | ||
|
|
f6bd12058f | ||
|
|
99ef1fc168 | ||
|
|
bd02515209 | ||
|
|
b3f1b093ed | ||
|
|
491d269f75 | ||
|
|
c79243a912 | ||
|
|
b762907d69 | ||
|
|
396831456c | ||
|
|
31e961c64f | ||
|
|
8baf9ad914 | ||
|
|
c2b0154bb5 | ||
|
|
c2b19c670b | ||
|
|
f97cfa81ca | ||
|
|
21643b781c | ||
|
|
5961db5c77 | ||
|
|
99d5e9f1f7 | ||
|
|
818116d57b | ||
|
|
ef1e743fe2 | ||
|
|
df9568b667 | ||
|
|
b5649e2607 | ||
|
|
92033b16d2 | ||
|
|
9a440513ef | ||
|
|
89aa4a6f95 | ||
|
|
5ac73bbeba | ||
|
|
76eee177a8 | ||
|
|
4124724fb1 | ||
|
|
7921745fd9 | ||
|
|
c24c23f003 | ||
|
|
8a6b71e913 | ||
|
|
29d046a094 | ||
|
|
c0423032c3 | ||
|
|
fd7ff57a37 | ||
|
|
9a0a164ab5 | ||
|
|
b35fd24a93 | ||
|
|
aac366c690 | ||
|
|
04a161dc47 | ||
|
|
0d623b2ecc | ||
|
|
aa9f1b6c4f | ||
|
|
8f77ecd891 | ||
|
|
c4ab506c87 | ||
|
|
e75b451b79 | ||
|
|
7ad47bb137 | ||
|
|
5518293813 | ||
|
|
a23ad2dde7 | ||
|
|
b74bf44141 | ||
|
|
84f10da006 | ||
|
|
d2c19cbbb4 | ||
|
|
ef582eceb6 | ||
|
|
258815d349 | ||
|
|
1b40e70058 | ||
|
|
43f1796058 | ||
|
|
53803eb878 | ||
|
|
7b5a2d1312 | ||
|
|
8f9a4557b5 | ||
|
|
cdf2b10fd2 | ||
|
|
2f29a21d72 | ||
|
|
93ed81dedb | ||
|
|
bcd636b46e | ||
|
|
6fd5f36ff5 | ||
|
|
4b79122899 | ||
|
|
1df4d1db10 | ||
|
|
804194c3f5 | ||
|
|
fc159c4d1a | ||
|
|
754b648877 | ||
|
|
c1606c54be | ||
|
|
82864c2069 | ||
|
|
3dc2a4a2d3 | ||
|
|
7d1a8652cf | ||
|
|
96ac674eaa | ||
|
|
664ea86e6c | ||
|
|
5aa2eb4499 | ||
|
|
0b9a44ab9b | ||
|
|
e3e35fa1eb | ||
|
|
221c58126c | ||
|
|
2cdf5d6673 | ||
|
|
2d5697cc9a | ||
|
|
d4ae290364 | ||
|
|
a690359541 | ||
|
|
b3fa6401f6 | ||
|
|
b58d74138d | ||
|
|
e6c413b176 | ||
|
|
c1399d129e | ||
|
|
337a5e3cae | ||
|
|
32b184a9cd | ||
|
|
fd538ebde1 | ||
|
|
d76de0123a | ||
|
|
de2b1a6c95 | ||
|
|
311964ce33 | ||
|
|
4d4c7d5b72 | ||
|
|
66535e11ce | ||
|
|
682dc62703 | ||
|
|
ba7bd0bd8a | ||
|
|
64b42ede7c | ||
|
|
8afe1ae537 | ||
|
|
258a550e15 | ||
|
|
1603a7473a | ||
|
|
ba6786cdcf | ||
|
|
b017e03728 | ||
|
|
5766bb1887 | ||
|
|
40bc651860 | ||
|
|
8a779e7f3c | ||
|
|
350e2a5a90 | ||
|
|
a75e268466 | ||
|
|
2fd456e33d | ||
|
|
64f7401b46 | ||
|
|
5dcb1350ac | ||
|
|
3aa370d746 | ||
|
|
583855de4f | ||
|
|
05acc19c9a | ||
|
|
71f6fe2375 | ||
|
|
286730c50d | ||
|
|
57d6145677 | ||
|
|
94c78c6327 | ||
|
|
7d2b7789d3 | ||
|
|
fe55ee1818 | ||
|
|
58b6be73ee | ||
|
|
cd646951cf | ||
|
|
3789ba6c07 | ||
|
|
429f9f81c4 | ||
|
|
639e2bb645 | ||
|
|
797bea9e72 | ||
|
|
1c28aa30f0 | ||
|
|
52e67c9ab6 | ||
|
|
fa9ff0c52a | ||
|
|
9cd73c2663 | ||
|
|
8722f37fa5 | ||
|
|
987d8fa5dc | ||
|
|
f4d06eee6d | ||
|
|
e2459e3fbd | ||
|
|
9adc7a7adf | ||
|
|
d474bb0016 | ||
|
|
3942847d16 | ||
|
|
2b2b219536 | ||
|
|
91c19dc2a1 | ||
|
|
0536c141b7 | ||
|
|
16331d268d | ||
|
|
7e2724ea31 | ||
|
|
8facd02e33 | ||
|
|
90872b2160 | ||
|
|
0580aef4a2 | ||
|
|
1a001e7bb3 | ||
|
|
4b91cfe809 | ||
|
|
3d5d7b4987 | ||
|
|
ca0b6ef2ba | ||
|
|
3103b51cf4 | ||
|
|
8bbc017f91 | ||
|
|
0df07ddfcb | ||
|
|
8f0879b8e5 | ||
|
|
f5ac8faa45 | ||
|
|
40aa469866 | ||
|
|
d2590b969b | ||
|
|
2de3ba82cc | ||
|
|
b3dec691d7 | ||
|
|
b40b37b6ac | ||
|
|
e6cd3a99c5 | ||
|
|
d5d289a834 | ||
|
|
a2d9f31a8a | ||
|
|
3790325a0f | ||
|
|
96f57b2c90 | ||
|
|
4b10dd58df | ||
|
|
dc4240a8f5 | ||
|
|
3220d793b4 | ||
|
|
84a52a1ce0 | ||
|
|
aaf86bf53e | ||
|
|
94e0f4ed02 | ||
|
|
af29133272 | ||
|
|
06240b1f15 | ||
|
|
d47ec06b19 | ||
|
|
9689acb5f3 | ||
|
|
bcb3838e85 | ||
|
|
4fcf962c71 | ||
|
|
2de4bb1adc | ||
|
|
9e1a2bb1e4 | ||
|
|
8720b00d4f | ||
|
|
9c35008fc4 | ||
|
|
fad08830a1 | ||
|
|
6d9964a1cb | ||
|
|
70ced84dc3 | ||
|
|
50da982b36 | ||
|
|
7ad8b2ce46 | ||
|
|
94ffe783e9 | ||
|
|
41e47fa71d | ||
|
|
08dd634eae | ||
|
|
cb3aa6c329 | ||
|
|
4e2e24c7ab | ||
|
|
67106e8a11 | ||
|
|
06c7ea0123 | ||
|
|
8022ac525f | ||
|
|
534f1ed8cd | ||
|
|
793b3fcd16 | ||
|
|
a4f84227b6 | ||
|
|
e921f26d5f | ||
|
|
0c3a792a84 | ||
|
|
6481fcbecd | ||
|
|
0f6c3b9a3a | ||
|
|
dcb0e31cd6 | ||
|
|
09cff7c930 | ||
|
|
cb121835f5 | ||
|
|
0610416f61 | ||
|
|
f0116ad06e | ||
|
|
89664a2813 | ||
|
|
9f465d3296 | ||
|
|
9c21a8a7da | ||
|
|
0f3b3c4b2d | ||
|
|
68d4686424 | ||
|
|
e2a812a416 | ||
|
|
2eece49b9b | ||
|
|
c34c107a7c | ||
|
|
e9861d80f6 | ||
|
|
7b84c81d9c | ||
|
|
9caf0d28f5 | ||
|
|
78e5f5e277 | ||
|
|
1bd52f163f | ||
|
|
d825995ef4 | ||
|
|
3e1e3e80fb | ||
|
|
951a3a73d0 | ||
|
|
8a9fdd2c34 | ||
|
|
5dbba6d06d | ||
|
|
5ee754c71a | ||
|
|
a739329ab8 | ||
|
|
c12febf980 | ||
|
|
c25d64558e | ||
|
|
5a3c821da4 | ||
|
|
b90aac496e | ||
|
|
4b52cdb101 | ||
|
|
4a4f03c12b | ||
|
|
1f9aeddb57 | ||
|
|
f3b049382e | ||
|
|
d1c64a0d15 | ||
|
|
8e25271bd6 | ||
|
|
0029df4575 | ||
|
|
b0e029cd59 | ||
|
|
d19ae56afa | ||
|
|
502d39c893 | ||
|
|
4f774ae3c5 | ||
|
|
86a9fb2af5 | ||
|
|
e36e49cc88 | ||
|
|
3ac303c724 | ||
|
|
e3e90229ce | ||
|
|
9b0f24ba94 | ||
|
|
7f6b0ef7c2 | ||
|
|
5c805d7551 | ||
|
|
57fe3740fc | ||
|
|
b871954293 | ||
|
|
d66e8d4cde | ||
|
|
103210ade4 | ||
|
|
0d4936cf4b | ||
|
|
d2b731e372 | ||
|
|
ceba31ec61 | ||
|
|
9684940265 | ||
|
|
dd7387a3f5 | ||
|
|
020ead3c3f | ||
|
|
1430ba4745 | ||
|
|
5f577df819 | ||
|
|
0971b716e1 | ||
|
|
53458ab484 | ||
|
|
879f70fcbb | ||
|
|
eb93664968 | ||
|
|
9816577f00 | ||
|
|
306ce9a36c | ||
|
|
10459b0a7a | ||
|
|
f1d61daa04 | ||
|
|
1ca0abf30d | ||
|
|
d0a9c1ccd0 | ||
|
|
617f1ba2a6 | ||
|
|
f1141e698e | ||
|
|
f94feebbd7 | ||
|
|
82d006f65e | ||
|
|
3a553fa550 | ||
|
|
1e9c3a47a4 | ||
|
|
3d9b4431ad | ||
|
|
6522d74253 | ||
|
|
8a8ae4fb8b | ||
|
|
6cac5fa5e4 | ||
|
|
aa3063acae | ||
|
|
46de90ea65 | ||
|
|
aa44560714 | ||
|
|
c9a6b63f67 | ||
|
|
47211e9136 | ||
|
|
cfbcc49940 | ||
|
|
b4a497fd11 | ||
|
|
9aa287f96a | ||
|
|
a6880f339c | ||
|
|
43996499ba | ||
|
|
5c0c1acc4a | ||
|
|
4bfacbc11e | ||
|
|
633c9e8dcf | ||
|
|
0f2a4d104c | ||
|
|
fb92d2da2f | ||
|
|
d29e90fe8f | ||
|
|
27329b7a0f | ||
|
|
577ef6437f | ||
|
|
dbe593db98 | ||
|
|
ff9509b649 | ||
|
|
2772bdff8b | ||
|
|
c6869eaff6 | ||
|
|
663f5f282e | ||
|
|
9f147f03c7 | ||
|
|
0ceccb23c6 | ||
|
|
ee65169896 | ||
|
|
6ac930c241 | ||
|
|
a09b8c5862 | ||
|
|
160d4722c0 | ||
|
|
497ae937fd | ||
|
|
b366aee1a9 | ||
|
|
09539ac22a | ||
|
|
e1e3a0e6a0 | ||
|
|
54e6be8ef3 | ||
|
|
e8462fe67f | ||
|
|
626daa9967 | ||
|
|
266d4cb34f | ||
|
|
fc72e20da7 | ||
|
|
594abd1d7a | ||
|
|
a0d8a770b7 | ||
|
|
fc7b44b9d0 | ||
|
|
00eb4e364a | ||
|
|
e88a36187d | ||
|
|
21d80518c9 | ||
|
|
7999608f28 | ||
|
|
ee20508e4b | ||
|
|
1231a1483f | ||
|
|
f8dd82f592 | ||
|
|
ca00e6a302 | ||
|
|
aeba5d1b51 | ||
|
|
3e014e7b0f | ||
|
|
95cda8ec08 | ||
|
|
1f3a818922 | ||
|
|
7fe02fcf2c | ||
|
|
1c52887e3a | ||
|
|
62d95e2128 | ||
|
|
d0961d08da | ||
|
|
f38383d4e4 | ||
|
|
739a073d4e | ||
|
|
a195167a56 | ||
|
|
72c089858d | ||
|
|
92fcf05557 | ||
|
|
73d38f6257 | ||
|
|
48d8e418e8 | ||
|
|
ff427d0fde | ||
|
|
6807554d1d | ||
|
|
433a15cf0a | ||
|
|
94c74a1e0c | ||
|
|
f839565152 | ||
|
|
d9c14a78a7 | ||
|
|
a9d87883d2 | ||
|
|
66e7559ddb | ||
|
|
2cd9a7dca6 | ||
|
|
123239443d | ||
|
|
76ce8f2974 | ||
|
|
e002e5c10f | ||
|
|
d101397166 | ||
|
|
2fd96da7c7 | ||
|
|
06bc1922cb | ||
|
|
9f1b5a3c1a | ||
|
|
4f4da12aa7 | ||
|
|
17495631f7 | ||
|
|
0041d87645 | ||
|
|
579f63935a | ||
|
|
b273e6dd4f | ||
|
|
eec8212070 | ||
|
|
af5f136e6c | ||
|
|
12d646e8a9 | ||
|
|
bd26fcfafd | ||
|
|
ca05b768df | ||
|
|
b75024e1fb | ||
|
|
816c598971 | ||
|
|
5636748cf8 | ||
|
|
3defc4d45b | ||
|
|
ee16324673 | ||
|
|
dfabd7afdd | ||
|
|
728768632c | ||
|
|
c2243c53cf | ||
|
|
5aed1cee2c | ||
|
|
8809593bbc | ||
|
|
83e942a7be | ||
|
|
1ee6316e92 | ||
|
|
357ca9b6eb | ||
|
|
b0f5e77077 | ||
|
|
1b7b8fa268 | ||
|
|
82d8411ac6 | ||
|
|
684b78d2df | ||
|
|
8b80ac13db | ||
|
|
83235dab69 | ||
|
|
b01f46b2ee | ||
|
|
6a787f2774 | ||
|
|
53206cc0b2 | ||
|
|
7cb6854489 | ||
|
|
95966e5874 | ||
|
|
723979f185 | ||
|
|
b989bc0236 | ||
|
|
54ab8f1448 | ||
|
|
1b0ff52c61 | ||
|
|
46b70b719a | ||
|
|
1972cdeafb | ||
|
|
39a7d98651 | ||
|
|
a93910f8cb | ||
|
|
314cbe1a32 | ||
|
|
54d0ec806d | ||
|
|
6faab55a55 | ||
|
|
8b5366f9e2 | ||
|
|
15b527c58d | ||
|
|
e501101062 | ||
|
|
15411edfa6 | ||
|
|
e65346d268 | ||
|
|
01b880c4a5 | ||
|
|
0d6760360f | ||
|
|
4610519bb3 | ||
|
|
905655461d | ||
|
|
f759acf66e | ||
|
|
8979706a81 | ||
|
|
0db664fabc | ||
|
|
c33fee59a2 | ||
|
|
eb36489094 | ||
|
|
51ce509928 | ||
|
|
0dd306b7fa | ||
|
|
8a6f830e29 | ||
|
|
bcb9ba26ab | ||
|
|
3aa15889c1 | ||
|
|
b47d9ad4d9 | ||
|
|
b504a2c0d4 | ||
|
|
69c9e84dc2 | ||
|
|
81591d6f76 | ||
|
|
d2187995b8 | ||
|
|
a3257a365b | ||
|
|
9180eeb59f | ||
|
|
b24983b232 | ||
|
|
fe3246c94e | ||
|
|
b9883093ac | ||
|
|
869debaf9d | ||
|
|
6a522f5c92 | ||
|
|
ae13725db5 | ||
|
|
95774ddca5 | ||
|
|
56450cf6c5 | ||
|
|
2da1dda019 | ||
|
|
fb73f6153f | ||
|
|
62807336fd | ||
|
|
fddb9b16ab | ||
|
|
0dcce5a987 | ||
|
|
16cf3c850a | ||
|
|
2b17e9303a | ||
|
|
61d9145310 | ||
|
|
40921d4b03 | ||
|
|
e0e898d4b1 | ||
|
|
bd2005ec6e | ||
|
|
2c825d5380 | ||
|
|
7fe522307a | ||
|
|
9660c739f3 | ||
|
|
9025741c13 | ||
|
|
0ef851e4bb | ||
|
|
7204d337e8 | ||
|
|
cb538bc6a3 | ||
|
|
d0b0f441b1 | ||
|
|
ddd7f19f38 | ||
|
|
e46c8f4c15 | ||
|
|
6b390dad30 | ||
|
|
50e067fbf0 | ||
|
|
04da076d95 | ||
|
|
fd88dfed7d | ||
|
|
22905bc04e | ||
|
|
c02ced7855 | ||
|
|
ea1145c890 | ||
|
|
043c8eff57 | ||
|
|
fbb036da4a | ||
|
|
71d30f83ea | ||
|
|
9a0c998654 | ||
|
|
88082b1ba3 | ||
|
|
ad2b2e6d02 | ||
|
|
5044a687fa | ||
|
|
298d4bb237 | ||
|
|
9c180cb87a | ||
|
|
2e3f2044d5 | ||
|
|
bdf012f641 | ||
|
|
034ee46543 | ||
|
|
6a17f94cb1 | ||
|
|
233059d544 | ||
|
|
7bd9b6a1ec | ||
|
|
04e77daaab | ||
|
|
308048c352 | ||
|
|
3f1173a42a | ||
|
|
39726142ae | ||
|
|
025cbeed07 | ||
|
|
8a1550cbd9 | ||
|
|
49148ddc76 | ||
|
|
d0e870e1b8 | ||
|
|
dbb2bfa512 | ||
|
|
d6b66c65fa | ||
|
|
9a3d365243 | ||
|
|
3b30d5493d | ||
|
|
7bfd1a9107 | ||
|
|
2c9e2de524 | ||
|
|
c89c5eb4b0 | ||
|
|
5c5eb53395 | ||
|
|
55f0092e03 | ||
|
|
54d9ac5730 | ||
|
|
d9a9137d89 | ||
|
|
7beff8c835 | ||
|
|
4a80a16473 | ||
|
|
b3e3b70dd0 | ||
|
|
03662b9d1b | ||
|
|
d33421fce4 | ||
|
|
4db6f5531c | ||
|
|
72ea4a44a2 | ||
|
|
022a9f6f6c | ||
|
|
248848799c | ||
|
|
72f317717b | ||
|
|
4651074774 | ||
|
|
fb058f93b6 | ||
|
|
9e34e9d720 | ||
|
|
04d6da28af | ||
|
|
192a43dc21 | ||
|
|
61510ae7e3 | ||
|
|
488286ce20 | ||
|
|
fab6c65e98 | ||
|
|
accde8622c | ||
|
|
4a590d67a6 | ||
|
|
6c3200a9cc | ||
|
|
f16143bec3 | ||
|
|
8216476916 | ||
|
|
ffd43e448b | ||
|
|
3a566dad6b | ||
|
|
bf861d6bc5 | ||
|
|
b31ace79c3 | ||
|
|
37a92c25f0 | ||
|
|
062e4af118 | ||
|
|
c083295f6a | ||
|
|
924baf0de3 | ||
|
|
6015c968f5 | ||
|
|
3e85991f19 | ||
|
|
893b23e4fb | ||
|
|
cb5d69e358 | ||
|
|
43688c4117 | ||
|
|
4e4521ecef | ||
|
|
7bc9205758 | ||
|
|
abf6cc00ae | ||
|
|
7146838f63 | ||
|
|
1037ab33e3 | ||
|
|
230fd553ae | ||
|
|
eac078c42d | ||
|
|
39c9b75bf4 | ||
|
|
e076a2dbe3 | ||
|
|
5705a26836 | ||
|
|
740c279825 | ||
|
|
718605e44b | ||
|
|
8df2ff839c | ||
|
|
00be9fc595 | ||
|
|
7d25ab13a9 | ||
|
|
d7888096e9 | ||
|
|
aad5aaa930 | ||
|
|
b877e4105a | ||
|
|
d1cd15c389 | ||
|
|
69e139e382 | ||
|
|
b2628764c9 | ||
|
|
f7f0d74be1 | ||
|
|
b47122d79a | ||
|
|
635ad30de9 | ||
|
|
0884318a55 | ||
|
|
d8c89317d2 | ||
|
|
772987dffd | ||
|
|
61c51ee9a8 | ||
|
|
f157f8eb9b | ||
|
|
d96f5953d5 | ||
|
|
ffb5cce509 | ||
|
|
b441292413 | ||
|
|
91748d5c39 | ||
|
|
a825f575de | ||
|
|
0581a3e836 | ||
|
|
82f0ed8a0d | ||
|
|
116bc108c3 | ||
|
|
44868ac4e6 | ||
|
|
2a24ca0787 | ||
|
|
03225fc696 | ||
|
|
a0ab4a824b | ||
|
|
0ba4ca13bf | ||
|
|
060a242a0f | ||
|
|
ae530ed813 | ||
|
|
a5c4a53afe | ||
|
|
fb6137f99a | ||
|
|
de99c50639 | ||
|
|
d5f8994128 | ||
|
|
cf02d808d6 | ||
|
|
f6aa732558 | ||
|
|
d321df79e8 | ||
|
|
d1b4f81083 | ||
|
|
0205f1fd12 | ||
|
|
d739fdac2d | ||
|
|
4a43a87a79 | ||
|
|
6f31898cdc | ||
|
|
6ebc878312 | ||
|
|
518e1c88ce | ||
|
|
f88985b2bf | ||
|
|
89e1e20b40 | ||
|
|
c2e725ef5f | ||
|
|
6e8c348cbc | ||
|
|
84316d320d | ||
|
|
87fb63af06 | ||
|
|
a3b0436d91 | ||
|
|
bb430605bc | ||
|
|
4c0d92f89f | ||
|
|
f0b14edef6 | ||
|
|
aae2ffebd7 | ||
|
|
7b11d0ce81 | ||
|
|
26ba75c7c9 | ||
|
|
67fb3bf488 | ||
|
|
24b29396cb | ||
|
|
25c3532637 | ||
|
|
cff6ee457d | ||
|
|
c0ba6f5b00 | ||
|
|
44d8b8ff33 | ||
|
|
f495acb570 | ||
|
|
ecfc8bcd87 | ||
|
|
724bea6bbb | ||
|
|
6826db13a4 | ||
|
|
c5dd2c5651 | ||
|
|
236ff2d680 | ||
|
|
13e2546d36 | ||
|
|
549b9f53e5 | ||
|
|
974c022aed | ||
|
|
a57566dd84 | ||
|
|
72e86cf391 | ||
|
|
3b23cded56 | ||
|
|
121337eb83 | ||
|
|
fe87d642c9 | ||
|
|
b8112ff1aa | ||
|
|
1eee0bd1ae | ||
|
|
f48335e3be | ||
|
|
fcc4befb22 | ||
|
|
e28a1896ec | ||
|
|
f10e66e6a5 | ||
|
|
825a227e2d | ||
|
|
3446fdaf7f | ||
|
|
9c29bf3f25 | ||
|
|
9642e45775 | ||
|
|
58788d2cd5 | ||
|
|
d7c18341cd | ||
|
|
ee16f6c143 | ||
|
|
d78b55d134 | ||
|
|
ff5e7359e2 | ||
|
|
4fd0f38402 | ||
|
|
5b516f4101 | ||
|
|
1fc390c215 | ||
|
|
2bb09f583b | ||
|
|
89de272ea1 | ||
|
|
b4eba2da02 | ||
|
|
786a173ba5 | ||
|
|
5ef3c8f00e | ||
|
|
d832351bcd | ||
|
|
9ae611831f | ||
|
|
6db287a008 | ||
|
|
99641e1a12 | ||
|
|
abb2f0557d | ||
|
|
76a66e7bd3 | ||
|
|
6cd50a115b | ||
|
|
0ed34cec09 | ||
|
|
c5e3657bfe | ||
|
|
da47965d76 | ||
|
|
a66417ec82 | ||
|
|
62c3c93d2e | ||
|
|
fbecc8b6d1 | ||
|
|
e95afa89ff | ||
|
|
d6f2560c1c | ||
|
|
d320b951ae | ||
|
|
af28e6d466 | ||
|
|
86e6d56dcd | ||
|
|
c66fa1073b | ||
|
|
6d9bffe9e7 | ||
|
|
04158cec6e | ||
|
|
76376a7c40 | ||
|
|
67ea0dcf7f | ||
|
|
cdc8850894 | ||
|
|
ce0c7c842d | ||
|
|
f51f929079 | ||
|
|
d87d2e0ef8 | ||
|
|
a3166f4ff3 | ||
|
|
9340c440f5 | ||
|
|
a7343d5b54 | ||
|
|
0551b82bd9 | ||
|
|
18fca8adac | ||
|
|
5d788ed669 | ||
|
|
54d52e0b77 | ||
|
|
28c92675b2 | ||
|
|
baa9ee0562 | ||
|
|
7326d6be93 | ||
|
|
e187e5392f | ||
|
|
7c8f6eb239 | ||
|
|
6294c65ad6 | ||
|
|
51d9657371 | ||
|
|
32783794ee | ||
|
|
e3858dc830 | ||
|
|
38a9030963 | ||
|
|
29c0c4cf5f | ||
|
|
dcbde81a23 | ||
|
|
56e6f06fe7 | ||
|
|
342b64f0eb | ||
|
|
a64a640165 | ||
|
|
6433830c32 | ||
|
|
3a58819b52 | ||
|
|
5c7be44369 | ||
|
|
6e66d008dc | ||
|
|
c61c4ce16c | ||
|
|
246ce55253 | ||
|
|
a704c81d24 | ||
|
|
9963245ec9 | ||
|
|
64016aa940 | ||
|
|
c6462f102a | ||
|
|
b05083863d | ||
|
|
86fffe6539 | ||
|
|
f94a4cfa65 | ||
|
|
3825642ae7 | ||
|
|
f90f81b2a5 | ||
|
|
bc042e52b0 | ||
|
|
d80b22fa29 | ||
|
|
b63ed4fdbd | ||
|
|
397acce85b | ||
|
|
5305929df3 | ||
|
|
6d4ba6d507 | ||
|
|
a028c2c3fc | ||
|
|
86e5dfa799 | ||
|
|
3aa316d262 | ||
|
|
3f4efdb6d9 | ||
|
|
a178e5f589 | ||
|
|
cc5a6cdaf2 | ||
|
|
d9596335de | ||
|
|
44c600dfa8 | ||
|
|
a92fe0bca2 | ||
|
|
dea9d698df | ||
|
|
94ae7b3da1 | ||
|
|
da1c94f77b | ||
|
|
64ff33e745 | ||
|
|
8810cfde37 | ||
|
|
c7b7ad78b7 | ||
|
|
ff9f25ccdb | ||
|
|
498595b892 | ||
|
|
56878900b0 | ||
|
|
d8d6736d55 | ||
|
|
ddc68e61d5 | ||
|
|
d4e8fc5737 | ||
|
|
3a7dfe7d03 | ||
|
|
d23a859f34 | ||
|
|
824cb4e5f1 | ||
|
|
35aeeb571c | ||
|
|
70d68c89c8 | ||
|
|
35a6331da2 | ||
|
|
6229a0ad63 | ||
|
|
620285596d | ||
|
|
fd2637ff42 | ||
|
|
afe8a06486 | ||
|
|
b682e90f81 | ||
|
|
ec700ca61f | ||
|
|
9b26ffd92a | ||
|
|
a8ca9ab727 | ||
|
|
e5832f6a45 | ||
|
|
8b3035f635 | ||
|
|
9f17cc50f8 | ||
|
|
d0633d07fa | ||
|
|
97b88c7ceb | ||
|
|
4961a22753 | ||
|
|
45b568f6a7 | ||
|
|
446859e8bd | ||
|
|
e1b24c22b9 | ||
|
|
e0b1c4dc3f | ||
|
|
5722507b3d | ||
|
|
74df309527 | ||
|
|
740c6a8c33 | ||
|
|
929a263665 | ||
|
|
6f44c02b5c | ||
|
|
bce01dc264 | ||
|
|
7919637e12 | ||
|
|
28da06ef01 | ||
|
|
428ed464af | ||
|
|
5921b712b0 | ||
|
|
5c9a37f01c | ||
|
|
514a69deec | ||
|
|
356cbc24d4 | ||
|
|
f4a2f247f7 | ||
|
|
9ee28e28b8 | ||
|
|
2457429470 | ||
|
|
b998381bb6 | ||
|
|
74725235b7 | ||
|
|
ef4d975c7c | ||
|
|
3fdd3f9b69 | ||
|
|
53aa9b0017 | ||
|
|
a52d87a555 | ||
|
|
39f062fe32 | ||
|
|
b2c13089ce | ||
|
|
4ddf307b9f | ||
|
|
3db36d8c67 | ||
|
|
bba7d3455c | ||
|
|
f086c0fe10 | ||
|
|
abc803953e | ||
|
|
8841aa9a10 | ||
|
|
7e417f09f7 | ||
|
|
0fde6ffd30 | ||
|
|
6f291139d8 | ||
|
|
1e3104d489 | ||
|
|
2e110422c8 | ||
|
|
7b7aa1932f | ||
|
|
773c96997c | ||
|
|
8f8199f489 | ||
|
|
a63d50c24f | ||
|
|
6af2b48d23 | ||
|
|
37197e1f8a | ||
|
|
8ede4eb3d6 | ||
|
|
c7fcc47b11 | ||
|
|
f12cf93e9f | ||
|
|
00e354394f | ||
|
|
bbb548fe21 | ||
|
|
c19e1fc56d | ||
|
|
065cf83b68 | ||
|
|
ca0da1dcc8 | ||
|
|
58a5cbc624 | ||
|
|
554fef9eae | ||
|
|
7dd76f866a | ||
|
|
84bcc33e87 | ||
|
|
6309aa1711 | ||
|
|
a8a3bc8a6b | ||
|
|
d2b4a859bf | ||
|
|
92d11d84a7 | ||
|
|
1cba6e6cdb | ||
|
|
20115c7080 | ||
|
|
4b3d986c5b | ||
|
|
21112d3d46 | ||
|
|
ec3f307e0b | ||
|
|
e17cfb4fdb | ||
|
|
010305fbeb | ||
|
|
5af473870e | ||
|
|
80f805617a | ||
|
|
3e1184562f | ||
|
|
39edbd22cc | ||
|
|
c90e615d5b | ||
|
|
5e6e7a3b5f | ||
|
|
3e23d6e526 | ||
|
|
08054e226e | ||
|
|
cfe668ab0d | ||
|
|
8f19276fac | ||
|
|
3a3c0d74d2 | ||
|
|
3ad21e72a9 | ||
|
|
790b9359bd | ||
|
|
e52e2bfdc9 | ||
|
|
a34c763656 | ||
|
|
adf5bbe80f | ||
|
|
c4c542c739 | ||
|
|
5f0236c015 | ||
|
|
023c0592e8 | ||
|
|
c6d2f05f15 | ||
|
|
08e9f6625a | ||
|
|
fcf5e972d6 | ||
|
|
20bf4dbc29 | ||
|
|
990c5df2b9 | ||
|
|
71af88725a | ||
|
|
fb5f5c3eba | ||
|
|
ead588314e | ||
|
|
fb447e441a | ||
|
|
3d61343905 | ||
|
|
7e4d9fb708 | ||
|
|
0217fd3eb3 | ||
|
|
96fa6f39b0 | ||
|
|
b6bb214532 | ||
|
|
ee160b76a8 | ||
|
|
2fd9d6610a | ||
|
|
272f99be17 | ||
|
|
b8246d7cd4 | ||
|
|
981d93f2fc | ||
|
|
1166fe92e9 | ||
|
|
b700d4e9bf | ||
|
|
0b41d09fda | ||
|
|
61c145a68f | ||
|
|
4103342f99 | ||
|
|
e48ed607ff | ||
|
|
d5a98db67c | ||
|
|
72412b9d03 | ||
|
|
b5b90c80e9 | ||
|
|
c1077f689d | ||
|
|
8b26a84564 | ||
|
|
2ca5186aef | ||
|
|
064e211154 | ||
|
|
2e8ab35f05 | ||
|
|
df429b976f | ||
|
|
d40995c338 | ||
|
|
bba7c257da | ||
|
|
a03e02bfeb | ||
|
|
a4a753e7b6 | ||
|
|
53acbd8606 | ||
|
|
f89c1ff391 | ||
|
|
471ee21749 | ||
|
|
24f7e043af | ||
|
|
34e82dab8b | ||
|
|
9fa1b8c541 | ||
|
|
1e61162f73 | ||
|
|
941266365e | ||
|
|
b1f1466e6b | ||
|
|
55d53dbc4f | ||
|
|
aeaddf722b | ||
|
|
b733fbce35 | ||
|
|
93e9e23730 | ||
|
|
388ccdbe75 | ||
|
|
255f464046 | ||
|
|
8be74649ce | ||
|
|
b5709f9527 | ||
|
|
7d9efcd1d9 | ||
|
|
d44f5dad18 | ||
|
|
06f37ab81d | ||
|
|
cb4a73bb9c | ||
|
|
efde14a348 | ||
|
|
0f86e3d8fb | ||
|
|
1407850e81 | ||
|
|
d9a87f6e7c | ||
|
|
5e0a49f225 | ||
|
|
3ebaf2bf1e | ||
|
|
9ef0ceed9d | ||
|
|
caa3760fd3 | ||
|
|
0b2a9e67c0 | ||
|
|
931081c5e7 | ||
|
|
f7237b5666 | ||
|
|
d851637ea2 | ||
|
|
2d39b4edfe | ||
|
|
5001c5bef8 | ||
|
|
6ac1db9fc8 | ||
|
|
52f33d79e0 | ||
|
|
1a9d91cdc0 | ||
|
|
2f444e6ec5 | ||
|
|
4665c488d5 | ||
|
|
42ab826d02 | ||
|
|
80a3f7424b | ||
|
|
1bd3e347b3 | ||
|
|
a332126e71 | ||
|
|
d97abe6af9 | ||
|
|
62d341bd8a | ||
|
|
a95d2e903e | ||
|
|
2b5e62aaf7 | ||
|
|
1c3e82913f | ||
|
|
ceaa20fabf | ||
|
|
1bbabb747e | ||
|
|
914c0a2926 | ||
|
|
4b249156f9 | ||
|
|
7b8c7a1a78 | ||
|
|
66ed26dcea | ||
|
|
356740479c | ||
|
|
c88dded01b | ||
|
|
7c974bc8ef | ||
|
|
efbc3e3e78 | ||
|
|
fac803e208 | ||
|
|
484a3d6b32 | ||
|
|
4e3b6d0299 | ||
|
|
f3161ada8d | ||
|
|
5c9b84476b | ||
|
|
24ea0cabda | ||
|
|
8b617fbd60 | ||
|
|
34149e6ce2 | ||
|
|
315a662c10 | ||
|
|
c373843e0f | ||
|
|
448ea796ef | ||
|
|
71b111ce8b | ||
|
|
ae686671ba | ||
|
|
6c45bb1ca1 | ||
|
|
a05ad6e686 | ||
|
|
485f7becd4 | ||
|
|
83f1e523ea | ||
|
|
a8a3c94115 | ||
|
|
4d21857adf | ||
|
|
0e7f41efe8 | ||
|
|
8d9bf60dba | ||
|
|
7c67924914 | ||
|
|
7626839037 | ||
|
|
23825ceed5 | ||
|
|
8d8016e01b | ||
|
|
6668e5a178 | ||
|
|
7d6bef9576 | ||
|
|
1aea7fbd03 | ||
|
|
ef96d296f8 | ||
|
|
d4da58e14a | ||
|
|
f646866192 | ||
|
|
e0849b12e7 | ||
|
|
1271343582 | ||
|
|
d61d14ea75 | ||
|
|
668d604aa9 | ||
|
|
6df2c3a0cf | ||
|
|
b0c33808e6 | ||
|
|
7bd3e0beab | ||
|
|
3a70cfb9e2 | ||
|
|
17c758f8f8 | ||
|
|
7cb03b939f | ||
|
|
481ef91cb1 | ||
|
|
1183aa8d11 | ||
|
|
4023261400 | ||
|
|
f1433504e9 | ||
|
|
989cbb35d3 | ||
|
|
9262b90096 | ||
|
|
ab3b7685cf | ||
|
|
40865ac97d | ||
|
|
ded67964b0 | ||
|
|
e991707ddf | ||
|
|
1b429475aa | ||
|
|
9613afb7ea | ||
|
|
26a12f2dbe | ||
|
|
23da02f932 | ||
|
|
59ff9007a7 | ||
|
|
0f817dbf00 | ||
|
|
7ab6322886 | ||
|
|
223c0c1263 | ||
|
|
d3a639a2ee | ||
|
|
2fb1cbe11f | ||
|
|
29c5b45764 | ||
|
|
ec83ad9d20 | ||
|
|
239f973e89 | ||
|
|
6c81fbbcf9 | ||
|
|
75e45f37b2 | ||
|
|
0bff3462b1 | ||
|
|
990db68a4d | ||
|
|
4343c744db | ||
|
|
5cb6f97178 | ||
|
|
a8efc9d348 | ||
|
|
e0b667fd2d | ||
|
|
a8ad2437fe | ||
|
|
83018e621e | ||
|
|
68b15ba483 | ||
|
|
7513036d47 | ||
|
|
c82c594926 | ||
|
|
bd18d4b4b2 | ||
|
|
2f5b99a1c7 | ||
|
|
1be6e0ec0f | ||
|
|
f43c0e5cf5 | ||
|
|
d6d8719c8d | ||
|
|
7478899388 | ||
|
|
6b193894bd | ||
|
|
69f5371ab8 | ||
|
|
526e3db3c1 | ||
|
|
3c9e8854eb | ||
|
|
7ee0aa47a9 | ||
|
|
5fbf0462fa | ||
|
|
eda5bc50e6 | ||
|
|
d6d26d466b | ||
|
|
6acd3ca091 | ||
|
|
c64d179f7f | ||
|
|
32bc8fe667 | ||
|
|
0a8c792d53 | ||
|
|
c32dc61ea1 | ||
|
|
3af2cb52cc | ||
|
|
ddb1d7ff82 | ||
|
|
fd0523085d | ||
|
|
1f9fbb0c86 | ||
|
|
5b95a1c177 | ||
|
|
2e144ebdf8 | ||
|
|
ac5f550ff3 | ||
|
|
9af123b709 | ||
|
|
75efd2b372 | ||
|
|
2cface9ac9 | ||
|
|
58491a97ac | ||
|
|
7a86d78c50 | ||
|
|
97a00ff83c | ||
|
|
4c6842faf4 | ||
|
|
fad8b9df0f | ||
|
|
cc52681635 | ||
|
|
939c8e95b0 | ||
|
|
312501e527 | ||
|
|
89638ff261 | ||
|
|
27d2e2309a | ||
|
|
7eaa553ebe | ||
|
|
8adddd0bdb | ||
|
|
d8f084f886 | ||
|
|
5c5a23dfbb | ||
|
|
282d4614c0 | ||
|
|
8ed78ea38f | ||
|
|
2e7b7c5c00 | ||
|
|
68cad97819 | ||
|
|
02f7b54661 | ||
|
|
e887232132 | ||
|
|
7bcf4e3750 | ||
|
|
ed0676a5f5 | ||
|
|
f731c654b0 | ||
|
|
b026bffe4a | ||
|
|
a1aaec0b7d | ||
|
|
6f59097ac0 | ||
|
|
263df0c895 | ||
|
|
1ca5713494 | ||
|
|
b706c4e871 | ||
|
|
e8574c10bb | ||
|
|
7cb87ea9c5 | ||
|
|
07e5132943 | ||
|
|
2b1e3cfc93 | ||
|
|
c17e1f0e1f | ||
|
|
e88d1d0d00 | ||
|
|
5fdff97cf8 | ||
|
|
e5328d7975 | ||
|
|
71b45b96fb | ||
|
|
dee28f3aa8 | ||
|
|
3a118bb939 | ||
|
|
971c92b27a | ||
|
|
4c70065843 | ||
|
|
eab69337ff | ||
|
|
ab772eb65e | ||
|
|
7a6720a7ab | ||
|
|
0f3dc18f7a | ||
|
|
84df86f4b6 | ||
|
|
a0289a0cb4 | ||
|
|
3c034ca8af | ||
|
|
0c8c3003aa | ||
|
|
919dabfefa | ||
|
|
fd06193e93 | ||
|
|
b02fa43628 | ||
|
|
7fdf38d17b | ||
|
|
63e036a9bb | ||
|
|
fc98442f8b | ||
|
|
3bbb780d2b | ||
|
|
42ceb0c404 | ||
|
|
046bd7a0e9 | ||
|
|
0af0f745bf | ||
|
|
b24999d78c | ||
|
|
d1278a4477 | ||
|
|
609ccaa635 | ||
|
|
4bae04ad31 | ||
|
|
8bca729edd | ||
|
|
2b2f04f724 | ||
|
|
231af1d3d8 | ||
|
|
d1b44c0ac0 | ||
|
|
f06c3f7376 | ||
|
|
b8e10b040d | ||
|
|
1a4396dbe3 | ||
|
|
856eb0da05 | ||
|
|
ceaeb8e0b9 | ||
|
|
b29ccd9746 | ||
|
|
f82364939f | ||
|
|
286583bba2 | ||
|
|
bc8e5aa458 | ||
|
|
b393fb042f | ||
|
|
0258c5aac2 | ||
|
|
a03a11c4cc | ||
|
|
3896bbab34 | ||
|
|
5459f31000 | ||
|
|
143c3a1444 | ||
|
|
bfbcf11fa3 | ||
|
|
25823b1f39 | ||
|
|
cb02955ac6 | ||
|
|
b440661e0d | ||
|
|
38ba456a22 | ||
|
|
bca941e045 | ||
|
|
dbaadb4595 | ||
|
|
231ca44e1c | ||
|
|
ca00c87a6e | ||
|
|
2a28111583 | ||
|
|
4def80c914 | ||
|
|
916ece1edb | ||
|
|
3c1f57fac0 | ||
|
|
e166c36731 | ||
|
|
a636f4a786 | ||
|
|
bb127506c7 | ||
|
|
92c704d227 | ||
|
|
e764ebbcb2 | ||
|
|
68fb34a8ad | ||
|
|
02683ba829 | ||
|
|
754a1b3a87 | ||
|
|
4b58da7be7 | ||
|
|
1ac9c850e4 | ||
|
|
3cc4dc1414 | ||
|
|
62ae43e863 | ||
|
|
8149a93f2f | ||
|
|
5a60c3a595 | ||
|
|
2ef2f0d1a5 | ||
|
|
52fef33662 | ||
|
|
a5c6f86b35 | ||
|
|
b80008d695 | ||
|
|
fa06aefbf1 | ||
|
|
ab9ede1e26 | ||
|
|
479b1ab20a | ||
|
|
8ed3f84cb7 | ||
|
|
c128e43fe5 | ||
|
|
f9ddf76686 | ||
|
|
b6cb839fe7 | ||
|
|
2b6a48f391 | ||
|
|
d1da863d3c | ||
|
|
99b7a8005a | ||
|
|
3f3367258f | ||
|
|
15ab955a7d | ||
|
|
5cf8b5aa12 | ||
|
|
eba6d03b53 | ||
|
|
de85239046 | ||
|
|
9176151971 | ||
|
|
cbd8acbd01 | ||
|
|
19c9bfa819 | ||
|
|
80c369a6ae | ||
|
|
d0190c87a2 | ||
|
|
62a2cba18b | ||
|
|
59b3ff1b94 | ||
|
|
35423e1bb6 | ||
|
|
e125690000 | ||
|
|
eba5ecfd91 | ||
|
|
3e43a75acd | ||
|
|
04bf9c07ac | ||
|
|
ac58f99560 | ||
|
|
df328ff139 | ||
|
|
60ba7786a0 | ||
|
|
1773ad5a88 | ||
|
|
26faa2bc69 | ||
|
|
75c7d40f4e | ||
|
|
aa4a3fe9af | ||
|
|
a32f907d6c | ||
|
|
d30e613b17 | ||
|
|
0477f390c2 | ||
|
|
15e5e2834a | ||
|
|
b1399d1c13 | ||
|
|
45446b4125 | ||
|
|
094d7e71e2 | ||
|
|
75423eaaab | ||
|
|
5e42fa5346 | ||
|
|
bc6d5060cb | ||
|
|
e578327c5b | ||
|
|
ce839ed6d9 | ||
|
|
197484dc1b | ||
|
|
60e8ae7b9a | ||
|
|
64ae32d005 | ||
|
|
87b8e18f1c | ||
|
|
47a9a6cc4e | ||
|
|
1a313a9f78 | ||
|
|
3554350ac1 | ||
|
|
e2e912c81f | ||
|
|
fa71161996 | ||
|
|
8de4d461d4 | ||
|
|
f1e3f6a592 | ||
|
|
234ef679c8 | ||
|
|
072000254f | ||
|
|
34321af07a | ||
|
|
7da559a0c9 | ||
|
|
0564a7e1f1 | ||
|
|
347b16ee8b | ||
|
|
edb834db38 | ||
|
|
97d0cf56a7 | ||
|
|
ad83d362da | ||
|
|
f15f6df2ae | ||
|
|
567a86c8a4 | ||
|
|
16c82ed69c | ||
|
|
a543ee54dc | ||
|
|
128831109a | ||
|
|
9641940bd9 | ||
|
|
ad449ba239 | ||
|
|
31e2fcdc21 | ||
|
|
e28b385038 | ||
|
|
8d9ecb2054 | ||
|
|
c57c190344 | ||
|
|
7dd319eb25 | ||
|
|
b4a059c742 | ||
|
|
43d140175c | ||
|
|
1a70a02b44 | ||
|
|
a476298c07 | ||
|
|
a0a7f28304 | ||
|
|
17109e7406 | ||
|
|
c50343cd04 | ||
|
|
8feac270f4 | ||
|
|
c7061066e9 | ||
|
|
217870244b | ||
|
|
6fd2282818 | ||
|
|
b360c3292f | ||
|
|
2a9e1bb08c | ||
|
|
aa4e6c051e | ||
|
|
f6b947430a | ||
|
|
4521317779 | ||
|
|
25ba353488 | ||
|
|
2074b34ec3 | ||
|
|
f63731c9b6 | ||
|
|
fc057f2b29 | ||
|
|
af4dfbf965 | ||
|
|
63c5098558 | ||
|
|
dcad12f836 | ||
|
|
08e84b056e | ||
|
|
f489deb808 | ||
|
|
511a7030b8 | ||
|
|
06a1862d17 | ||
|
|
b730b8c6f6 | ||
|
|
21a45844df | ||
|
|
2480b43778 | ||
|
|
ad6dcfc030 | ||
|
|
c81324843c | ||
|
|
a55da5f62e | ||
|
|
9b26bdac5c | ||
|
|
4bda6fa0cc | ||
|
|
1eaa22fe51 | ||
|
|
8e212bbb55 | ||
|
|
fdf0b69e5d | ||
|
|
4b5ab8eb35 | ||
|
|
349d6cfc22 | ||
|
|
33b8267de7 | ||
|
|
713bd59435 | ||
|
|
8c34394d86 | ||
|
|
6e1de6fd26 | ||
|
|
8d88efd27b | ||
|
|
6aeaeea5d1 | ||
|
|
3153296161 | ||
|
|
692f7c4270 | ||
|
|
cc8d73e119 | ||
|
|
27c2c71879 | ||
|
|
464beccd95 | ||
|
|
7519fc2f10 | ||
|
|
01816b0389 | ||
|
|
cc8e432336 | ||
|
|
d1ed73f78b | ||
|
|
7a76bf2ae5 | ||
|
|
6efb970794 | ||
|
|
a0a8010305 | ||
|
|
9ed1dbac72 | ||
|
|
c7280cf1be | ||
|
|
0ed87db3b3 | ||
|
|
20bc814211 | ||
|
|
0c92c57b79 | ||
|
|
e61404146b | ||
|
|
07dbcbf5c3 | ||
|
|
8421dbfa4c | ||
|
|
d2af7ba407 | ||
|
|
d0718fcda7 | ||
|
|
2372acbb2a | ||
|
|
9374798f92 | ||
|
|
e96015bcbc | ||
|
|
2bd05d80b0 | ||
|
|
3a7b503890 | ||
|
|
d20707f4df | ||
|
|
b01d308d65 | ||
|
|
e0f5c730a6 | ||
|
|
b9086de336 | ||
|
|
405d3d24a0 | ||
|
|
236de87574 | ||
|
|
b12d14d6f5 | ||
|
|
1d42440e26 | ||
|
|
ec065a6595 | ||
|
|
0d83511509 | ||
|
|
9fe08f6c45 | ||
|
|
557f2e1a83 | ||
|
|
efced4670b | ||
|
|
c89596809a | ||
|
|
7ccc93f17f | ||
|
|
f50e511da0 | ||
|
|
3d497ccf4d | ||
|
|
5c21e219fe | ||
|
|
58796ac31d | ||
|
|
66317f5593 | ||
|
|
2f276cb155 | ||
|
|
b1b915b405 | ||
|
|
052f9fa21f | ||
|
|
893b237d6a | ||
|
|
e367f51458 | ||
|
|
fa44b74f09 | ||
|
|
bb00616275 | ||
|
|
581d11702c | ||
|
|
16eb6097ce | ||
|
|
2051a6b2f3 | ||
|
|
02320548e3 | ||
|
|
44a0b59989 | ||
|
|
3288c35bcd | ||
|
|
bbe0b86690 | ||
|
|
afbabfd2d3 | ||
|
|
d404a09f8e | ||
|
|
30e9d4b3cd | ||
|
|
4238978373 | ||
|
|
7f3c7ea69c | ||
|
|
a4202c96d2 | ||
|
|
593f817b6d | ||
|
|
2230d1467c | ||
|
|
af3a52c75e | ||
|
|
f9b1cbc9ba | ||
|
|
0df37d6694 | ||
|
|
9aa511b398 | ||
|
|
5257b1e1cc | ||
|
|
4e9e123666 | ||
|
|
e7fec7caab | ||
|
|
9a4d829f73 | ||
|
|
2423ad6e87 | ||
|
|
dc40856bc2 | ||
|
|
5cbea6999e | ||
|
|
0d18a89177 | ||
|
|
b935f60fcb | ||
|
|
45df7713ce | ||
|
|
b26355097d | ||
|
|
feee845d31 | ||
|
|
660be862d6 | ||
|
|
b5047f213e | ||
|
|
1fafb6a503 | ||
|
|
7c6ec49312 | ||
|
|
6eb7b45990 | ||
|
|
7c7f704173 | ||
|
|
f331398316 | ||
|
|
6c88029fe3 | ||
|
|
aae7236106 | ||
|
|
963a42becf | ||
|
|
f0044afeab | ||
|
|
f9c3ff22e3 | ||
|
|
347b82ef7f | ||
|
|
a42762de53 | ||
|
|
27e5d135bc | ||
|
|
e073e58241 | ||
|
|
a57ba2550c | ||
|
|
a9cbbf66bd | ||
|
|
91caacd36d | ||
|
|
e181296a91 | ||
|
|
6a626c9865 | ||
|
|
7095c475c0 | ||
|
|
c18df3a157 | ||
|
|
aee63de507 | ||
|
|
2f9438d48d | ||
|
|
700974a5ae | ||
|
|
15b8a8027c | ||
|
|
6c0b22f129 | ||
|
|
4ee3694292 | ||
|
|
989099c791 | ||
|
|
a89440cba2 | ||
|
|
39c9b4d50f | ||
|
|
0a6d9a7764 | ||
|
|
372eaabb50 | ||
|
|
409b63b374 | ||
|
|
ca5c0d6ee4 | ||
|
|
faca3615fc | ||
|
|
7e31401b86 | ||
|
|
0fad7b313b | ||
|
|
930d7d0fed | ||
|
|
fb59499a0d | ||
|
|
6e256e465f | ||
|
|
e358387ba1 | ||
|
|
cd9ba9befe | ||
|
|
d6ab376f2d | ||
|
|
cc6551b11b | ||
|
|
5e417dd890 | ||
|
|
95694ac355 | ||
|
|
215fba83cf | ||
|
|
a8a2bda9af | ||
|
|
79ace7f18c | ||
|
|
c448e75953 | ||
|
|
683af698c6 | ||
|
|
fe19ddb259 | ||
|
|
4c3c7e7cba | ||
|
|
f5e08e47a8 | ||
|
|
782df3bcb5 | ||
|
|
1b2f7e21f6 | ||
|
|
4943e1c7ed | ||
|
|
0f066d0817 | ||
|
|
839cadea35 | ||
|
|
8cef7bcb75 | ||
|
|
62ac05ae81 | ||
|
|
4609d6e343 | ||
|
|
e70e8f8437 | ||
|
|
1b57c5636d | ||
|
|
9065f8eea2 | ||
|
|
8c3e87339a | ||
|
|
9ee2452430 | ||
|
|
266dabdd7a | ||
|
|
21898482de | ||
|
|
c281654e97 | ||
|
|
c0ee8e2d61 | ||
|
|
66e4021d7b | ||
|
|
b00781ec32 | ||
|
|
f08a41d7a5 | ||
|
|
6eb0f153fa | ||
|
|
a6bc40515b | ||
|
|
67ef25f538 | ||
|
|
c37adfc6a2 | ||
|
|
d6764e08fd | ||
|
|
ed80511cb6 | ||
|
|
08da97e36d | ||
|
|
a14ab59e78 | ||
|
|
f7c60b3745 | ||
|
|
f8a77d634c | ||
|
|
fdd1bb9810 | ||
|
|
8a1aefeda7 | ||
|
|
64813a4623 | ||
|
|
e86f0c54f3 | ||
|
|
d2f06f5310 | ||
|
|
7a22b3df71 | ||
|
|
97671c570c | ||
|
|
accad0c77a | ||
|
|
a19deb2076 | ||
|
|
bba7dba17c | ||
|
|
985274980a | ||
|
|
4470d01229 | ||
|
|
35a9c07636 | ||
|
|
3fa5877840 | ||
|
|
56bbfe60bb | ||
|
|
d2f604960e | ||
|
|
76a4236b70 | ||
|
|
fece7411c9 | ||
|
|
0e1e379c7d | ||
|
|
b39ac214a5 | ||
|
|
9e9e0667cd | ||
|
|
82f99faec8 | ||
|
|
d07a767fa5 | ||
|
|
ec19038e5c | ||
|
|
eaa09055fc | ||
|
|
0d956c2f39 | ||
|
|
3a4e2e9623 | ||
|
|
d33befe1ad | ||
|
|
b6fd067d48 | ||
|
|
d28f20519b | ||
|
|
239f390e7c | ||
|
|
857d16582a | ||
|
|
aae47aa2e1 | ||
|
|
f115111c49 | ||
|
|
83058500d6 | ||
|
|
2cb1e70170 | ||
|
|
73513713eb | ||
|
|
7bf5c03637 | ||
|
|
d39d1239f6 | ||
|
|
a0271a2982 | ||
|
|
2484927c22 | ||
|
|
bff662b85d | ||
|
|
f293f6c169 | ||
|
|
e9456af15c | ||
|
|
157fad6a84 | ||
|
|
6c137a5174 | ||
|
|
6550bdefd5 | ||
|
|
614e9ff661 | ||
|
|
5da9e430b0 | ||
|
|
fbd32bf426 | ||
|
|
f28d96d303 | ||
|
|
442322f708 | ||
|
|
a463e8889c | ||
|
|
224e54cc72 | ||
|
|
a23cbd1a38 | ||
|
|
548dd9e34f | ||
|
|
c27f4a62b6 | ||
|
|
e18945b378 | ||
|
|
08869ac350 | ||
|
|
5d5c3ae3e3 | ||
|
|
0fa6dce42c | ||
|
|
aad95595eb | ||
|
|
24fede3768 | ||
|
|
a8b6118e2d | ||
|
|
719be8469f | ||
|
|
1496052bba | ||
|
|
545df83177 | ||
|
|
80d506be37 | ||
|
|
ae0ae413e6 | ||
|
|
43339b8d36 | ||
|
|
6a9f9e3f81 | ||
|
|
010402ebd5 | ||
|
|
c07a0065a1 | ||
|
|
704af55636 | ||
|
|
475e1c6fe0 | ||
|
|
21c397b5e0 | ||
|
|
29a002e451 | ||
|
|
035affadb9 | ||
|
|
79e3142f2d | ||
|
|
bf880983a7 | ||
|
|
115746353e | ||
|
|
2fd4521e1d | ||
|
|
3a8a9e296e | ||
|
|
86ebb78823 | ||
|
|
bda0e37767 | ||
|
|
900cc03e4f | ||
|
|
013357ae6b | ||
|
|
a58a67d404 | ||
|
|
2ded31dd55 | ||
|
|
dad455f9a0 | ||
|
|
8b87f04268 | ||
|
|
627c8d1663 | ||
|
|
287a64be11 | ||
|
|
8f24eeaf4a | ||
|
|
39efc2ef9b | ||
|
|
52a24d5223 | ||
|
|
ca8d32b837 | ||
|
|
f508f45e16 | ||
|
|
d7a22f609a | ||
|
|
2503cbaeac | ||
|
|
6bc305dbe6 | ||
|
|
26cbc0754e | ||
|
|
ed5d7335a0 | ||
|
|
76e3b5abe7 | ||
|
|
45445c03e5 | ||
|
|
0d00f2f80a | ||
|
|
27f437020e | ||
|
|
2345c45ebf | ||
|
|
42408978c4 | ||
|
|
a716d13c69 | ||
|
|
77393b433e | ||
|
|
a7c06b46a7 | ||
|
|
1de55bb16f | ||
|
|
20520bbce4 | ||
|
|
42e11d239d | ||
|
|
957ad1e0db | ||
|
|
3123685f88 | ||
|
|
31e8782b1a | ||
|
|
cafad6a003 | ||
|
|
b785e0d505 | ||
|
|
226153a359 | ||
|
|
b70aa54aa3 | ||
|
|
fc96d89a44 | ||
|
|
dd71b931fc | ||
|
|
d6abdf16e0 | ||
|
|
66e18e17c5 | ||
|
|
7b2da8312d | ||
|
|
353a30efe4 | ||
|
|
c71c87e946 | ||
|
|
9ca6151313 | ||
|
|
b322efc286 | ||
|
|
b775c1bf74 | ||
|
|
a6b2671b1c | ||
|
|
abc5d0ea2f | ||
|
|
3c25d91b98 | ||
|
|
3d8aace55a | ||
|
|
bd6202f8e1 | ||
|
|
c1875e1389 | ||
|
|
3a63c1d1ab | ||
|
|
609faf245a | ||
|
|
bfd28c4b44 | ||
|
|
70a8488f12 | ||
|
|
cda6e4722c | ||
|
|
c4ecf85618 | ||
|
|
6ed1f9d369 | ||
|
|
aea765ec54 | ||
|
|
165c3bb852 | ||
|
|
2e9683491f | ||
|
|
eea8b99622 | ||
|
|
0db6a0d27b | ||
|
|
723e575b4b | ||
|
|
b182d409b6 | ||
|
|
043e37030e | ||
|
|
8d730040b8 | ||
|
|
794a300c74 | ||
|
|
91fd490ab7 | ||
|
|
0e1de03db8 | ||
|
|
7906a94ca0 | ||
|
|
6ce019a86a | ||
|
|
d8939779e8 | ||
|
|
313d87a58b | ||
|
|
057e61bfe1 | ||
|
|
c94c882c3a | ||
|
|
673f632563 | ||
|
|
1a56b2bf68 | ||
|
|
2e9964e0e8 | ||
|
|
b80eb32991 | ||
|
|
ad281c7607 | ||
|
|
3c1a918b2d | ||
|
|
53e6677558 | ||
|
|
b8deb34544 | ||
|
|
d9cffa87ef | ||
|
|
ae418f9d45 | ||
|
|
d10b4c6bde | ||
|
|
f1e516662c | ||
|
|
1bf8165641 | ||
|
|
807af3b613 | ||
|
|
e36ec0a0a3 | ||
|
|
3172b69271 | ||
|
|
81ee13bebc | ||
|
|
a6a8138c7f | ||
|
|
16142f61b8 | ||
|
|
7193228dd2 | ||
|
|
29b8f6916b | ||
|
|
a6519d57c6 | ||
|
|
2b2ee5bbfc | ||
|
|
62a6b7b7e0 | ||
|
|
5e0a1f9de1 | ||
|
|
571b1bc9cf | ||
|
|
a3e76225e6 | ||
|
|
4388ceb529 | ||
|
|
5d4fa0d732 | ||
|
|
ed6825de97 | ||
|
|
10f623b6b3 | ||
|
|
467e5c0e4a | ||
|
|
09d2e9593e | ||
|
|
cd20c0567b | ||
|
|
db15f5e2a6 | ||
|
|
18d5c7e3f4 | ||
|
|
d42ae7c9f7 | ||
|
|
2b741b62e8 | ||
|
|
4e1c63d36a | ||
|
|
95797171d7 | ||
|
|
395ef9695e | ||
|
|
ad1c7088c7 | ||
|
|
eda4569b64 | ||
|
|
fb5f1e738b | ||
|
|
e3cca6c691 | ||
|
|
ce2b8dfa11 | ||
|
|
b5faf6ffc5 | ||
|
|
4ee157f27f | ||
|
|
ad8c04b79a | ||
|
|
c0e6273954 | ||
|
|
aebade21a6 | ||
|
|
bda24c0101 | ||
|
|
3d0b461550 | ||
|
|
5900e6c49f | ||
|
|
f29fe3ced5 | ||
|
|
bf17cc8f03 | ||
|
|
a8d10c22d4 | ||
|
|
5492ebf43f | ||
|
|
0c48366d56 | ||
|
|
d66a027270 | ||
|
|
f1834af506 | ||
|
|
f26fa71e49 | ||
|
|
4faee68174 | ||
|
|
2326e66382 | ||
|
|
bf71365f51 | ||
|
|
f45e8d491c | ||
|
|
5a476cab32 | ||
|
|
b5b0a19df5 | ||
|
|
85d26dee08 | ||
|
|
476da07d3d |
12
.github/ISSUE_TEMPLATE/bug_report.md
vendored
12
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -24,9 +24,13 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add a video and/or screenshots to help explain your problem.
|
||||
|
||||
**Device (please complete the following information):**
|
||||
- Phone Brand/Model [e.g. Pixel 7 Pro]:
|
||||
- Android Version [e.g. 33]:
|
||||
- App Version [e.g. v0.20.3]:
|
||||
- Phone Brand/Model [e.g. Pixel 7 Pro]:
|
||||
- Android Version [e.g. 34]:
|
||||
- App Version [e.g. v0.94.3]:
|
||||
- App Flavour [e.g. Google Play or FDroid]:
|
||||
- Amber Version (if using it to sign):
|
||||
|
||||
**Bounty (in Bitcoin sats) offered for a solution**
|
||||
Incentivize developers to work on your issue. Describe clear milestones to claim payment.
|
||||
The size of the bounty is proportional to how much this matters to you. If no bounty is offered,
|
||||
not even a small one, this bug will not be worked on because it doesn't matter to you. We prioritize
|
||||
bug fixing to issues that have bounties, even small ones.
|
||||
|
||||
10
.github/ISSUE_TEMPLATE/feature_request.md
vendored
10
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -7,14 +7,10 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Bounty (in Bitcoin sats) offered for the implementation**
|
||||
Incentivize developers to work on your feature. Describe clear milestones to claim payment.
|
||||
|
||||
**Additional context**
|
||||
Add any other context, video, or screenshots about the feature request here.
|
||||
The size of the bounty is proportional to how much this matters to you. If no bounty is offered,
|
||||
not even a small one, this feature will not be coded because it doesn't actually matter to you.
|
||||
We prioritize feature development by its bounty size compared to how much work is required to get it done.
|
||||
54
.github/workflows/build.yml
vendored
54
.github/workflows/build.yml
vendored
@@ -15,14 +15,14 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 17
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
|
||||
@@ -40,17 +40,47 @@ jobs:
|
||||
if: ${{ always() }} # IMPORTANT: run Android Test Report regardless
|
||||
|
||||
- name: Build APK (gradle)
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v2
|
||||
- name: Upload Play APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Debug APK
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
name: Play Debug APK
|
||||
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
|
||||
|
||||
- name: Upload FDroid APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FDroid Debug APK
|
||||
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
|
||||
|
||||
- name: Build APK (gradle)
|
||||
run: ./gradlew assembleBenchmark
|
||||
|
||||
- name: Upload Play APK Benchmark
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Play Benchmark APK
|
||||
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-benchmark.apk
|
||||
|
||||
- name: Upload FDroid APK Benchmark
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: FDroid Benchmark APK
|
||||
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-benchmark.apk
|
||||
|
||||
- name: Upload Compose Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Compose Reports
|
||||
path: amethyst/build/compose_compiler
|
||||
|
||||
- name: Tests
|
||||
run: ./gradlew test --no-daemon
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Build Reports
|
||||
path: app/build/reports
|
||||
name: Test Reports
|
||||
path: amethyst/build/reports
|
||||
|
||||
|
||||
48
.github/workflows/create-release.yml
vendored
48
.github/workflows/create-release.yml
vendored
@@ -12,14 +12,14 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 17
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
|
||||
@@ -32,26 +32,26 @@ jobs:
|
||||
- name: Sign AAB (Google Play)
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/bundle/playRelease
|
||||
releaseDirectory: amethyst/build/outputs/bundle/playRelease
|
||||
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Sign AAB (F-Droid)
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/bundle/fdroidRelease
|
||||
releaseDirectory: amethyst/build/outputs/bundle/fdroidRelease
|
||||
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Build APK
|
||||
run: ./gradlew assembleRelease --stacktrace
|
||||
@@ -59,26 +59,26 @@ jobs:
|
||||
- name: Sign APK (Google Play)
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/play/release
|
||||
releaseDirectory: amethyst/build/outputs/apk/play/release
|
||||
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Sign APK (F-Droid)
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/fdroid/release
|
||||
releaseDirectory: amethyst/build/outputs/apk/fdroid/release
|
||||
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
|
||||
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
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-release-unsigned-signed.apk
|
||||
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
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_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/play/release/app-play-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
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_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
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_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
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_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk
|
||||
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
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_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab
|
||||
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -225,6 +225,6 @@ jobs:
|
||||
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_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab
|
||||
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
|
||||
asset_content_type: application/zip
|
||||
|
||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v1
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -12,6 +12,11 @@
|
||||
/.idea/ktlint-plugin.xml
|
||||
/.idea/ktfmt.xml
|
||||
/.idea/studiobot.xml
|
||||
/.idea/other.xml
|
||||
/.idea/runConfigurations.xml
|
||||
/.idea/ChatHistory_schema_v2.xml
|
||||
/.idea/artifacts/*
|
||||
/.idea/kotlinNotebook.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
@@ -41,6 +46,7 @@ out/
|
||||
.gradle
|
||||
.gradle/
|
||||
build/
|
||||
.kotlin/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
1
.idea/icon.svg
generated
Normal file
1
.idea/icon.svg
generated
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 170.16 186.2"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:url(#linear-gradient-2);}</style><linearGradient id="linear-gradient" x1="80.19" y1="57.17" x2="91.66" y2="57.17" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#652d80"/><stop offset="1" stop-color="#2598cf"/></linearGradient><linearGradient id="linear-gradient-2" x1="335.87" y1="297.64" x2="506.02" y2="297.64" xlink:href="#linear-gradient"/></defs><title>Amethyst_svg_monogram</title><ellipse class="cls-1" cx="85.93" cy="57.17" rx="5.74" ry="5.64"/><path class="cls-2" d="M506,347.08c-.2-.61-46.85-111.69-59.68-142.5-15.62,0-48.61,0-71.88,0-6.83,16.89-24.46,59.94-38.6,94.62,7.75,18.49,14.71,35.1,20.08,47.91l29.27,0a2,2,0,0,0,1.87-2.35c-.25-5.91-16.34-27.39-11.54-50.63,1.28-5,2.26-10.08,3.66-15.06a175.77,175.77,0,0,1,13.44-34.8c.89-1.69,1.3-2.45,3.35-1.17,4.37,2.72,9.25,2.56,14.11,2.21,4.19-.91,6.23-2.92,13.91-.88,1.52,0,3,0,4.55.14,3.69.29,7.15,1.17,9.37,4.51,2.42,3.65,2.81,7.78,2.42,12-.59,6.31-.17,12.19,5.17,16.64a57.52,57.52,0,0,0,6,4c2.65,1.7,5.85,2.44,8.12,4.8,1.34,1.39,2.13,2.87,1.55,4.85s-2.13,2.91-4.17,3.13c-5.16.56-10.2-.49-15.27-1.1-.66-.08-1.31-.13-2-.17a11.47,11.47,0,0,1-3.81.13l-1.19,0a26.7,26.7,0,0,0-5.9,1.41c-4.78,1.74-9.13,3.66-14.77,3.56a4.32,4.32,0,0,0-2.05.89c-4.42,3.93-7.08,8.89-4.87,16.14,6.06,16.93,21.61,57.77,28.29,75.4-.19-11.94-.24-33.32-.28-43.7C461.83,347.1,502.74,347.08,506,347.08Z" transform="translate(-335.87 -204.54)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
36
.idea/inspectionProfiles/Project_Default.xml
generated
36
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,6 +1,34 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
@@ -9,6 +37,10 @@
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" 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" />
|
||||
@@ -33,6 +65,10 @@
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
|
||||
6
.idea/kotlinNotebook.xml
generated
Normal file
6
.idea/kotlinNotebook.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinNotebookOptionsProvider">
|
||||
<option name="shouldAddProjectLibrariesToClasspath" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/kotlinc.xml
generated
8
.idea/kotlinc.xml
generated
@@ -1,6 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Kotlin2JsCompilerArguments">
|
||||
<option name="moduleKind" value="plain" />
|
||||
</component>
|
||||
<component name="Kotlin2JvmCompilerArguments">
|
||||
<option name="jvmTarget" value="1.8" />
|
||||
</component>
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.24" />
|
||||
<option name="version" value="2.1.0" />
|
||||
</component>
|
||||
</project>
|
||||
20
PRIVACY.md
20
PRIVACY.md
@@ -4,33 +4,39 @@
|
||||
|
||||
Effective as of Jun 12, 2023
|
||||
|
||||
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.
|
||||
The Amethyst app for Android does not collect or process any personal information from its users.
|
||||
|
||||
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.
|
||||
The app is used to browse third-party Nostr servers (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.
|
||||
|
||||
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.
|
||||
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 is 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.
|
||||
|
||||
### Privacy with Relays services
|
||||
Amethyst offers several options for uploading pictures and videos to post 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.
|
||||
|
||||
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.
|
||||
### Privacy with Relay 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.
|
||||
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 into 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.
|
||||
The information you share is publicly visible to anyone reading from relays that have access to 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.
|
||||
|
||||
## Child safety standards
|
||||
|
||||
Amethyst does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. The application is 17+. We rely on Google Play's age verification to make sure the user downloading the app is an adult. Since we do not control which relays the user connects to, there is no content moderation beyond the standard block post, block account, and report post and/or account that will hide the content from the user.
|
||||
|
||||
## Terms of Use
|
||||
|
||||
### For versions downloaded from Google's Play Store
|
||||
|
||||
98
README.md
98
README.md
@@ -1,4 +1,4 @@
|
||||
<div align="center">
|
||||
<div align="center">
|
||||
|
||||
<a href="https://amethyst.social">
|
||||
<img src="./docs/design/3rd%20Logo%20-%20Zitron/amethyst.svg" alt="Amethyst Logo" title="Amethyst logo" width="80"/>
|
||||
@@ -17,17 +17,18 @@ Join the social network you control.
|
||||
[](https://jitpack.io/#vitorpamplona/amethyst)
|
||||
[](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
|
||||
[](/LICENSE)
|
||||
[](https://deepwiki.com/vitorpamplona/amethyst)
|
||||
|
||||
## Download and Install
|
||||
|
||||
[<img src="./docs/design/zapstore.svg"
|
||||
alt="Get it on Zap Store"
|
||||
height="70">](https://github.com/zapstore/zapstore/releases)
|
||||
[<img src="./docs/design/obtainium.png"
|
||||
alt="Get it on Obtaininum"
|
||||
height="70">](https://github.com/ImranR98/Obtainium)
|
||||
[<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub"
|
||||
height="70">](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="70">](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="70">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
@@ -61,7 +62,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Bech Encoding support (NIP-19)
|
||||
- [x] Command Results (NIP-20)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] Long-form Content (NIP-23) (view only)
|
||||
- [x] User Profile Fields / Relay list (NIP-24)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [ ] Delegated Event Signing (NIP-26, Will not implement)
|
||||
@@ -105,7 +106,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Video Events (NIP-71)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [ ] Zap Goals (NIP-75)
|
||||
- [ ] Arbitrary Custom App Data (NIP-78)
|
||||
- [x] Arbitrary Custom App Data (NIP-78)
|
||||
- [x] Highlights (NIP-84)
|
||||
- [x] Notify Request (NIP-88/Draft)
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
@@ -134,7 +135,8 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [ ] Signed Filters (NIP-xx/Draft)
|
||||
- [ ] Key Migration (NIP-xx/Draft)
|
||||
- [ ] Time-based Sync (NIP-xx/Draft)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [x] Image Capture in the app
|
||||
- [ ] Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] Workspaces
|
||||
- [ ] Infinity Scroll
|
||||
@@ -173,7 +175,7 @@ Lastly, the user's account information (private key/pub key) is stored in the An
|
||||
## Setup
|
||||
|
||||
Make sure to have the following pre-requisites installed:
|
||||
1. Java 17+
|
||||
1. Java 21+
|
||||
2. Android Studio
|
||||
3. Android 8.0+ Phone or Emulation setup
|
||||
|
||||
@@ -226,7 +228,7 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
|
||||
- `KEY_PASSWORD` <- `<your password>`
|
||||
- `KEY_STORE_PASSWORD` <- `<your key store password>`
|
||||
- `SIGNING_KEY` <- the data from `<my-release-key.keystore>`
|
||||
3. Change the `versionCode` and `versionName` on `app/build.gradle`
|
||||
3. Change the `versionCode` and `versionName` on `amethyst/build.gradle`
|
||||
4. Commit and push.
|
||||
5. Tag the commit with `v{x.x.x}`
|
||||
6. Let the [Create Release GitHub Action](https://github.com/vitorpamplona/amethyst/actions/workflows/create-release.yml) build a new `aab` file.
|
||||
@@ -249,10 +251,84 @@ dependencyResolutionManagement {
|
||||
}
|
||||
```
|
||||
|
||||
Add the dependency
|
||||
Add the dependency by using one of the versions [here](https://jitpack.io/#vitorpamplona/amethyst/)
|
||||
|
||||
```gradle
|
||||
implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1')
|
||||
implementation('com.github.vitorpamplona.amethyst:quartz:<tag, commit, -SNAPSHOT>')
|
||||
```
|
||||
|
||||
Manage logged in users with the `KeyPair` class
|
||||
|
||||
```kt
|
||||
val keys = KeyPair() // creates a random key
|
||||
val keys = KeyPair("hex...".hexToByteArray())
|
||||
val keys = KeyPair("nsec1...".bechToBytes())
|
||||
val keys = KeyPair(Nip06().privateKeyFromMnemonic("<mnemonic>"))
|
||||
val readOnly = KeyPair(pubKey = "hex...".hexToByteArray())
|
||||
val readOnly = KeyPair(pubKey = "npub1...".bechToBytes())
|
||||
```
|
||||
|
||||
Create signers that can be internal, when you have the private key or when it is a read-only user
|
||||
or external, when it is controlled by Amber in NIP-55
|
||||
|
||||
the `NostrSignerInternal` and `NostrSignerExternal` classes.
|
||||
|
||||
```kt
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
val amberSigner = NostrSignerExternal(
|
||||
pubKey = keyPair.pubKey.toHexKey(),
|
||||
packageName = signerPackageName,
|
||||
contentResolver = appContext.contentResolver,
|
||||
)
|
||||
```
|
||||
|
||||
Create a single NostrClient for the entire application and control which relays it will access by
|
||||
registering subscriptions and sending events. The pool will automatically changed based on filters +
|
||||
outbox events.
|
||||
|
||||
You will need a coroutine scope to process events and if you are using OKHttp, we offer a basic
|
||||
wrapper to create the socket connections themselves.
|
||||
|
||||
```kt
|
||||
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
val rootClient = OkHttpClient.Builder().build()
|
||||
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
|
||||
|
||||
val client = NostrClient(socketBuilder, appScope)
|
||||
```
|
||||
|
||||
If you want to auth, given a logged-in `signer`:
|
||||
|
||||
```kt
|
||||
val authCoordinator = RelayAuthenticator(client, applicationIOScope) { challenge, relay ->
|
||||
val authedEvent = RelayAuthEvent.create(relayUrl, challenge, signer)
|
||||
client.sendIfExists(authedEvent, relay.url)
|
||||
}
|
||||
```
|
||||
|
||||
To manage subscriptions, the suggested approach is to use subscriptions in the Application class.
|
||||
|
||||
```kt
|
||||
val metadataSub = RelayClientSubscription(
|
||||
client = client,
|
||||
filter = {
|
||||
val filters = listOf(
|
||||
Filter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = listOf(signer.pubkey)
|
||||
)
|
||||
)
|
||||
|
||||
val signerOutboxRelays = listOfNotNull(
|
||||
RelayUrlNormalizer.normalizeOrNull("wss://relay1.com"),
|
||||
RelayUrlNormalizer.normalizeOrNull("wss://relay2.com")
|
||||
)
|
||||
|
||||
signerOutboxRelays.associateWith { filters }
|
||||
}
|
||||
) { event ->
|
||||
/* consume event */
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
0
app/.gitignore → amethyst/.gitignore
vendored
0
app/.gitignore → amethyst/.gitignore
vendored
363
amethyst/build.gradle
Normal file
363
amethyst/build.gradle
Normal file
@@ -0,0 +1,363 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid)
|
||||
alias(libs.plugins.googleServices)
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
alias(libs.plugins.serialization)
|
||||
}
|
||||
|
||||
def getCurrentBranch() {
|
||||
try {
|
||||
def branch = 'git rev-parse --abbrev-ref HEAD'.execute().text.trim()
|
||||
return branch
|
||||
} catch (Exception e) {
|
||||
println "Could not determine git branch: ${e.message}"
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
def generateVersionName(String baseVersion) {
|
||||
def currentBranch = getCurrentBranch()
|
||||
|
||||
if (currentBranch == "main" || currentBranch == "master" || currentBranch == "unknown" || currentBranch == "HEAD") {
|
||||
return baseVersion
|
||||
} else {
|
||||
// Clean branch name for version (replace special characters)
|
||||
def cleanBranch = currentBranch.replaceAll(/[^a-zA-Z0-9\-_]/, "-")
|
||||
|
||||
// Limit branch name to maximum 20 characters
|
||||
if (cleanBranch.length() > 20) {
|
||||
cleanBranch = cleanBranch.substring(0, 20)
|
||||
}
|
||||
|
||||
return "${baseVersion}-${cleanBranch}"
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = 'com.vitorpamplona.amethyst'
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInteger()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.vitorpamplona.amethyst"
|
||||
minSdk = libs.versions.android.minSdk.get().toInteger()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInteger()
|
||||
versionCode = 428
|
||||
versionName = generateVersionName("1.03.0")
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"08abe267baf5d7ce14db7975866f929e2794cc23484171aef0816c60a2416597\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
resourceConfigurations += [
|
||||
'ar',
|
||||
'ar-rSA',
|
||||
'bn-rBD',
|
||||
'cs',
|
||||
'cs-rCZ',
|
||||
'cy-rGB',
|
||||
'da-rDK',
|
||||
'de',
|
||||
'de-rDE',
|
||||
'el-rGR',
|
||||
'en-rGB',
|
||||
'eo',
|
||||
'eo-rUY',
|
||||
'es',
|
||||
'es-rES',
|
||||
'es-rMX',
|
||||
'es-rUS',
|
||||
'et-rEE',
|
||||
'fa',
|
||||
'fa-rIR',
|
||||
'fi-rFI',
|
||||
'fo-rFO',
|
||||
'fr',
|
||||
'fr-rCA',
|
||||
'fr-rFR',
|
||||
'gu-rIN',
|
||||
'hi-rIN',
|
||||
'hr-rHR',
|
||||
'hu',
|
||||
'hu-rHU',
|
||||
'in',
|
||||
'in-rID',
|
||||
'it-rIT',
|
||||
'iw-rIL',
|
||||
'ja',
|
||||
'ja-rJP',
|
||||
'kk-rKZ',
|
||||
'ko-rKR',
|
||||
'ks-rIN',
|
||||
'ku-rTR',
|
||||
'lt-rLT',
|
||||
'ne-rNP',
|
||||
'nl',
|
||||
'nl-rBE',
|
||||
'nl-rNL',
|
||||
'pcm-rNG',
|
||||
'pl-rPL',
|
||||
'pt-rBR',
|
||||
'pt-rPT',
|
||||
'ru',
|
||||
'ru-rRU',
|
||||
'ru-rUA',
|
||||
'sa-rIN',
|
||||
'sl-rSI',
|
||||
'so-rSO',
|
||||
'sr-rSP',
|
||||
'ss-rZA',
|
||||
'sv-rSE',
|
||||
'sw-rKE',
|
||||
'sw-rTZ',
|
||||
'ta',
|
||||
'ta-rIN',
|
||||
'th',
|
||||
'th-rTH',
|
||||
'tr',
|
||||
'tr-rTR',
|
||||
'uk',
|
||||
'uk-rUA',
|
||||
'ur-rIN',
|
||||
'uz-rUZ',
|
||||
'vi-rVN',
|
||||
'zh',
|
||||
'zh-rCN',
|
||||
'zh-rHK',
|
||||
'zh-rSG',
|
||||
'zh-rTW'
|
||||
]
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
|
||||
minifyEnabled = true
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix '.debug'
|
||||
versionNameSuffix '-DEBUG'
|
||||
resValue "string", "app_name", "@string/app_name_debug"
|
||||
}
|
||||
create("benchmark") {
|
||||
initWith(getByName("release"))
|
||||
applicationIdSuffix '.benchmark'
|
||||
versionNameSuffix '-BENCHMARK'
|
||||
resValue "string", "app_name", "@string/app_name_benchmark"
|
||||
profileable = true
|
||||
signingConfig = signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions = ["channel"]
|
||||
|
||||
productFlavors {
|
||||
play {
|
||||
getIsDefault().set(true)
|
||||
dimension "channel"
|
||||
}
|
||||
|
||||
fdroid {
|
||||
dimension "channel"
|
||||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable = true
|
||||
reset()
|
||||
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
|
||||
universalApk = true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
|
||||
configurations.all {
|
||||
def tink = "com.google.crypto.tink:tink-android:1.17.0"
|
||||
resolutionStrategy {
|
||||
force(tink)
|
||||
dependencySubstitution {
|
||||
substitute module('com.google.crypto.tink:tink') using module(tink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_21
|
||||
}
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
reportsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
metricsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation platform(libs.androidx.compose.bom)
|
||||
|
||||
implementation project(path: ':quartz')
|
||||
implementation project(path: ':commons')
|
||||
implementation project(path: ':ammolite')
|
||||
implementation libs.androidx.core.ktx
|
||||
implementation libs.androidx.activity.compose
|
||||
|
||||
implementation libs.androidx.ui
|
||||
implementation libs.androidx.ui.graphics
|
||||
implementation libs.androidx.ui.tooling.preview
|
||||
|
||||
// Needs this to open gallery / image upload
|
||||
implementation libs.androidx.fragment.ktx
|
||||
|
||||
// Navigation
|
||||
implementation libs.androidx.navigation.compose
|
||||
|
||||
// Material 3 Design
|
||||
implementation libs.androidx.material3
|
||||
implementation libs.androidx.material.icons
|
||||
|
||||
// Adaptive Layout / Two Pane
|
||||
implementation libs.androidx.material3.windowSize
|
||||
implementation libs.accompanist.adaptive
|
||||
|
||||
// Lifecycle
|
||||
implementation libs.androidx.lifecycle.runtime.ktx
|
||||
implementation libs.androidx.lifecycle.runtime.compose
|
||||
implementation libs.androidx.lifecycle.viewmodel.compose
|
||||
|
||||
// Zoomable images
|
||||
implementation libs.zoomable
|
||||
|
||||
// Biometrics
|
||||
implementation libs.androidx.biometric.ktx
|
||||
|
||||
// Websockets API
|
||||
implementation libs.okhttp
|
||||
implementation libs.okhttpCoroutines
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation libs.androidx.security.crypto.ktx
|
||||
implementation libs.androidx.datastore.preferences
|
||||
|
||||
// view videos
|
||||
implementation libs.androidx.media3.exoplayer
|
||||
implementation libs.androidx.media3.exoplayer.hls
|
||||
implementation libs.androidx.media3.ui
|
||||
implementation libs.androidx.media3.session
|
||||
|
||||
// important for proxy / tor
|
||||
implementation libs.androidx.media3.datasource.okhttp
|
||||
|
||||
// Load images from the web.
|
||||
implementation libs.coil.compose
|
||||
// view gifs
|
||||
implementation libs.coil.gif
|
||||
// view svgs
|
||||
implementation libs.coil.svg
|
||||
// enables network for coil
|
||||
implementation libs.coil.okhttp
|
||||
|
||||
// Permission to upload pictures:
|
||||
implementation libs.accompanist.permissions
|
||||
|
||||
// For QR generation
|
||||
implementation libs.zxing
|
||||
implementation libs.zxing.embedded
|
||||
|
||||
// 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"
|
||||
|
||||
// Markdown (With fix for full-image bleeds)
|
||||
implementation libs.markdown.ui
|
||||
implementation libs.markdown.ui.material3
|
||||
implementation libs.markdown.commonmark
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation libs.androidx.appcompat
|
||||
implementation libs.androidx.window.core.android
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation libs.google.mlkit.language.id
|
||||
|
||||
// Google services model the translate text
|
||||
playImplementation libs.google.mlkit.translate
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform(libs.firebase.bom)
|
||||
playImplementation libs.firebase.messaging
|
||||
|
||||
//PushNotifications(FDroid)
|
||||
fdroidImplementation libs.unifiedpush
|
||||
|
||||
// Charts
|
||||
implementation libs.vico.charts.core
|
||||
implementation libs.vico.charts.compose
|
||||
implementation libs.vico.charts.views
|
||||
implementation libs.vico.charts.m3
|
||||
|
||||
// GeoHash
|
||||
implementation libs.drfonfon.geohash
|
||||
|
||||
// Waveform visualizer
|
||||
implementation libs.audiowaveform
|
||||
|
||||
// Video compression lib
|
||||
implementation libs.abedElazizShe.image.compressor
|
||||
// Image compression lib
|
||||
implementation libs.zelory.video.compressor
|
||||
|
||||
// Cbor for cashuB format
|
||||
implementation libs.kotlinx.serialization.cbor
|
||||
|
||||
implementation libs.tor.android
|
||||
implementation libs.jtorctl
|
||||
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.mockk
|
||||
testImplementation libs.kotlinx.coroutines.test
|
||||
|
||||
androidTestImplementation platform(libs.androidx.compose.bom)
|
||||
androidTestImplementation libs.androidx.junit
|
||||
androidTestImplementation libs.androidx.junit.ktx
|
||||
androidTestImplementation libs.androidx.espresso.core
|
||||
|
||||
debugImplementation platform(libs.androidx.compose.bom)
|
||||
debugImplementation libs.androidx.ui.tooling
|
||||
debugImplementation libs.androidx.ui.test.manifest
|
||||
|
||||
implementation libs.androidx.camera.core
|
||||
implementation libs.androidx.camera.camera2
|
||||
implementation libs.androidx.camera.lifecycle
|
||||
implementation libs.androidx.camera.view
|
||||
implementation libs.androidx.camera.extensions
|
||||
}
|
||||
67
amethyst/google-services.json
Normal file
67
amethyst/google-services.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "768341258853",
|
||||
"project_id": "amethyst-3057a",
|
||||
"storage_bucket": "amethyst-3057a.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:768341258853:android:5d07c35a37b24ff36b8c8c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vitorpamplona.amethyst"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:768341258853:android:3270514bee61de546b8c8c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vitorpamplona.amethyst.benchmark"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:768341258853:android:e0200680f552484d6b8c8c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vitorpamplona.amethyst.debug"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
65
amethyst/proguard-rules.pro
vendored
Normal file
65
amethyst/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# preserve the line number information for debugging stack traces.
|
||||
-dontobfuscate
|
||||
-keepattributes LocalVariableTable
|
||||
-keepattributes LocalVariableTypeTable
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile
|
||||
-keepattributes LineNumberTable
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
-keepattributes MethodParameters
|
||||
-keepparameternames
|
||||
|
||||
-keepdirectories libs
|
||||
|
||||
# Keep all names
|
||||
-keepnames class ** { *; }
|
||||
|
||||
# Keep All enums
|
||||
-keep enum ** { *; }
|
||||
|
||||
# preserve access to native classses
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
|
||||
# JNA For Libsodium
|
||||
-keep class com.goterl.lazysodium.** { *; }
|
||||
|
||||
# libscrypt
|
||||
-keep class com.lambdaworks.codec.** { *; }
|
||||
-keep class com.lambdaworks.crypto.** { *; }
|
||||
-keep class com.lambdaworks.jni.** { *; }
|
||||
|
||||
-keep class info.guardianproject.** { *; }
|
||||
|
||||
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
|
||||
-keep class com.sun.jna.ToNativeConverter { *; }
|
||||
-keep class com.sun.jna.NativeMapped { *; }
|
||||
-keep class com.sun.jna.CallbackReference { *; }
|
||||
-keep class com.sun.jna.ptr.IntByReference { *; }
|
||||
-keep class com.sun.jna.NativeLong { *; }
|
||||
-keep class com.sun.jna.Structure { *; }
|
||||
-keep class com.sun.jna.Structure$* { *; }
|
||||
-keep class com.sun.jna.Native$ffi_callback { *; }
|
||||
-keep class * implements com.sun.jna.Structure$* { *; }
|
||||
-keep class * implements com.sun.jna.Native$* { *; }
|
||||
-keep class com.sun.jna.Native {
|
||||
private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object);
|
||||
private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object);
|
||||
private static java.lang.Class nativeType(java.lang.Class);
|
||||
private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object);
|
||||
private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method);
|
||||
}
|
||||
|
||||
# JSON parsing
|
||||
-keep class com.vitorpamplona.quartz.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.** { *; }
|
||||
-keep class com.vitorpamplona.ammolite.** { *; }
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.cashu.CashuParser
|
||||
import com.vitorpamplona.amethyst.service.cashu.CashuToken
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CashuBTest {
|
||||
val cashuTokenA = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"
|
||||
val cashuTokenB1 = "cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA"
|
||||
val cashuTokenB2 = "cashuBpGFkb01pbmliaXRzIHJ1bGVzIWFteEpodHRwOi8vbGJ1dGxoNWxmZ2dxNXI3eHBpd2hyYWpkbDdzeHB1cGdhZ2F6eGw2NXc0YzVjZzcyd3RvZmFzYWQub25pb246MzMzOGF0gaJhaUgAm7I9OpEuTmFwg6NhYRhAYWNYIQPfWR0mG80XbGnj6DO8q1NIyjHSGGIEkoWTA6H16HTpx2FzeEA3YThkY2Y5YjNlOGEyNDdjZTMzOWU3MzY5ZTliNGExOWYzMWVhY2I2OWQ4YjBjNjVkYWFlYjcyZDFhY2I5YWQzo2FhGCBhY1ghA55SwCFBc46dwnjbkb87Mzo30T2EE9Ws_nemuFneDegGYXN4QDlkODFjMWEyNjE2ODUzYWQ4MDQ5Y2JjZDFjN2MyNDdhZGQ4M2IzNzM4Mjg2MjBiYWMyZmQ3ZjNlNWE1OGFjZWKjYWEEYWNYIQM-yAQQTR2t6pIAmfmGM8Wxy7ajKVLOaUg7TrV8o-EdVWFzeEBmNmViMTI4ZmJlMDM3MTEzZTkzZjM3NjllYTYwMTk1NmY1N2NkZWNhNTYwOGY0NWUzMDhhZDU0ZmQ4YTQxNWVhYXVjc2F0"
|
||||
|
||||
@Test()
|
||||
fun parseCashuA() {
|
||||
runBlocking {
|
||||
val parsed = (CashuParser().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0]
|
||||
|
||||
assertEquals(cashuTokenA, parsed.token)
|
||||
assertEquals("https://8333.space:3338", parsed.mint)
|
||||
assertEquals(10, parsed.totalAmount)
|
||||
|
||||
assertEquals(2, parsed.proofs[0].amount)
|
||||
assertEquals("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", parsed.proofs[0].secret)
|
||||
assertEquals("009a1f293253e41e", parsed.proofs[0].id)
|
||||
assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].C)
|
||||
|
||||
assertEquals(8, parsed.proofs[1].amount)
|
||||
assertEquals("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", parsed.proofs[1].secret)
|
||||
assertEquals("009a1f293253e41e", parsed.proofs[1].id)
|
||||
assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].C)
|
||||
}
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun parseCashuB() =
|
||||
runBlocking {
|
||||
val parsed = (CashuParser().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded
|
||||
|
||||
assertEquals(cashuTokenB1, parsed[0].token)
|
||||
assertEquals("http://localhost:3338", parsed[0].mint)
|
||||
assertEquals(1, parsed[0].totalAmount)
|
||||
assertEquals(1, parsed[0].proofs[0].amount)
|
||||
assertEquals("acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", parsed[0].proofs[0].secret)
|
||||
assertEquals("00ffd48b8f5ecf80", parsed[0].proofs[0].id)
|
||||
assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].C)
|
||||
|
||||
assertEquals(3, parsed[1].totalAmount)
|
||||
assertEquals(2, parsed[1].proofs[0].amount)
|
||||
assertEquals("1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", parsed[1].proofs[0].secret)
|
||||
assertEquals("00ad268c4d1f5826", parsed[1].proofs[0].id)
|
||||
assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].C)
|
||||
|
||||
assertEquals(1, parsed[1].proofs[1].amount)
|
||||
assertEquals("56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", parsed[1].proofs[1].secret)
|
||||
assertEquals("00ad268c4d1f5826", parsed[1].proofs[1].id)
|
||||
assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].C)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun parseCashuB2() =
|
||||
runBlocking {
|
||||
val parsed = (CashuParser().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded
|
||||
|
||||
assertEquals(cashuTokenB2, parsed[0].token)
|
||||
assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint)
|
||||
assertEquals(100, parsed[0].totalAmount)
|
||||
assertEquals(64, parsed[0].proofs[0].amount)
|
||||
assertEquals("7a8dcf9b3e8a247ce339e7369e9b4a19f31eacb69d8b0c65daaeb72d1acb9ad3", parsed[0].proofs[0].secret)
|
||||
assertEquals("009bb23d3a912e4e", parsed[0].proofs[0].id)
|
||||
assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].C)
|
||||
|
||||
assertEquals(32, parsed[0].proofs[1].amount)
|
||||
assertEquals("9d81c1a2616853ad8049cbcd1c7c247add83b373828620bac2fd7f3e5a58aceb", parsed[0].proofs[1].secret)
|
||||
assertEquals("009bb23d3a912e4e", parsed[0].proofs[1].id)
|
||||
assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].C)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.okhttp.EncryptedBlobInterceptor
|
||||
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DMFileDecryptionTest {
|
||||
// Key cache to download and decrypt encrypted files before caching them.
|
||||
val keyCache = EncryptionKeyCache()
|
||||
|
||||
val url = "https://cdn.satellite.earth/812fd4cf9d4d4b59c141ecd6a6c08c7571b5872237ad6477916cb2d119b5cacd"
|
||||
val cipher =
|
||||
AESGCM(
|
||||
"7b184b3849e161027bab1aac9f2d96eb22bfe02c8dc34cad5d2cc436a64f191d".hexToByteArray(),
|
||||
"3936923383652e3f0d72bfd40568b435".hexToByteArray(),
|
||||
)
|
||||
val decryptedSize = 1277122
|
||||
val expectedMimeType = "video/mp4"
|
||||
|
||||
@Test
|
||||
fun runDownloadAndDecryptVideo() {
|
||||
val client =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.addNetworkInterceptor(EncryptedBlobInterceptor(keyCache))
|
||||
.build()
|
||||
|
||||
keyCache.add(url, cipher, "video/mp4")
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
assertEquals(decryptedSize, it.body.bytes().size)
|
||||
assertEquals(expectedMimeType, it.body.contentType().toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.uploads.ImageDownloader
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ImageUploadTesting {
|
||||
companion object {
|
||||
val accountSettings = AccountSettings(KeyPair())
|
||||
val signer = NostrSignerInternal(accountSettings.keyPair)
|
||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
val cache = LocalCache
|
||||
|
||||
val blossomServerListState =
|
||||
BlossomServerListState(
|
||||
signer = signer,
|
||||
cache = cache,
|
||||
scope = scope,
|
||||
settings = accountSettings,
|
||||
)
|
||||
}
|
||||
|
||||
val client =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/${BuildConfig.VERSION_NAME}"))
|
||||
.build()
|
||||
|
||||
private suspend fun getBitmap(): ByteArray {
|
||||
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
|
||||
for (x in 0 until bitmap.width) {
|
||||
for (y in 0 until bitmap.height) {
|
||||
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))
|
||||
}
|
||||
}
|
||||
|
||||
val baos = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
|
||||
return baos.toByteArray()
|
||||
}
|
||||
|
||||
private suspend fun testBase(server: ServerName) {
|
||||
if (server.type == ServerType.NIP96) {
|
||||
testNip96(server)
|
||||
} else {
|
||||
testBlossom(server)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun testBlossom(server: ServerName) {
|
||||
val paylod = getBitmap()
|
||||
val initialHash = sha256(paylod).toHexKey()
|
||||
val inputStream = paylod.inputStream()
|
||||
val result =
|
||||
BlossomUploader()
|
||||
.upload(
|
||||
inputStream = inputStream,
|
||||
hash = initialHash,
|
||||
length = paylod.size,
|
||||
baseFileName = "filename.png",
|
||||
contentType = "image/png",
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = server.baseUrl,
|
||||
okHttpClient = { client },
|
||||
httpAuth = blossomServerListState::createBlossomUploadAuth,
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
)
|
||||
|
||||
assertEquals("image/png", result.type)
|
||||
assertEquals(paylod.size.toLong(), result.size)
|
||||
assertEquals(initialHash, result.sha256)
|
||||
assertEquals("${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png"))
|
||||
|
||||
val imageData: ByteArray =
|
||||
ImageDownloader().waitAndGetImage(result.url!!, { client })?.bytes
|
||||
?: run {
|
||||
fail("${server.name}: Should not be null")
|
||||
return
|
||||
}
|
||||
|
||||
val downloadedHash = sha256(imageData).toHexKey()
|
||||
assertEquals(initialHash, downloadedHash)
|
||||
}
|
||||
|
||||
private suspend fun testNip96(server: ServerName) {
|
||||
val serverInfo =
|
||||
ServerInfoRetriever()
|
||||
.loadInfo(
|
||||
server.baseUrl,
|
||||
{ client },
|
||||
)
|
||||
|
||||
val payload = getBitmap()
|
||||
val inputStream = payload.inputStream()
|
||||
val result =
|
||||
Nip96Uploader()
|
||||
.upload(
|
||||
inputStream = inputStream,
|
||||
length = payload.size.toLong(),
|
||||
contentType = "image/png",
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server = serverInfo,
|
||||
okHttpClient = { client },
|
||||
onProgress = {},
|
||||
httpAuth = { url, method, body ->
|
||||
signer.sign(HTTPAuthorizationEvent.build(url, method, body))
|
||||
},
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
)
|
||||
|
||||
val url = result.url!!
|
||||
val size = result.size?.toInt()
|
||||
val dim = result.dimension
|
||||
val hash = result.sha256
|
||||
|
||||
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
|
||||
|
||||
val imageData: ByteArray =
|
||||
ImageDownloader().waitAndGetImage(url, { client })?.bytes
|
||||
?: run {
|
||||
fail("${server.name}: Should not be null")
|
||||
return
|
||||
}
|
||||
|
||||
val prepared =
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
"image/png",
|
||||
null,
|
||||
)
|
||||
|
||||
prepared.fold(
|
||||
onSuccess = {
|
||||
if (dim != null) {
|
||||
assertEquals("${server.name}: Invalid dimensions", it.dim.toString(), dim.toString())
|
||||
}
|
||||
if (size != null) {
|
||||
assertEquals("${server.name}: Invalid size", it.size, size)
|
||||
}
|
||||
},
|
||||
onFailure = { fail("${server.name}: It should not fail") },
|
||||
)
|
||||
|
||||
// delay(1000)
|
||||
|
||||
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runTestOnDefaultServers() =
|
||||
runBlocking {
|
||||
DEFAULT_MEDIA_SERVERS.forEach {
|
||||
// skip paid servers and primal server is buggy.
|
||||
if (!it.name.contains("Paid") && !it.name.contains("Primal")) {
|
||||
testBase(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrCheck() =
|
||||
runBlocking {
|
||||
testBase(ServerName("nostrcheck.me", "https://nostrcheck.me", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testNostrage() =
|
||||
runBlocking {
|
||||
testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testSove() =
|
||||
runBlocking {
|
||||
testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Ignore("Not Working anymore")
|
||||
@Test()
|
||||
fun testNostrBuild() =
|
||||
runBlocking {
|
||||
testBase(ServerName("nostr.build", "https://nostr.build", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testSovbit() =
|
||||
runBlocking {
|
||||
testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testVoidCat() =
|
||||
runBlocking {
|
||||
testBase(ServerName("void.cat", "https://void.cat", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Returns invalid image size")
|
||||
fun testNostrPic() =
|
||||
runBlocking {
|
||||
testBase(ServerName("nostpic.com", "https://nostpic.com", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test(expected = RuntimeException::class)
|
||||
fun testSprovoostNl() =
|
||||
runBlocking {
|
||||
testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testNostrOnch() =
|
||||
runBlocking {
|
||||
testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Ignore("Changes sha256")
|
||||
fun testPrimalBlossom() =
|
||||
runBlocking {
|
||||
testBase(ServerName("primal.net", "https://blossom.primal.net", ServerType.Blossom))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore/ Timeout")
|
||||
fun testNostrCheckBlossom() =
|
||||
runBlocking {
|
||||
testBase(ServerName("nostrcheck", "https://cdn.nostrcheck.me", ServerType.Blossom))
|
||||
}
|
||||
|
||||
@Ignore("Requires Payment")
|
||||
fun testSatelliteBlossom() =
|
||||
runBlocking {
|
||||
testBase(ServerName("satellite", "https://cdn.satellite.earth", ServerType.Blossom))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
@@ -21,20 +21,19 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.ots.OpenTimestamps
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpBitcoinExplorer
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpCalendarBuilder
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OtsBlockHeightCache
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OkHttpOtsTest {
|
||||
@@ -43,53 +42,54 @@ class OkHttpOtsTest {
|
||||
val otsPendingEvent = "{\"id\":\"12fa15ad4b4cf9dc5940389325b69b93c5c1f59c049c701ee669b275299fdaf1\",\"pubkey\":\"dcaa6c8a2f47b6fef4a34b20e8843c59dbe7c5f07a402338c09fd147dd01d22b\",\"created_at\":1708877521,\"kind\":1040,\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"alt\",\"Opentimestamps Attestation\"]],\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwELidvzr0usf55CkpKf6OABQI//AQK3sWd2tq+7KO8YNJIARJugjxBGXbZtPwCL0H4/7GL5+SAIPf4w0u+QyOLCtodHRwczovL2JvYi5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQPDZsJgN1TnJXoUzlsgo93wjwIIfBc7LUqkCbC1BLZRZ+6LXztK50UdH5xe7fn40bupkrCPEEZdtm0/AI0CADXN5ZIncAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ/AQcELcSrE04cuGKlZQf2LeVwjwILUDSf9vK2GaefKTpn/LV2oUsQaA5WbqaP3C+1ZxQfRNCPEEZdtm0/AIbCtb+yRXFqUAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb20=\",\"sig\":\"f6854c0228c15c08aeb70bbabe9ed87bbb7289fab31b13cabac15138bb71179553e06080b83f4a813fbdaf614f63293beea3fc73fe865da6551193fa4d38de04\"}"
|
||||
|
||||
val otsEvent2Digest = "a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6"
|
||||
val otsCache = OtsBlockHeightCache()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
|
||||
}
|
||||
val resolver =
|
||||
OtsResolver(
|
||||
OkHttpBitcoinExplorer(
|
||||
baseAPI = OkHttpBitcoinExplorer.MEMPOOL_API_URL,
|
||||
client = OkHttpClient.Builder().build(),
|
||||
cache = otsCache,
|
||||
),
|
||||
OkHttpCalendarBuilder { OkHttpClient.Builder().build() },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun verifyNostrEvent() {
|
||||
val ots = Event.fromJson(otsEvent) as OtsEvent
|
||||
println(ots.info())
|
||||
assertEquals(1707688818L, ots.verify())
|
||||
val ots = JsonMapper.fromJson(otsEvent) as OtsEvent
|
||||
println(resolver.info(ots.otsByteArray()))
|
||||
assertEquals(1707688818L, ots.verify(resolver))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyNostrEvent2() {
|
||||
val ots = Event.fromJson(otsEvent2) as OtsEvent
|
||||
println(ots.info())
|
||||
assertEquals(1706322179L, ots.verify())
|
||||
val ots = JsonMapper.fromJson(otsEvent2) as OtsEvent
|
||||
println(resolver.info(ots.otsByteArray()))
|
||||
assertEquals(1706322179L, ots.verify(resolver))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyNostrPendingEvent() {
|
||||
val ots = Event.fromJson(otsPendingEvent) as OtsEvent
|
||||
println(ots.info())
|
||||
assertEquals(null, ots.verify())
|
||||
val ots = JsonMapper.fromJson(otsPendingEvent) as OtsEvent
|
||||
println(resolver.info(ots.otsByteArray()))
|
||||
assertEquals(null, ots.verify(resolver))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createOTSEventAndVerify() {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
var ots: OtsEvent? = null
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
val otsFile = OtsEvent.stamp(otsEvent2Digest, resolver)
|
||||
|
||||
val otsFile = OtsEvent.stamp(otsEvent2Digest)
|
||||
val ots =
|
||||
runBlocking {
|
||||
signer.sign(OtsEvent.build(otsEvent2Digest, otsFile))
|
||||
}
|
||||
|
||||
OtsEvent.create(otsEvent2Digest, otsFile, signer) {
|
||||
ots = it
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
|
||||
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
|
||||
|
||||
println(ots!!.toJson())
|
||||
println(ots!!.info())
|
||||
println(ots.toJson())
|
||||
println(resolver.info(ots.otsByteArray()))
|
||||
|
||||
// Should not be valid because we need to wait for confirmations
|
||||
assertEquals(null, ots!!.verify())
|
||||
assertEquals(null, ots.verify(resolver))
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import java.net.URI
|
||||
|
||||
class URIParserTest {
|
||||
@Test
|
||||
fun testNEventWithAccount() {
|
||||
val test = "nevent1qqsp9wg5r3vkuv5al0459h3h44yxh6r0y7chgvu3pkdxhcj99t3msrgpzdmhxue69uhhwmm59e6hg7r09ehkuef0qgsdhcrqt2w8x9et446j8ge8kgmd2h4ykc6wsrnc4yqnmdu3lr74ktqrqsqqqqqp578kku?account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
|
||||
val testUri = URI(test)
|
||||
|
||||
// assertEquals("nostr", testUri.scheme)
|
||||
assertEquals(null, testUri.authority)
|
||||
assertEquals(null, testUri.host)
|
||||
assertEquals(-1, testUri.port)
|
||||
assertEquals(null, testUri.userInfo)
|
||||
assertEquals("nevent1qqsp9wg5r3vkuv5al0459h3h44yxh6r0y7chgvu3pkdxhcj99t3msrgpzdmhxue69uhhwmm59e6hg7r09ehkuef0qgsdhcrqt2w8x9et446j8ge8kgmd2h4ykc6wsrnc4yqnmdu3lr74ktqrqsqqqqqp578kku", testUri.path)
|
||||
assertEquals(null, testUri.fragment)
|
||||
assertEquals("account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.rawQuery)
|
||||
|
||||
assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.findParameterValue("account"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNotificationsWithAccount() {
|
||||
val test = "notifications?account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
|
||||
val testUri = URI(test)
|
||||
|
||||
// assertEquals("nostr", testUri.scheme)
|
||||
assertEquals(null, testUri.authority)
|
||||
assertEquals(null, testUri.host)
|
||||
assertEquals(-1, testUri.port)
|
||||
assertEquals(null, testUri.userInfo)
|
||||
assertEquals("notifications", testUri.path)
|
||||
assertEquals(null, testUri.fragment)
|
||||
assertEquals("account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.rawQuery)
|
||||
|
||||
assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.findParameterValue("account"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UrlUserTagTransformationTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
|
||||
}
|
||||
|
||||
fun debugCursor(
|
||||
original: String,
|
||||
transformedText: TransformedText,
|
||||
offset: Int,
|
||||
): String {
|
||||
val offsetTransformed = transformedText.offsetMapping.originalToTransformed(offset)
|
||||
val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length)
|
||||
val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length)
|
||||
return "$originalWithCursor $transformedWithCursor"
|
||||
}
|
||||
|
||||
fun debugCursorReverse(
|
||||
original: String,
|
||||
transformedText: TransformedText,
|
||||
offsetTransformed: Int,
|
||||
): String {
|
||||
val offset = transformedText.offsetMapping.transformedToOriginal(offsetTransformed)
|
||||
val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length)
|
||||
val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length)
|
||||
return "$originalWithCursor $transformedWithCursor"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKeepTransformedIndexFullyInsideTransformedText() {
|
||||
val user =
|
||||
LocalCache.getOrCreateUser(
|
||||
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
|
||||
.toHexKey(),
|
||||
)
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
|
||||
|
||||
val transformedText =
|
||||
buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString(original),
|
||||
Color.Red,
|
||||
)
|
||||
|
||||
val expected = "@Vitor Pamplona"
|
||||
assertEquals(expected, transformedText.text.text)
|
||||
|
||||
assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 0))
|
||||
assertEquals("@|npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 1))
|
||||
assertEquals("@n|pub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 2))
|
||||
assertEquals("@np|ub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 3))
|
||||
assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 4))
|
||||
assertEquals("@npub|1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 5))
|
||||
assertEquals("@npub1|gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 6))
|
||||
assertEquals("@npub1g|cxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 7))
|
||||
assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 8))
|
||||
assertEquals("@npub1gcx|zte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 9))
|
||||
assertEquals("@npub1gcxz|te5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 10))
|
||||
assertEquals("@npub1gcxzt|e5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 11))
|
||||
assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 12))
|
||||
assertEquals("@npub1gcxzte5|zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 13))
|
||||
assertEquals("@npub1gcxzte5z|lkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 14))
|
||||
assertEquals("@npub1gcxzte5zl|kncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 15))
|
||||
assertEquals("@npub1gcxzte5zlk|ncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 16))
|
||||
assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 17))
|
||||
assertEquals("@npub1gcxzte5zlknc|x26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 18))
|
||||
assertEquals("@npub1gcxzte5zlkncx|26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 19))
|
||||
assertEquals("@npub1gcxzte5zlkncx2|6j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 20))
|
||||
assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 21))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j|68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 22))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j6|8ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 23))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68|ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 24))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 25))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez|60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 26))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez6|0fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 27))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60|fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 28))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 29))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fz|kvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 30))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzk|vtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 31))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkv|tkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 32))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvt|km9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 33))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 34))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm|9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 35))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9|e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 36))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e|0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 37))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 38))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0v|rwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 39))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vr|wdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 40))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrw|dcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 41))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 42))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdc|vsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 43))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcv|sjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 44))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvs|jakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 45))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 46))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsja|kxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 47))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjak|xf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 48))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakx|f9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 49))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf|9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 50))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 51))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9m|u9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 52))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu|9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 53))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9|qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 54))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 55))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qe|wqlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 56))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qew|qlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 57))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewq|lfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 58))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 59))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlf|nj5z @Vitor Pamplon|a", debugCursor(original, transformedText, 60))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfn|j5z @Vitor Pamplon|a", debugCursor(original, transformedText, 61))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj|5z @Vitor Pamplon|a", debugCursor(original, transformedText, 62))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5|z @Vitor Pamplon|a", debugCursor(original, transformedText, 63))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursor(original, transformedText, 64))
|
||||
|
||||
assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursorReverse(original, transformedText, 0))
|
||||
assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursorReverse(original, transformedText, 1))
|
||||
assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursorReverse(original, transformedText, 2))
|
||||
assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursorReverse(original, transformedText, 3))
|
||||
assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursorReverse(original, transformedText, 4))
|
||||
assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursorReverse(original, transformedText, 5))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursorReverse(original, transformedText, 6))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursorReverse(original, transformedText, 7))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursorReverse(original, transformedText, 8))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursorReverse(original, transformedText, 9))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursorReverse(original, transformedText, 10))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursorReverse(original, transformedText, 11))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pampl|ona", debugCursorReverse(original, transformedText, 12))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pamplo|na", debugCursorReverse(original, transformedText, 13))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplon|a", debugCursorReverse(original, transformedText, 14))
|
||||
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursorReverse(original, transformedText, 15))
|
||||
|
||||
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0))
|
||||
assertEquals(15, transformedText.offsetMapping.originalToTransformed(64))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transformationText() {
|
||||
val user =
|
||||
LocalCache.getOrCreateUser(
|
||||
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
|
||||
.toHexKey(),
|
||||
)
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
val transformedText =
|
||||
buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
|
||||
Color.Red,
|
||||
)
|
||||
|
||||
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(8, transformedText.offsetMapping.originalToTransformed(11)) // Before u
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) // Before b
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
|
||||
|
||||
assertEquals(22, 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(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"
|
||||
|
||||
val transformedText =
|
||||
buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString(
|
||||
"New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||
),
|
||||
Color.Red,
|
||||
)
|
||||
|
||||
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
|
||||
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(11))
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(12))
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
|
||||
|
||||
assertEquals(22, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
|
||||
assertEquals(22, 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(67, 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 @
|
||||
}
|
||||
}
|
||||
5
amethyst/src/androidTest/res/values/strings.xml
Normal file
5
amethyst/src/androidTest/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">Amethyst</string>
|
||||
<string name="app_name_debug">Amy Debug</string>
|
||||
<string name="app_name_benchmark">Amy Benchmark</string>
|
||||
</resources>
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
@@ -27,6 +27,7 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TranslationsTest {
|
||||
@@ -124,7 +125,10 @@ class TranslationsTest {
|
||||
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay",
|
||||
"pt",
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = CancellationException::class)
|
||||
fun testTranslationLnInvoice2() {
|
||||
assertTranslateContains(
|
||||
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
|
||||
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
|
||||
@@ -132,7 +136,7 @@ class TranslationsTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Test(expected = CancellationException::class)
|
||||
fun testNostrEvents() {
|
||||
assertTranslateContains(
|
||||
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
|
||||
@@ -162,4 +166,15 @@ class TranslationsTest {
|
||||
"pt",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHttp() {
|
||||
val text = "https://m.primal.net/MdDd.png \nRunning... \uD83D\uDE01 nostr:npub126ntw5mnermmj0znhjhgdk8lh2af72sm8qfzq48umdlnhaj9kuns3le9ll nostr:npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm"
|
||||
|
||||
assertTranslateContains(
|
||||
"https://m.primal.net/MdDd.png",
|
||||
text,
|
||||
"pt",
|
||||
)
|
||||
}
|
||||
}
|
||||
22
amethyst/src/fdroid/AndroidManifest.xml
Normal file
22
amethyst/src/fdroid/AndroidManifest.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:name=".Amethyst">
|
||||
|
||||
<receiver
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".service.notifications.PushMessageReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
|
||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.lang
|
||||
|
||||
object LanguageTranslatorService {
|
||||
fun clear() {}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
@@ -38,13 +38,11 @@ interface PushDistributorActions {
|
||||
}
|
||||
|
||||
object PushDistributorHandler : PushDistributorActions {
|
||||
private val appContext = Amethyst.instance.applicationContext
|
||||
private val unifiedPush: UnifiedPush = UnifiedPush
|
||||
|
||||
private var endpointInternal = ""
|
||||
val endpoint = endpointInternal
|
||||
|
||||
fun getSavedEndpoint() = endpoint
|
||||
fun getSavedEndpoint() = endpointInternal
|
||||
|
||||
fun setEndpoint(newEndpoint: String) {
|
||||
endpointInternal = newEndpoint
|
||||
@@ -55,15 +53,13 @@ object PushDistributorHandler : PushDistributorActions {
|
||||
endpointInternal = ""
|
||||
}
|
||||
|
||||
override fun getSavedDistributor(): String {
|
||||
return unifiedPush.getSavedDistributor(appContext) ?: ""
|
||||
}
|
||||
fun appContext(): Context = Amethyst.instance.applicationContext
|
||||
|
||||
override fun getSavedDistributor(): String = unifiedPush.getSavedDistributor(appContext()) ?: ""
|
||||
|
||||
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
|
||||
|
||||
override fun getInstalledDistributors(): List<String> {
|
||||
return unifiedPush.getDistributors(appContext)
|
||||
}
|
||||
override fun getInstalledDistributors(): List<String> = unifiedPush.getDistributors(appContext())
|
||||
|
||||
fun formattedDistributorNames(): List<String> {
|
||||
val distributorsArray = getInstalledDistributors().toTypedArray()
|
||||
@@ -73,35 +69,34 @@ object PushDistributorHandler : PushDistributorActions {
|
||||
try {
|
||||
val ai =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appContext.packageManager.getApplicationInfo(
|
||||
appContext().packageManager.getApplicationInfo(
|
||||
it,
|
||||
PackageManager.ApplicationInfoFlags.of(
|
||||
PackageManager.GET_META_DATA.toLong(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
appContext.packageManager.getApplicationInfo(it, 0)
|
||||
appContext().packageManager.getApplicationInfo(it, 0)
|
||||
}
|
||||
appContext.packageManager.getApplicationLabel(ai)
|
||||
appContext().packageManager.getApplicationLabel(ai)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
it
|
||||
}
|
||||
as String
|
||||
}
|
||||
.toTypedArray()
|
||||
}.toTypedArray()
|
||||
return distributorsNameArray.toList()
|
||||
}
|
||||
|
||||
override fun saveDistributor(distributor: String) {
|
||||
unifiedPush.saveDistributor(appContext, distributor)
|
||||
unifiedPush.registerApp(appContext)
|
||||
unifiedPush.saveDistributor(appContext(), distributor)
|
||||
unifiedPush.register(appContext())
|
||||
}
|
||||
|
||||
override fun removeSavedDistributor() {
|
||||
unifiedPush.safeRemoveDistributor(appContext)
|
||||
unifiedPush.removeDistributor(appContext())
|
||||
}
|
||||
|
||||
fun forceRemoveDistributor(context: Context) {
|
||||
unifiedPush.forceRemoveDistributor(context)
|
||||
UnifiedPush.removeDistributor(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import org.unifiedpush.android.connector.data.PushMessage
|
||||
|
||||
class PushMessageReceiver : MessagingReceiver() {
|
||||
companion object {
|
||||
private val TAG = "Amethyst-OSSPushReceiver"
|
||||
}
|
||||
|
||||
private val appContext = Amethyst.instance.applicationContext
|
||||
private val scope = Amethyst.instance.applicationIOScope
|
||||
private val eventCache = LruCache<String, String>(100)
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
override fun onMessage(
|
||||
context: Context,
|
||||
message: PushMessage,
|
||||
instance: String,
|
||||
) {
|
||||
val messageStr = message.content.decodeToString()
|
||||
Log.d(TAG, "New message $messageStr for Instance: $instance")
|
||||
scope.launch {
|
||||
try {
|
||||
parseMessage(messageStr)?.let {
|
||||
receiveIfNew(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d(TAG, "Message could not be parsed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun parseMessage(message: String): GiftWrapEvent? {
|
||||
(Event.fromJson(message) as? GiftWrapEvent)?.let {
|
||||
return it
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun receiveIfNew(event: GiftWrapEvent) {
|
||||
if (eventCache.get(event.id) == null) {
|
||||
eventCache.put(event.id, event.id)
|
||||
EventNotificationConsumer(appContext).consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewEndpoint(
|
||||
context: Context,
|
||||
endpoint: PushEndpoint,
|
||||
instance: String,
|
||||
) {
|
||||
val sanitizedEndpoint = if (endpoint.url.endsWith("?up=1")) endpoint.url.dropLast(5) else endpoint.url
|
||||
if (sanitizedEndpoint != pushHandler.getSavedEndpoint()) {
|
||||
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint")
|
||||
pushHandler.setEndpoint(sanitizedEndpoint)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
PushNotificationUtils.checkAndInit(sanitizedEndpoint, LocalPreferences.allSavedAccounts()) {
|
||||
Amethyst.instance.okHttpClients.getHttpClient(Amethyst.instance.torManager.isSocksReady())
|
||||
}
|
||||
notificationManager().getOrCreateZapChannel(appContext)
|
||||
notificationManager().getOrCreateDMChannel(appContext)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(
|
||||
context: Context,
|
||||
reason: FailedReason,
|
||||
instance: String,
|
||||
) {
|
||||
Log.d(TAG, "Registration failed for Instance: $instance")
|
||||
pushHandler.forceRemoveDistributor(context)
|
||||
}
|
||||
|
||||
override fun onUnregistered(
|
||||
context: Context,
|
||||
instance: String,
|
||||
) {
|
||||
val removedEndpoint = pushHandler.getSavedEndpoint()
|
||||
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
|
||||
Log.d(TAG, "App is unregistered. ")
|
||||
pushHandler.forceRemoveDistributor(context)
|
||||
pushHandler.removeEndpoint()
|
||||
}
|
||||
|
||||
fun notificationManager(): NotificationManager =
|
||||
ContextCompat.getSystemService(appContext, NotificationManager::class.java)
|
||||
as NotificationManager
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.service.retryIfException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
object PushNotificationUtils {
|
||||
var lastToken: String? = null
|
||||
var hasInit: List<AccountInfo>? = null
|
||||
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
suspend fun checkAndInit(
|
||||
accounts: List<AccountInfo>,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
) = with(Dispatchers.IO) {
|
||||
if (!pushHandler.savedDistributorExists()) return
|
||||
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor()
|
||||
PushDistributorHandler.saveDistributor(currentDistributor)
|
||||
val token = pushHandler.getSavedEndpoint()
|
||||
|
||||
if (hasInit?.equals(accounts) == true && lastToken == token) {
|
||||
return@with
|
||||
}
|
||||
|
||||
registerToken(token, accounts, okHttpClient)
|
||||
}
|
||||
|
||||
suspend fun checkAndInit(
|
||||
token: String,
|
||||
accounts: List<AccountInfo>,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
) = with(Dispatchers.IO) {
|
||||
// initializes if the accounts are different or if the token has changed
|
||||
if (hasInit?.equals(accounts) == true && lastToken == token) {
|
||||
return@with
|
||||
}
|
||||
registerToken(token, accounts, okHttpClient)
|
||||
}
|
||||
|
||||
private suspend fun registerToken(
|
||||
token: String,
|
||||
accounts: List<AccountInfo>,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
) = retryIfException("RegisterAccounts") {
|
||||
RegisterAccounts(accounts, okHttpClient).go(token)
|
||||
lastToken = token
|
||||
hasInit = accounts.toList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
|
||||
import com.halilibo.richtext.commonmark.MarkdownParseOptions
|
||||
import com.halilibo.richtext.markdown.BasicMarkdown
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material3.RichText
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
|
||||
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeedsToRequestNotificationPermission
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
|
||||
val notificationPermissionState =
|
||||
checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
|
||||
|
||||
if (notificationPermissionState.status.isGranted) {
|
||||
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
|
||||
val context = LocalContext.current
|
||||
var distributorPresent by remember {
|
||||
mutableStateOf(PushDistributorHandler.savedDistributorExists())
|
||||
}
|
||||
if (!distributorPresent) {
|
||||
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
|
||||
if (readableListWithExplainer.size > 1) {
|
||||
SpinnerSelectionDialog(
|
||||
title = stringRes(id = R.string.select_push_server),
|
||||
options = readableListWithExplainer,
|
||||
onSelect = { index ->
|
||||
if (list[index] == "None") {
|
||||
PushDistributorHandler.forceRemoveDistributor(context)
|
||||
sharedPreferencesViewModel.dontAskForNotificationPermissions()
|
||||
sharedPreferencesViewModel.dontShowPushNotificationSelector()
|
||||
} else {
|
||||
val fullDistributorName = list[index]
|
||||
PushDistributorHandler.saveDistributor(fullDistributorName)
|
||||
}
|
||||
distributorPresent = true
|
||||
Log.d("Amethyst", "NotificationScreen: Distributor registered.")
|
||||
},
|
||||
onDismiss = {
|
||||
distributorPresent = true
|
||||
Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.")
|
||||
},
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = { distributorPresent = true },
|
||||
title = { Text(stringRes(R.string.push_server_install_app)) },
|
||||
text = {
|
||||
val content = stringRes(R.string.push_server_install_app_description)
|
||||
|
||||
val astNode =
|
||||
remember {
|
||||
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content)
|
||||
}
|
||||
|
||||
RichText(
|
||||
style = RichTextStyle().resolveDefaults(),
|
||||
renderer = null,
|
||||
) {
|
||||
BasicMarkdown(astNode)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
distributorPresent = true
|
||||
sharedPreferencesViewModel.dontShowPushNotificationSelector()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.quick_action_dont_show_again_button))
|
||||
}
|
||||
Button(
|
||||
onClick = { distributorPresent = true },
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringRes(R.string.error_dialog_button_ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, ImmutableList<TitleExplainer>) -> Unit) {
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None"
|
||||
|
||||
val list =
|
||||
remember {
|
||||
PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList()
|
||||
}
|
||||
|
||||
val readableListWithExplainer =
|
||||
PushDistributorHandler
|
||||
.formattedDistributorNames()
|
||||
.mapIndexed { index, name ->
|
||||
TitleExplainer(
|
||||
name,
|
||||
stringRes(id = R.string.push_server_uses_app_explainer, list[index]),
|
||||
)
|
||||
}.plus(
|
||||
TitleExplainer(
|
||||
stringRes(id = R.string.push_server_none),
|
||||
stringRes(id = R.string.push_server_none_explainer),
|
||||
),
|
||||
).toImmutableList()
|
||||
|
||||
onInner(
|
||||
currentDistributor,
|
||||
list,
|
||||
readableListWithExplainer,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
|
||||
SettingsRow(
|
||||
R.string.push_server_title,
|
||||
R.string.push_server_explainer,
|
||||
selectedItens = readableListWithExplainer,
|
||||
selectedIndex = list.indexOf(currentDistributor),
|
||||
) { index ->
|
||||
if (list[index] == "None") {
|
||||
sharedPreferencesViewModel.dontAskForNotificationPermissions()
|
||||
sharedPreferencesViewModel.dontShowPushNotificationSelector()
|
||||
PushDistributorHandler.forceRemoveDistributor(context)
|
||||
} else {
|
||||
PushDistributorHandler.saveDistributor(list[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists
|
||||
|
||||
@Composable
|
||||
fun TranslatableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
id: String,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) = ExpandableRichTextViewer(
|
||||
content,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
id,
|
||||
callbackUri,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TranslatableRichTextViewer(
|
||||
content: String,
|
||||
id: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
displayText: @Composable (String) -> Unit,
|
||||
) = displayText(content)
|
||||
209
amethyst/src/main/AndroidManifest.xml
Normal file
209
amethyst/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,209 @@
|
||||
<?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"/>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrsigner" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</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 (old devices) -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
|
||||
<!-- To read QRs that contain nostr:<NIP19> -->
|
||||
<!-- To take pictures -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- To record audio messages -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- 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" />
|
||||
|
||||
<!-- This notification permission is needed for some phones -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- Adds Geohash to posts if active -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Old permission to access media -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
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="@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="34">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:taskAffinity=".service.playback.pip.PipVideoActivity"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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="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>
|
||||
|
||||
<intent-filter android:label="New Post">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="New Post">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="New Post">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="njump.me">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="njump.me" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Primal">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="primal.net" />
|
||||
<data android:pathPrefix="/e/" />
|
||||
<data android:pathPrefix="/p/" />
|
||||
<data android:pathPrefix="/a/" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Yakihonne">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="yakihonne.com" />
|
||||
<data android:pathPrefix="/notes/" />
|
||||
<data android:pathPrefix="/users/" />
|
||||
<data android:pathPrefix="/videos/" />
|
||||
<data android:pathPrefix="/article/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:replace="screenOrientation"
|
||||
tools:ignore="DiscouragedApi" />
|
||||
|
||||
<activity
|
||||
android:name=".service.playback.pip.PipVideoActivity"
|
||||
android:autoRemoveFromRecents="true"
|
||||
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|keyboardHidden|keyboard|uiMode"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:exported="false"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/noAnimTheme"
|
||||
/>
|
||||
|
||||
<service
|
||||
android:name=".service.playback.service.PlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:stopWithTask="true"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".service.notifications.PokeyReceiver"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedReceiver"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="com.shared.NOSTR" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
233
amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
Normal file
233
amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
|
||||
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
|
||||
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
|
||||
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
|
||||
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
|
||||
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.logging.Logging
|
||||
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
|
||||
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
|
||||
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OtsBlockHeightCache
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class Amethyst : Application() {
|
||||
val appAgent = "Amethyst/${BuildConfig.VERSION_NAME}"
|
||||
|
||||
// Exists to avoid exceptions stopping the coroutine
|
||||
val exceptionHandler =
|
||||
CoroutineExceptionHandler { _, throwable ->
|
||||
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
|
||||
}
|
||||
|
||||
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
|
||||
|
||||
// Key cache service to download and decrypt encrypted files before caching them.
|
||||
val keyCache = EncryptionKeyCache()
|
||||
|
||||
// App services that should be run as soon as there are subscribers to their flows
|
||||
val locationManager = LocationState(this, applicationIOScope)
|
||||
val connManager = ConnectivityManager(this, applicationIOScope)
|
||||
|
||||
val torManager = TorManager(this, applicationIOScope)
|
||||
|
||||
// Service that will run at all times to receive events from Pokey
|
||||
val pokeyReceiver = PokeyReceiver()
|
||||
|
||||
// manages all the other connections separately from relays.
|
||||
val okHttpClients =
|
||||
DualHttpClientManager(
|
||||
userAgent = appAgent,
|
||||
proxyPortProvider = torManager.activePortOrNull,
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
keyCache = keyCache,
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
// manages all relay connections
|
||||
val okHttpClientForRelays =
|
||||
DualHttpClientManager(
|
||||
userAgent = appAgent,
|
||||
proxyPortProvider = torManager.activePortOrNull,
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
keyCache = keyCache,
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
// manages all relay connections
|
||||
val okHttpClientForRelaysForDms =
|
||||
DualHttpClientManager(
|
||||
userAgent = appAgent,
|
||||
proxyPortProvider = torManager.activePortOrNull,
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
keyCache = keyCache,
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
val torProxySettingsAnchor = ProxySettingsAnchor()
|
||||
|
||||
// Connects the NostrClient class with okHttp
|
||||
val websocketBuilder =
|
||||
OkHttpWebSocket.Builder { url ->
|
||||
val useTor = torProxySettingsAnchor.useProxy(url)
|
||||
if (torProxySettingsAnchor.isDM(url)) {
|
||||
okHttpClientForRelaysForDms.getHttpClient(useTor)
|
||||
} else {
|
||||
okHttpClientForRelays.getHttpClient(useTor)
|
||||
}
|
||||
}
|
||||
|
||||
// Caches all events in Memory
|
||||
val cache: LocalCache = LocalCache
|
||||
|
||||
// Organizes cache clearing
|
||||
val trimmingService = MemoryTrimmingService(cache)
|
||||
|
||||
// Provides a relay pool
|
||||
val client: NostrClient = NostrClient(websocketBuilder, applicationIOScope)
|
||||
|
||||
// Watches for changes on Tor and Relay List Settings
|
||||
val relayProxyClientConnector = RelayProxyClientConnector(torProxySettingsAnchor, okHttpClients, connManager, client, torManager, applicationIOScope)
|
||||
|
||||
// Verifies and inserts in the cache from all relays, all subscriptions
|
||||
val cacheClientConnector = CacheClientConnector(client, cache)
|
||||
|
||||
// Show messages from the Relay and controls their dismissal
|
||||
val notifyCoordinator = NotifyCoordinator(client)
|
||||
|
||||
// Authenticates with relays.
|
||||
val authCoordinator = AuthCoordinator(client, applicationIOScope)
|
||||
|
||||
val logger = if (isDebug) RelaySpeedLogger(client) else null
|
||||
|
||||
// Coordinates all subscriptions for the Nostr Client
|
||||
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope)
|
||||
|
||||
// saves the .content of NIP-95 blobs in disk to save memory
|
||||
val nip95cache: File by lazy { Nip95CacheFactory.new(this) }
|
||||
|
||||
// local video cache with disk + memory
|
||||
val videoCache: VideoCache by lazy { VideoCacheFactory.new(this) }
|
||||
|
||||
// image cache in disk for coil
|
||||
val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(this) }
|
||||
|
||||
// image cache in memory for coil
|
||||
val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(this) }
|
||||
|
||||
val crashReportCache: CrashReportCache by lazy { CrashReportCache(this) }
|
||||
|
||||
// Application-wide ots verification cache
|
||||
val otsVerifCache by lazy { VerificationStateCache() }
|
||||
|
||||
// Application-wide block height request cache
|
||||
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d("AmethystApp", "onCreate $this")
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
|
||||
|
||||
instance = this
|
||||
|
||||
if (isDebug) {
|
||||
Logging.setup()
|
||||
}
|
||||
|
||||
// initializes diskcache on an IO thread.
|
||||
applicationIOScope.launch {
|
||||
diskCache
|
||||
videoCache
|
||||
}
|
||||
|
||||
// registers to receive events
|
||||
pokeyReceiver.register(this)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
Log.d("AmethystApp", "onTerminate $this")
|
||||
|
||||
pokeyReceiver.unregister(this)
|
||||
applicationIOScope.cancel("Application onTerminate $this")
|
||||
}
|
||||
|
||||
fun contentResolverFn(): ContentResolver = contentResolver
|
||||
|
||||
fun setImageLoader(shouldUseTor: (String) -> Boolean?) {
|
||||
ImageLoaderSetup.setup(this, diskCache, memoryCache) { url ->
|
||||
shouldUseTor(url)?.let { okHttpClients.getHttpClient(it) } ?: okHttpClients.getHttpClient(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
|
||||
|
||||
/**
|
||||
* Release memory when the UI becomes hidden or when system resources become low.
|
||||
*
|
||||
* @param level the memory-related event that was raised.
|
||||
*/
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
Log.d("AmethystApp", "onTrimMemory $level")
|
||||
applicationIOScope.launch(Dispatchers.Default) {
|
||||
trimmingService.run(null, LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: Amethyst
|
||||
private set
|
||||
}
|
||||
}
|
||||
199
amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt
Normal file
199
amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Debug
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
|
||||
|
||||
fun debugState(context: Context) {
|
||||
// Amethyst.instance.client
|
||||
// .allSubscriptions()
|
||||
// .forEach { Log.d("STATE DUMP", "${it.key} ${it.value.filters.joinToString { it.filter.toJson() }}") }
|
||||
|
||||
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
|
||||
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
|
||||
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
|
||||
|
||||
val jvmHeapAllocatedMb = totalMemoryMb - freeMemoryMb
|
||||
|
||||
Log.d("STATE DUMP", "Total Heap Allocated: $jvmHeapAllocatedMb/$maxMemoryMb MB")
|
||||
|
||||
val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024)
|
||||
val maxNative = Debug.getNativeHeapSize() / (1024 * 1024)
|
||||
|
||||
Log.d("STATE DUMP", "Total Native Heap Allocated: $nativeHeap/$maxNative MB")
|
||||
|
||||
val activityManager: ActivityManager? = context.getSystemService()
|
||||
if (activityManager != null) {
|
||||
val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0
|
||||
val memClass = if (isLargeHeap) activityManager.largeMemoryClass else activityManager.memoryClass
|
||||
|
||||
Log.d("STATE DUMP", "Memory Class $memClass MB (largeHeap $isLargeHeap)")
|
||||
}
|
||||
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Connected Relays: " +
|
||||
Amethyst.instance.client
|
||||
.relayStatusFlow()
|
||||
.value.connected.size + "/" +
|
||||
Amethyst.instance.client
|
||||
.relayStatusFlow()
|
||||
.value.available.size,
|
||||
)
|
||||
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Image Disk Cache ${(Amethyst.instance.diskCache.size) / (1024 * 1024)}/${(Amethyst.instance.diskCache.maxSize) / (1024 * 1024)} MB",
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Image Memory Cache ${(Amethyst.instance.memoryCache.size) / (1024 * 1024)}/${(Amethyst.instance.memoryCache.maxSize) / (1024 * 1024)} MB",
|
||||
)
|
||||
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Notes: " +
|
||||
LocalCache.notes.filter { _, it -> it.flowSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.notes.filter { _, it -> it.event != null }.size +
|
||||
" / " +
|
||||
LocalCache.notes.size(),
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Addressables: " +
|
||||
LocalCache.addressables.filter { _, it -> it.flowSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.addressables.filter { _, it -> it.event != null }.size +
|
||||
" / " +
|
||||
LocalCache.addressables.size(),
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Users: " +
|
||||
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.size(),
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Deletion Events: " +
|
||||
LocalCache.deletionIndex.size(),
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Observable Events: " +
|
||||
LocalCache.observablesByKindAndETag.size +
|
||||
" / " +
|
||||
LocalCache.observablesByKindAndAuthor.size,
|
||||
)
|
||||
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Spam: " +
|
||||
LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentEventIds.size() + " / " + LocalCache.antiSpam.recentAddressables.size(),
|
||||
)
|
||||
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Memory used by Events: " +
|
||||
LocalCache.notes.sumOfLong { _, note -> note.event?.countMemory() ?: 0L } / (1024 * 1024) +
|
||||
" MB",
|
||||
)
|
||||
|
||||
val qttNotes = LocalCache.notes.countByGroup { _, it -> it.event?.kind }
|
||||
val qttAddressables = LocalCache.addressables.countByGroup { _, it -> it.event?.kind }
|
||||
|
||||
val bytesNotes =
|
||||
LocalCache.notes
|
||||
.sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
|
||||
val bytesAddressables =
|
||||
LocalCache.addressables
|
||||
.sumByGroup(groupMap = { _, it -> it.event?.kind }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
|
||||
|
||||
qttNotes.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) ->
|
||||
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes[kind]?.div((1024 * 1024))}MB ")
|
||||
}
|
||||
qttAddressables.toList().sortedByDescending { bytesNotes[it.first] }.forEach { (kind, qtt) ->
|
||||
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables[kind]?.div((1024 * 1024))}MB ")
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> logTime(
|
||||
debugMessage: String,
|
||||
minToReportMs: Int = 1,
|
||||
block: () -> T,
|
||||
): T =
|
||||
if (isDebug) {
|
||||
val (result, elapsed) = measureTimedValue(block)
|
||||
if (elapsed.inWholeMilliseconds > minToReportMs) {
|
||||
Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: $debugMessage")
|
||||
}
|
||||
result
|
||||
} else {
|
||||
block()
|
||||
}
|
||||
|
||||
inline fun <T> logTime(
|
||||
debugMessage: (T) -> String,
|
||||
minToReportMs: Int = 1,
|
||||
block: () -> T,
|
||||
): T =
|
||||
if (isDebug) {
|
||||
val (result, elapsed) = measureTimedValue(block)
|
||||
if (elapsed.inWholeMilliseconds > minToReportMs) {
|
||||
Log.d("DEBUG-TIME", "${elapsed.toString(DurationUnit.MILLISECONDS, 3).padStart(12)}: ${debugMessage(result)}")
|
||||
}
|
||||
result
|
||||
} else {
|
||||
block()
|
||||
}
|
||||
|
||||
fun debug(
|
||||
tag: String,
|
||||
debugMessage: String,
|
||||
) {
|
||||
if (isDebug) {
|
||||
Log.d(tag, debugMessage)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun debug(
|
||||
tag: String,
|
||||
debugMessage: () -> String,
|
||||
) {
|
||||
if (isDebug) {
|
||||
Log.d(tag, debugMessage())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
@@ -29,16 +29,15 @@ class EncryptedStorage {
|
||||
private const val PREFERENCES_NAME = "secret_keeper"
|
||||
|
||||
// returns the preferences for each account or a global file if null.
|
||||
fun prefsFileName(npub: String? = null): String {
|
||||
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
|
||||
}
|
||||
fun prefsFileName(npub: String? = null): String = if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
|
||||
|
||||
fun preferences(
|
||||
applicationContext: Context,
|
||||
npub: String? = null,
|
||||
): EncryptedSharedPreferences {
|
||||
val masterKey: MasterKey =
|
||||
MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
MasterKey
|
||||
.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.core.content.edit
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
// 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"
|
||||
|
||||
@Immutable
|
||||
data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean,
|
||||
val loggedInWithExternalSigner: Boolean,
|
||||
val isTransient: Boolean,
|
||||
)
|
||||
|
||||
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 LOCAL_RELAY_SERVERS = "localRelayServers"
|
||||
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_USER_METADATA = "latestUserMetadata"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
|
||||
const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList"
|
||||
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
|
||||
const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList"
|
||||
const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList"
|
||||
const val LATEST_MUTE_LIST = "latestMuteList"
|
||||
const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList"
|
||||
const val LATEST_APP_SPECIFIC_DATA = "latestAppSpecificData"
|
||||
const val LATEST_CHANNEL_LIST = "latestChannelList"
|
||||
const val LATEST_COMMUNITY_LIST = "latestCommunityList"
|
||||
const val LATEST_HASHTAG_LIST = "latestHashtagList"
|
||||
const val LATEST_GEOHASH_LIST = "latestGeohashList"
|
||||
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
|
||||
const val TOR_SETTINGS = "tor_settings"
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
|
||||
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
|
||||
const val SIGNER_PACKAGE_NAME = "signer_package_name"
|
||||
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
|
||||
const val PENDING_ATTESTATIONS = "pending_attestations"
|
||||
|
||||
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
|
||||
const val SHARED_SETTINGS = "shared_settings"
|
||||
}
|
||||
|
||||
object LocalPreferences {
|
||||
private const val COMMA = ","
|
||||
|
||||
private var currentAccount: String? = null
|
||||
private val savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
|
||||
private val cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
|
||||
|
||||
suspend fun currentAccount(): String? {
|
||||
if (currentAccount == null) {
|
||||
currentAccount =
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
}
|
||||
}
|
||||
return currentAccount
|
||||
}
|
||||
|
||||
private suspend fun updateCurrentAccount(info: AccountInfo?) {
|
||||
if (info == null) {
|
||||
currentAccount = null
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences().edit { clear() }
|
||||
}
|
||||
} else if (currentAccount != info.npub) {
|
||||
currentAccount = info.npub
|
||||
if (!info.isTransient) {
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences().edit { putString(PrefKeys.CURRENT_ACCOUNT, info.npub) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun savedAccounts(): List<AccountInfo> {
|
||||
if (savedAccounts.value == null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
with(encryptedPreferences()) {
|
||||
val newSystemOfAccounts =
|
||||
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
|
||||
JsonMapper.mapper.readValue<List<AccountInfo>>(it)
|
||||
}
|
||||
|
||||
if (!newSystemOfAccounts.isNullOrEmpty()) {
|
||||
savedAccounts.emit(newSystemOfAccounts)
|
||||
} else {
|
||||
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
|
||||
|
||||
val migrated =
|
||||
oldAccounts.map { npub ->
|
||||
AccountInfo(
|
||||
npub,
|
||||
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
|
||||
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
savedAccounts.emit(migrated)
|
||||
|
||||
edit { putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.mapper.writeValueAsString(savedAccounts.value)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// it's always not null when it gets here.
|
||||
return savedAccounts.value!!
|
||||
}
|
||||
|
||||
fun accountsFlow() = savedAccounts
|
||||
|
||||
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (savedAccounts != accounts) {
|
||||
savedAccounts.emit(accounts)
|
||||
|
||||
encryptedPreferences()
|
||||
.edit {
|
||||
putString(
|
||||
PrefKeys.ALL_ACCOUNT_INFO,
|
||||
JsonMapper.mapper.writeValueAsString(accounts.filter { !it.isTransient }),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val prefsDirPath: String
|
||||
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
|
||||
|
||||
private suspend fun addAccount(accInfo: AccountInfo) {
|
||||
val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo)
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
|
||||
private suspend fun setCurrentAccount(accountSettings: AccountSettings) {
|
||||
val npub = accountSettings.keyPair.pubKey.toNpub()
|
||||
val accInfo =
|
||||
AccountInfo(
|
||||
npub,
|
||||
accountSettings.isWriteable(),
|
||||
accountSettings.externalSignerPackageName != null,
|
||||
accountSettings.transientAccount,
|
||||
)
|
||||
updateCurrentAccount(accInfo)
|
||||
addAccount(accInfo)
|
||||
}
|
||||
|
||||
suspend fun switchToAccount(accountInfo: AccountInfo) = updateCurrentAccount(accountInfo)
|
||||
|
||||
/** Removes the account from the app level shared preferences */
|
||||
private suspend fun removeAccount(accountInfo: AccountInfo) {
|
||||
updateSavedAccounts(savedAccounts().filter { it.npub != accountInfo.npub })
|
||||
}
|
||||
|
||||
/** Deletes the npub-specific shared preference file */
|
||||
private suspend fun deleteUserPreferenceFile(npub: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
File(prefsDir, it).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
|
||||
checkNotInMainThread()
|
||||
|
||||
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
|
||||
val preferenceFile =
|
||||
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
|
||||
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
|
||||
} else {
|
||||
Amethyst.instance.encryptedStorage(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")
|
||||
suspend fun deleteAccount(accountInfo: AccountInfo) {
|
||||
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}")
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() }
|
||||
removeAccount(accountInfo)
|
||||
deleteUserPreferenceFile(accountInfo.npub)
|
||||
|
||||
if (savedAccounts().isEmpty()) {
|
||||
updateCurrentAccount(null)
|
||||
} else if (currentAccount() == accountInfo.npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDefaultAccount(accountSettings: AccountSettings) {
|
||||
setCurrentAccount(accountSettings)
|
||||
saveToEncryptedStorage(accountSettings)
|
||||
}
|
||||
|
||||
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
|
||||
|
||||
suspend fun saveToEncryptedStorage(settings: AccountSettings) {
|
||||
Log.d("LocalPreferences", "Saving to encrypted storage")
|
||||
if (!settings.transientAccount) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub())
|
||||
prefs.edit {
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null)
|
||||
if (settings.externalSignerPackageName != null) {
|
||||
remove(PrefKeys.NOSTR_PRIVKEY)
|
||||
putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName)
|
||||
} else {
|
||||
remove(PrefKeys.SIGNER_PACKAGE_NAME)
|
||||
settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
|
||||
}
|
||||
settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
|
||||
|
||||
putString(
|
||||
PrefKeys.DEFAULT_FILE_SERVER,
|
||||
JsonMapper.mapper.writeValueAsString(settings.defaultFileServer),
|
||||
)
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
|
||||
settings.defaultNotificationFollowList.value,
|
||||
)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
|
||||
settings.defaultDiscoveryFollowList.value,
|
||||
)
|
||||
putString(
|
||||
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
|
||||
JsonMapper.mapper.writeValueAsString(settings.zapPaymentRequest.value?.denormalize()),
|
||||
)
|
||||
if (settings.backupContactList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_CONTACT_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupContactList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_CONTACT_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupUserMetadata != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_USER_METADATA,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupUserMetadata),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_USER_METADATA)
|
||||
}
|
||||
|
||||
if (settings.backupDMRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_DM_RELAY_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupDMRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_DM_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupNIP65RelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_NIP65_RELAY_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupNIP65RelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupSearchRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_SEARCH_RELAY_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupSearchRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupBlockedRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_BLOCKED_RELAY_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupBlockedRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupTrustedRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_TRUSTED_RELAY_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupTrustedRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.localRelayServers.value.isNotEmpty()) {
|
||||
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers.value)
|
||||
} else {
|
||||
remove(PrefKeys.LOCAL_RELAY_SERVERS)
|
||||
}
|
||||
|
||||
if (settings.backupMuteList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_MUTE_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupMuteList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_MUTE_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupPrivateHomeRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupAppSpecificData != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_APP_SPECIFIC_DATA,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupAppSpecificData),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_APP_SPECIFIC_DATA)
|
||||
}
|
||||
|
||||
if (settings.backupChannelList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_CHANNEL_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupChannelList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_CHANNEL_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupCommunityList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_COMMUNITY_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupCommunityList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_COMMUNITY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupHashtagList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_HASHTAG_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupHashtagList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_HASHTAG_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupGeohashList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_GEOHASH_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupGeohashList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_GEOHASH_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupEphemeralChatList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_EPHEMERAL_LIST,
|
||||
JsonMapper.mapper.writeValueAsString(settings.backupEphemeralChatList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_EPHEMERAL_LIST)
|
||||
}
|
||||
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
|
||||
|
||||
// migrating from previous design
|
||||
remove(PrefKeys.USE_PROXY)
|
||||
remove(PrefKeys.PROXY_PORT)
|
||||
|
||||
putString(PrefKeys.TOR_SETTINGS, JsonMapper.mapper.writeValueAsString(settings.torSettings.toSettings()))
|
||||
|
||||
val regularMap =
|
||||
settings.lastReadPerRoute.value.mapValues {
|
||||
it.value.value
|
||||
}
|
||||
|
||||
putString(
|
||||
PrefKeys.LAST_READ_PER_ROUTE,
|
||||
JsonMapper.mapper.writeValueAsString(regularMap),
|
||||
)
|
||||
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
|
||||
|
||||
putString(
|
||||
PrefKeys.PENDING_ATTESTATIONS,
|
||||
JsonMapper.mapper.writeValueAsString(settings.pendingAttestations.value),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d("LocalPreferences", "Saved to encrypted storage")
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
|
||||
|
||||
suspend fun saveSharedSettings(
|
||||
sharedSettings: Settings,
|
||||
prefs: SharedPreferences = encryptedPreferences(),
|
||||
) {
|
||||
Log.d("LocalPreferences", "Saving to shared settings")
|
||||
prefs.edit {
|
||||
putString(PrefKeys.SHARED_SETTINGS, JsonMapper.mapper.writeValueAsString(sharedSettings))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
|
||||
Log.d("LocalPreferences", "Load shared settings")
|
||||
with(prefs) {
|
||||
return try {
|
||||
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { JsonMapper.mapper.readValue<Settings>(it) }
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mutex = Mutex()
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): AccountSettings? {
|
||||
// if already loaded, return right away
|
||||
if (cachedAccounts.containsKey(npub)) {
|
||||
return cachedAccounts[npub]
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
if (cachedAccounts.containsKey(npub)) {
|
||||
return@withContext cachedAccounts.get(npub)
|
||||
}
|
||||
|
||||
val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub)
|
||||
|
||||
cachedAccounts.put(npub, accountSettings)
|
||||
|
||||
return@withContext accountSettings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
|
||||
Log.d("LocalPreferences", "Load account from file $npub")
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
return@withContext with(encryptedPreferences(npub)) {
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null
|
||||
val externalSignerPackageName =
|
||||
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
|
||||
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
|
||||
|
||||
val defaultHomeFollowList =
|
||||
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: ALL_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 zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
|
||||
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
|
||||
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
|
||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||
|
||||
val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
|
||||
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
|
||||
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
|
||||
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
|
||||
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
|
||||
val latestBlockedRelayList = parseEventOrNull<BlockedRelayListEvent>(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
|
||||
val latestTrustedRelayList = parseEventOrNull<TrustedRelayListEvent>(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
|
||||
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
|
||||
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
|
||||
val latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(PrefKeys.LATEST_APP_SPECIFIC_DATA)
|
||||
val latestChannelList = parseEventOrNull<ChannelListEvent>(PrefKeys.LATEST_CHANNEL_LIST)
|
||||
val latestCommunityList = parseEventOrNull<CommunityListEvent>(PrefKeys.LATEST_COMMUNITY_LIST)
|
||||
val latestHashtagList = parseEventOrNull<HashtagListEvent>(PrefKeys.LATEST_HASHTAG_LIST)
|
||||
val latestGeohashList = parseEventOrNull<GeohashListEvent>(PrefKeys.LATEST_GEOHASH_LIST)
|
||||
val latestEphemeralList = parseEventOrNull<EphemeralChatListEvent>(PrefKeys.LATEST_EPHEMERAL_LIST)
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||
|
||||
val torSettings = parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
|
||||
|
||||
val lastReadPerRoute =
|
||||
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
|
||||
MutableStateFlow(it.value)
|
||||
} ?: mapOf()
|
||||
|
||||
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
|
||||
return@with AccountSettings(
|
||||
keyPair = keyPair,
|
||||
transientAccount = false,
|
||||
externalSignerPackageName = externalSignerPackageName,
|
||||
localRelayServers = MutableStateFlow(localRelayServers),
|
||||
defaultFileServer = defaultFileServer,
|
||||
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
|
||||
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
|
||||
zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer?.normalize()),
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
backupUserMetadata = latestUserMetadata,
|
||||
backupContactList = latestContactList,
|
||||
backupNIP65RelayList = latestNip65RelayList,
|
||||
backupDMRelayList = latestDmRelayList,
|
||||
backupSearchRelayList = latestSearchRelayList,
|
||||
backupBlockedRelayList = latestBlockedRelayList,
|
||||
backupTrustedRelayList = latestTrustedRelayList,
|
||||
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
|
||||
backupMuteList = latestMuteList,
|
||||
backupAppSpecificData = latestAppSpecificData,
|
||||
backupChannelList = latestChannelList,
|
||||
backupCommunityList = latestCommunityList,
|
||||
backupHashtagList = latestHashtagList,
|
||||
backupGeohashList = latestGeohashList,
|
||||
backupEphemeralChatList = latestEphemeralList,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations),
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("LocalPreferences", "Loaded account from file $npub")
|
||||
return result
|
||||
}
|
||||
|
||||
private inline fun <reified T> SharedPreferences.parseOrNull(key: String): T? {
|
||||
val value = getString(key, null)
|
||||
if (value.isNullOrEmpty() || value == "null") {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
if (T::class.java.isInstance(Event::class.java)) {
|
||||
Event.fromJson(value) as T?
|
||||
} else {
|
||||
JsonMapper.mapper.readValue<T?>(value)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> SharedPreferences.parseEventOrNull(key: String): T? {
|
||||
val value = getString(key, null)
|
||||
if (value.isNullOrEmpty() || value == "null") {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
Event.fromJson(value) as T?
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
1778
amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
Normal file
1778
amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.content.ContentResolver
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultChannels =
|
||||
listOf(
|
||||
// Anigma's Nostr
|
||||
ChannelTag("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos),
|
||||
// Amethyst's Group
|
||||
ChannelTag("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos),
|
||||
)
|
||||
|
||||
val DefaultNIP65RelaySet = setOf(Constants.mom, Constants.nos, Constants.bitcoiner)
|
||||
|
||||
val DefaultNIP65List =
|
||||
listOf(
|
||||
AdvertisedRelayInfo(Constants.mom, AdvertisedRelayType.BOTH),
|
||||
AdvertisedRelayInfo(Constants.nos, AdvertisedRelayType.BOTH),
|
||||
AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH),
|
||||
)
|
||||
|
||||
val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos)
|
||||
|
||||
val DefaultSearchRelayList = setOf(Constants.band, Constants.wine, Constants.where, Constants.nostoday)
|
||||
|
||||
val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds)
|
||||
|
||||
val DefaultSignerPermissions =
|
||||
listOf(
|
||||
Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND),
|
||||
Permission(CommandType.SIGN_EVENT, DraftWrapEvent.KIND),
|
||||
Permission(CommandType.NIP04_ENCRYPT),
|
||||
Permission(CommandType.NIP04_DECRYPT),
|
||||
Permission(CommandType.NIP44_DECRYPT),
|
||||
Permission(CommandType.NIP44_DECRYPT),
|
||||
Permission(CommandType.DECRYPT_ZAP_EVENT),
|
||||
)
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val GLOBAL_FOLLOWS = " Global "
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val ALL_FOLLOWS = " All Follows "
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val AROUND_ME = " Around Me "
|
||||
|
||||
@Stable
|
||||
class AccountSettings(
|
||||
val keyPair: KeyPair,
|
||||
val transientAccount: Boolean = false,
|
||||
var externalSignerPackageName: String? = null,
|
||||
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
|
||||
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(ALL_FOLLOWS),
|
||||
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
var zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP17WarningDialog: Boolean = false,
|
||||
var backupUserMetadata: MetadataEvent? = null,
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var backupDMRelayList: ChatMessageRelayListEvent? = null,
|
||||
var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
|
||||
var backupSearchRelayList: SearchRelayListEvent? = null,
|
||||
var backupBlockedRelayList: BlockedRelayListEvent? = null,
|
||||
var backupTrustedRelayList: TrustedRelayListEvent? = null,
|
||||
var backupMuteList: MuteListEvent? = null,
|
||||
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
|
||||
var backupAppSpecificData: AppSpecificDataEvent? = null,
|
||||
var backupChannelList: ChannelListEvent? = null,
|
||||
var backupCommunityList: CommunityListEvent? = null,
|
||||
var backupHashtagList: HashtagListEvent? = null,
|
||||
var backupGeohashList: GeohashListEvent? = null,
|
||||
var backupEphemeralChatList: EphemeralChatListEvent? = null,
|
||||
val torSettings: TorSettingsFlow = TorSettingsFlow(),
|
||||
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
|
||||
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
|
||||
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
|
||||
) {
|
||||
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
|
||||
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
|
||||
|
||||
class AccountSettingsUpdater(
|
||||
val accountSettings: AccountSettings?,
|
||||
)
|
||||
|
||||
fun saveAccountSettings() {
|
||||
saveable.update { AccountSettingsUpdater(this) }
|
||||
}
|
||||
|
||||
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
|
||||
|
||||
fun createSigner(contentResolver: ContentResolver) =
|
||||
if (keyPair.privKey != null) {
|
||||
NostrSignerInternal(keyPair)
|
||||
} else {
|
||||
when (val packageName = externalSignerPackageName) {
|
||||
null -> NostrSignerInternal(keyPair)
|
||||
else ->
|
||||
NostrSignerExternal(
|
||||
pubKey = keyPair.pubKey.toHexKey(),
|
||||
packageName = packageName,
|
||||
contentResolver = contentResolver,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// Zaps and Reactions
|
||||
// ---
|
||||
|
||||
fun changeDefaultZapType(zapType: LnZapEvent.ZapType): Boolean {
|
||||
if (syncedSettings.zaps.defaultZapType.value != zapType) {
|
||||
syncedSettings.zaps.defaultZapType.tryEmit(zapType)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>): Boolean {
|
||||
if (syncedSettings.zaps.zapAmountChoices.value != newAmounts) {
|
||||
syncedSettings.zaps.zapAmountChoices.tryEmit(newAmounts.toImmutableList())
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeReactionTypes(newTypes: List<String>): Boolean {
|
||||
if (syncedSettings.reactions.reactionChoices.value != newTypes) {
|
||||
syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList())
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean {
|
||||
if (zapPaymentRequest.value != newServer) {
|
||||
zapPaymentRequest.tryEmit(newServer)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---
|
||||
// file servers
|
||||
// ---
|
||||
|
||||
fun changeDefaultFileServer(server: ServerName) {
|
||||
if (defaultFileServer != server) {
|
||||
defaultFileServer = server
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// list names
|
||||
// ---
|
||||
|
||||
fun changeDefaultHomeFollowList(name: FeedDefinition) {
|
||||
changeDefaultHomeFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultHomeFollowList(name: String) {
|
||||
if (defaultHomeFollowList.value != name) {
|
||||
defaultHomeFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultStoriesFollowList(name: FeedDefinition) {
|
||||
changeDefaultStoriesFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultStoriesFollowList(name: String) {
|
||||
if (defaultStoriesFollowList.value != name) {
|
||||
defaultStoriesFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultNotificationFollowList(name: FeedDefinition) {
|
||||
changeDefaultNotificationFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultNotificationFollowList(name: String) {
|
||||
if (defaultNotificationFollowList.value != name) {
|
||||
defaultNotificationFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultDiscoveryFollowList(name: FeedDefinition) {
|
||||
changeDefaultDiscoveryFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultDiscoveryFollowList(name: String) {
|
||||
if (defaultDiscoveryFollowList.value != name) {
|
||||
defaultDiscoveryFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// proxy settings
|
||||
// ---
|
||||
fun setTorSettings(newTorSettings: TorSettings): Boolean =
|
||||
if (torSettings.update(newTorSettings)) {
|
||||
saveAccountSettings()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
// ---
|
||||
// language services
|
||||
// ---
|
||||
fun toggleDontTranslateFrom(languageCode: String) {
|
||||
syncedSettings.languages.toggleDontTranslateFrom(languageCode)
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) = syncedSettings.languages.translateTo.contains(languageCode.language)
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale): Boolean {
|
||||
if (syncedSettings.languages.updateTranslateTo(languageCode)) {
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun prefer(
|
||||
source: String,
|
||||
target: String,
|
||||
preference: String,
|
||||
) {
|
||||
syncedSettings.languages.prefer(source, target, preference)
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun preferenceBetween(
|
||||
source: String,
|
||||
target: String,
|
||||
): String? = syncedSettings.languages.preferenceBetween(source, target)
|
||||
|
||||
// ----
|
||||
// Backup Lists
|
||||
// ----
|
||||
|
||||
fun updateLocalRelayServers(servers: Set<String>) {
|
||||
if (localRelayServers.value != servers) {
|
||||
localRelayServers.update { servers }
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUserMetadata(newMetadata: MetadataEvent?) {
|
||||
if (newMetadata == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupUserMetadata?.id != newMetadata.id) {
|
||||
backupUserMetadata = newMetadata
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateContactListTo(newContactList: ContactListEvent?) {
|
||||
if (newContactList == null || newContactList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupContactList?.id != newContactList.id) {
|
||||
backupContactList = newContactList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) {
|
||||
if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupDMRelayList?.id != newDMRelayList.id) {
|
||||
backupDMRelayList = newDMRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) {
|
||||
if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupNIP65RelayList?.id != newNIP65RelayList.id) {
|
||||
backupNIP65RelayList = newNIP65RelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
|
||||
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupSearchRelayList?.id != newSearchRelayList.id) {
|
||||
backupSearchRelayList = newSearchRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) {
|
||||
if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupBlockedRelayList?.id != newBlockedRelayList.id) {
|
||||
backupBlockedRelayList = newBlockedRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTrustedRelayList(newTrustedRelayList: TrustedRelayListEvent?) {
|
||||
if (newTrustedRelayList == null || newTrustedRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupTrustedRelayList?.id != newTrustedRelayList.id) {
|
||||
backupTrustedRelayList = newTrustedRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) {
|
||||
if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) {
|
||||
backupPrivateHomeRelayList = newPrivateHomeRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateChannelListTo(newChannelList: ChannelListEvent?) {
|
||||
if (newChannelList == null || newChannelList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupChannelList?.id != newChannelList.id) {
|
||||
backupChannelList = newChannelList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateGeohashListTo(newGeohashList: GeohashListEvent?) {
|
||||
if (newGeohashList == null || newGeohashList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupGeohashList?.id != newGeohashList.id) {
|
||||
backupGeohashList = newGeohashList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateHashtagListTo(newHashtagList: HashtagListEvent?) {
|
||||
if (newHashtagList == null || newHashtagList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupHashtagList?.id != newHashtagList.id) {
|
||||
backupHashtagList = newHashtagList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCommunityListTo(newCommunityList: CommunityListEvent?) {
|
||||
if (newCommunityList == null || newCommunityList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupCommunityList?.id != newCommunityList.id) {
|
||||
backupCommunityList = newCommunityList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
|
||||
if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupEphemeralChatList?.id != newEphemeralChatList.id) {
|
||||
backupEphemeralChatList = newEphemeralChatList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMuteList(newMuteList: MuteListEvent?) {
|
||||
if (newMuteList == null || newMuteList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupMuteList?.id != newMuteList.id) {
|
||||
backupMuteList = newMuteList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAppSpecificData(
|
||||
appSettings: AppSpecificDataEvent?,
|
||||
newSyncedSettings: AccountSyncedSettingsInternal,
|
||||
) {
|
||||
if (appSettings == null || appSettings.content.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupAppSpecificData?.id != appSettings.id) {
|
||||
backupAppSpecificData = appSettings
|
||||
syncedSettings.updateFrom(newSyncedSettings)
|
||||
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
// Warning dialogs
|
||||
// ----
|
||||
|
||||
fun setHideDeleteRequestDialog() {
|
||||
if (!hideDeleteRequestDialog) {
|
||||
hideDeleteRequestDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideNIP17WarningDialog() {
|
||||
if (!hideNIP17WarningDialog) {
|
||||
hideNIP17WarningDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideBlockAlertDialog() {
|
||||
if (!hideBlockAlertDialog) {
|
||||
hideBlockAlertDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// donations
|
||||
// ---
|
||||
|
||||
fun hasDonatedInVersion(versionName: String) = hasDonatedInVersion.value.contains(versionName)
|
||||
|
||||
fun observeDonatedInVersion(versionName: String) =
|
||||
hasDonatedInVersion
|
||||
.map {
|
||||
it.contains(versionName)
|
||||
}
|
||||
|
||||
fun markDonatedInThisVersion(versionName: String): Boolean {
|
||||
if (!hasDonatedInVersion.value.contains(versionName)) {
|
||||
hasDonatedInVersion.update {
|
||||
it + versionName
|
||||
}
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ----
|
||||
// last read flows
|
||||
// ----
|
||||
|
||||
fun getLastReadFlow(route: String): StateFlow<Long> = lastReadPerRoute.value[route] ?: addLastRead(route, 0)
|
||||
|
||||
private fun addLastRead(
|
||||
route: String,
|
||||
timestampInSecs: Long,
|
||||
): MutableStateFlow<Long> =
|
||||
MutableStateFlow<Long>(timestampInSecs).also { newFlow ->
|
||||
lastReadPerRoute.update { it + Pair(route, newFlow) }
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun markAsRead(
|
||||
route: String,
|
||||
timestampInSecs: Long,
|
||||
): Boolean {
|
||||
val lastTime = lastReadPerRoute.value[route]
|
||||
return if (lastTime == null) {
|
||||
addLastRead(route, timestampInSecs)
|
||||
true
|
||||
} else if (timestampInSecs > lastTime.value) {
|
||||
lastTime.tryEmit(timestampInSecs)
|
||||
saveAccountSettings()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// attestations
|
||||
// ---
|
||||
|
||||
fun addPendingAttestation(
|
||||
id: HexKey,
|
||||
stamp: String,
|
||||
) {
|
||||
val current = pendingAttestations.value.get(id)
|
||||
if (current == null) {
|
||||
pendingAttestations.update {
|
||||
it + Pair(id, stamp)
|
||||
}
|
||||
saveAccountSettings()
|
||||
} else {
|
||||
if (current != stamp) {
|
||||
pendingAttestations.update {
|
||||
it + Pair(id, stamp)
|
||||
}
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// filters
|
||||
// ---
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (syncedSettings.security.updateShowSensitiveContent(show)) {
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun updateWarnReports(warnReports: Boolean): Boolean =
|
||||
if (syncedSettings.security.updateWarnReports(warnReports)) {
|
||||
saveAccountSettings()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
fun updateFilterSpam(filterSpam: Boolean): Boolean =
|
||||
if (syncedSettings.security.updateFilterSpam(filterSpam)) {
|
||||
saveAccountSettings()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Locale
|
||||
|
||||
@Stable
|
||||
class AccountSyncedSettings(
|
||||
internalSettings: AccountSyncedSettingsInternal,
|
||||
) {
|
||||
val reactions = AccountReactionPreferences(MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()))
|
||||
val zaps =
|
||||
AccountZapPreferences(
|
||||
MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()),
|
||||
MutableStateFlow(internalSettings.zaps.defaultZapType),
|
||||
)
|
||||
val languages =
|
||||
AccountLanguagePreferences(
|
||||
internalSettings.languages.dontTranslateFrom,
|
||||
internalSettings.languages.languagePreferences,
|
||||
internalSettings.languages.translateTo,
|
||||
)
|
||||
val security =
|
||||
AccountSecurityPreferences(
|
||||
MutableStateFlow(internalSettings.security.showSensitiveContent),
|
||||
internalSettings.security.warnAboutPostsWithReports,
|
||||
MutableStateFlow(internalSettings.security.filterSpamFromStrangers),
|
||||
)
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
AccountSyncedSettingsInternal(
|
||||
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value),
|
||||
zaps =
|
||||
AccountZapPreferencesInternal(
|
||||
zaps.zapAmountChoices.value,
|
||||
zaps.defaultZapType.value,
|
||||
),
|
||||
languages =
|
||||
AccountLanguagePreferencesInternal(
|
||||
languages.dontTranslateFrom,
|
||||
languages.languagePreferences,
|
||||
languages.translateTo,
|
||||
),
|
||||
security =
|
||||
AccountSecurityPreferencesInternal(
|
||||
security.showSensitiveContent.value,
|
||||
security.warnAboutPostsWithReports,
|
||||
security.filterSpamFromStrangers.value,
|
||||
),
|
||||
)
|
||||
|
||||
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
||||
val newReactionChoices = syncedSettingsInternal.reactions.reactionChoices.toImmutableList()
|
||||
if (!equalImmutableLists(reactions.reactionChoices.value, newReactionChoices)) {
|
||||
reactions.reactionChoices.tryEmit(newReactionChoices)
|
||||
}
|
||||
|
||||
val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList()
|
||||
if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) {
|
||||
zaps.zapAmountChoices.tryEmit(newZapChoices)
|
||||
}
|
||||
|
||||
if (zaps.defaultZapType.value != syncedSettingsInternal.zaps.defaultZapType) {
|
||||
zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType)
|
||||
}
|
||||
|
||||
if (languages.dontTranslateFrom != syncedSettingsInternal.languages.dontTranslateFrom) {
|
||||
languages.dontTranslateFrom = syncedSettingsInternal.languages.dontTranslateFrom
|
||||
}
|
||||
|
||||
if (languages.languagePreferences != syncedSettingsInternal.languages.languagePreferences) {
|
||||
languages.languagePreferences = syncedSettingsInternal.languages.languagePreferences
|
||||
}
|
||||
|
||||
if (languages.translateTo != syncedSettingsInternal.languages.translateTo) {
|
||||
languages.translateTo = syncedSettingsInternal.languages.translateTo
|
||||
}
|
||||
|
||||
if (security.showSensitiveContent.value != syncedSettingsInternal.security.showSensitiveContent) {
|
||||
security.showSensitiveContent.tryEmit(syncedSettingsInternal.security.showSensitiveContent)
|
||||
}
|
||||
|
||||
if (security.filterSpamFromStrangers.value != syncedSettingsInternal.security.filterSpamFromStrangers) {
|
||||
security.filterSpamFromStrangers.tryEmit(syncedSettingsInternal.security.filterSpamFromStrangers)
|
||||
}
|
||||
|
||||
if (security.warnAboutPostsWithReports != syncedSettingsInternal.security.warnAboutPostsWithReports) {
|
||||
security.warnAboutPostsWithReports = syncedSettingsInternal.security.warnAboutPostsWithReports
|
||||
}
|
||||
}
|
||||
|
||||
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom - getLanguagesSpokenByUser()
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AccountReactionPreferences(
|
||||
var reactionChoices: MutableStateFlow<ImmutableList<String>>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class AccountZapPreferences(
|
||||
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
|
||||
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class AccountLanguagePreferences(
|
||||
var dontTranslateFrom: Set<String>,
|
||||
var languagePreferences: Map<String, String>,
|
||||
var translateTo: String,
|
||||
) {
|
||||
// ---
|
||||
// language services
|
||||
// ---
|
||||
fun toggleDontTranslateFrom(languageCode: String) {
|
||||
dontTranslateFrom =
|
||||
if (!dontTranslateFrom.contains(languageCode)) {
|
||||
dontTranslateFrom.plus(languageCode)
|
||||
} else {
|
||||
dontTranslateFrom.minus(languageCode)
|
||||
}
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale): Boolean {
|
||||
if (translateTo != languageCode.language) {
|
||||
translateTo = languageCode.language
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun prefer(
|
||||
source: String,
|
||||
target: String,
|
||||
preference: String,
|
||||
) {
|
||||
val key = "$source,$target"
|
||||
if (key !in languagePreferences) {
|
||||
languagePreferences = languagePreferences + Pair(key, preference)
|
||||
} else {
|
||||
if (languagePreferences.get(key) == preference) {
|
||||
languagePreferences = languagePreferences.minus(key)
|
||||
} else {
|
||||
languagePreferences = languagePreferences + Pair(key, preference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun preferenceBetween(
|
||||
source: String,
|
||||
target: String,
|
||||
): String? = languagePreferences["$source,$target"]
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AccountSecurityPreferences(
|
||||
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
) {
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (showSensitiveContent.value != show) {
|
||||
showSensitiveContent.update { show }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun updateWarnReports(warnReports: Boolean): Boolean =
|
||||
if (warnAboutPostsWithReports != warnReports) {
|
||||
warnAboutPostsWithReports = warnReports
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
fun updateFilterSpam(filterSpam: Boolean): Boolean =
|
||||
if (filterSpam != filterSpamFromStrangers.value) {
|
||||
filterSpamFromStrangers.tryEmit(filterSpam)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultReactions =
|
||||
listOf(
|
||||
"\uD83D\uDE80",
|
||||
"\uD83E\uDEC2",
|
||||
"\uD83D\uDC40",
|
||||
"\uD83D\uDE02",
|
||||
"\uD83C\uDF89",
|
||||
"\uD83E\uDD14",
|
||||
"\uD83D\uDE31",
|
||||
)
|
||||
|
||||
val DefaultZapAmounts = listOf(100L, 500L, 1000L)
|
||||
|
||||
fun getLanguagesSpokenByUser(): Set<String> {
|
||||
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||
val codedList = mutableSetOf<String>()
|
||||
for (i in 0 until languageList.size()) {
|
||||
languageList.get(i)?.let { codedList.add(it.language) }
|
||||
}
|
||||
return codedList
|
||||
}
|
||||
|
||||
class AccountSyncedSettingsInternal(
|
||||
val reactions: AccountReactionPreferencesInternal = AccountReactionPreferencesInternal(),
|
||||
val zaps: AccountZapPreferencesInternal = AccountZapPreferencesInternal(),
|
||||
val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(),
|
||||
val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(),
|
||||
)
|
||||
|
||||
class AccountReactionPreferencesInternal(
|
||||
var reactionChoices: List<String> = DefaultReactions,
|
||||
)
|
||||
|
||||
class AccountZapPreferencesInternal(
|
||||
var zapAmountChoices: List<Long> = DefaultZapAmounts,
|
||||
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
|
||||
)
|
||||
|
||||
class AccountLanguagePreferencesInternal(
|
||||
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
)
|
||||
|
||||
class AccountSecurityPreferencesInternal(
|
||||
val showSensitiveContent: Boolean? = null,
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.ui.note.njumpLink
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
data class Spammer(
|
||||
val pubkeyHex: HexKey,
|
||||
var duplicatedEventIds: Set<HexKey>,
|
||||
var duplicatedEventAddresses: Set<Address>,
|
||||
) {
|
||||
fun shouldHide() = duplicatedEventIds.size >= 5 || duplicatedEventAddresses.size >= 5
|
||||
}
|
||||
|
||||
class AntiSpamFilter {
|
||||
val recentEventIds = LruCache<Int, String>(2000)
|
||||
val recentAddressables = LruCache<Int, Address>(2000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
var active: Boolean = true
|
||||
|
||||
fun spamHashCode(event: Event): Int = 31 * event.content.hashCode() + event.tags.contentDeepHashCode()
|
||||
|
||||
fun isSpam(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
): Boolean {
|
||||
if (!active) return false
|
||||
|
||||
// if short message, ok
|
||||
// The idea here is to avoid considering repeated "GM" messages spam.
|
||||
if (event.content.length < 60) return false
|
||||
|
||||
// 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 && event.content.startsWith("nostr:") && Nip19Parser.nip19regex.matcher(event.content).find()) return false
|
||||
|
||||
// double list strategy:
|
||||
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
|
||||
|
||||
// Considers tags so that same replies to different people don't count.
|
||||
val hash = spamHashCode(event)
|
||||
|
||||
// ignores multiple versions of the same addressable.
|
||||
if (event is AddressableEvent) {
|
||||
val address = event.address()
|
||||
|
||||
// normal event
|
||||
if (
|
||||
(recentAddressables[hash] != null && recentAddressables[hash] != address) ||
|
||||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventAddresses.contains(address))
|
||||
) {
|
||||
val existingAddress = recentAddressables[hash]
|
||||
|
||||
val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay))
|
||||
val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
|
||||
|
||||
Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2")
|
||||
|
||||
// Log down offenders
|
||||
val spammer = logOffender(hash, event)
|
||||
|
||||
if (spammer.shouldHide() && relay != null) {
|
||||
RelayStats.newSpam(relay, "$link1 $link2")
|
||||
}
|
||||
|
||||
flowSpam.tryEmit(AntiSpamState(this))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
recentAddressables.put(hash, address)
|
||||
} else {
|
||||
// normal event
|
||||
if (
|
||||
(recentEventIds[hash] != null && recentEventIds[hash] != event.id) ||
|
||||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id))
|
||||
) {
|
||||
val existingEvent = recentEventIds[hash]
|
||||
|
||||
val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay))
|
||||
val link2 = njumpLink(NEvent.create(event.id, null, null, relay))
|
||||
|
||||
Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2")
|
||||
|
||||
// Log down offenders
|
||||
val spammer = logOffender(hash, event)
|
||||
|
||||
if (spammer.shouldHide() && relay != null) {
|
||||
RelayStats.newSpam(relay, "$link1 $link2")
|
||||
}
|
||||
|
||||
flowSpam.tryEmit(AntiSpamState(this))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
recentEventIds.put(hash, event.id)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun logOffender(
|
||||
hashCode: Int,
|
||||
event: Event,
|
||||
): Spammer {
|
||||
val spammer = spamMessages.get(hashCode)
|
||||
|
||||
if (spammer == null) {
|
||||
val newSpammer =
|
||||
if (event is AddressableEvent) {
|
||||
Spammer(
|
||||
pubkeyHex = event.pubKey,
|
||||
duplicatedEventIds = setOf(),
|
||||
duplicatedEventAddresses = setOf(recentAddressables[hashCode], event.address()),
|
||||
)
|
||||
} else {
|
||||
Spammer(
|
||||
pubkeyHex = event.pubKey,
|
||||
duplicatedEventIds = setOf(recentEventIds[hashCode], event.id),
|
||||
duplicatedEventAddresses = setOf(),
|
||||
)
|
||||
}
|
||||
spamMessages.put(hashCode, newSpammer)
|
||||
return newSpammer
|
||||
} else {
|
||||
if (event is AddressableEvent) {
|
||||
spammer.duplicatedEventAddresses += event.address()
|
||||
} else {
|
||||
spammer.duplicatedEventIds += event.id
|
||||
}
|
||||
return spammer
|
||||
}
|
||||
}
|
||||
|
||||
val flowSpam = MutableStateFlow<AntiSpamState>(AntiSpamState(this))
|
||||
}
|
||||
|
||||
class AntiSpamState(
|
||||
val cache: AntiSpamFilter,
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.dal.ListChange
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.LargeCache
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
@Stable
|
||||
abstract class Channel : NotesGatherer {
|
||||
val notes = LargeCache<HexKey, Note>()
|
||||
var lastNote: Note? = null
|
||||
|
||||
private var relays = mapOf<NormalizedRelayUrl, Counter>()
|
||||
|
||||
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
|
||||
|
||||
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
|
||||
val current = changesFlow.get()
|
||||
if (current != null) return current
|
||||
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
|
||||
changesFlow = WeakReference(new)
|
||||
return new
|
||||
}
|
||||
|
||||
open fun participatingAuthors() = notes.mapNotNull { key, value -> value.author }
|
||||
|
||||
abstract fun toBestDisplayName(): String
|
||||
|
||||
open fun relays(): Set<NormalizedRelayUrl> =
|
||||
relays.keys
|
||||
.toSortedSet { o1, o2 ->
|
||||
val o1Count = relays[o1]?.number ?: 0
|
||||
val o2Count = relays[o2]?.number ?: 0
|
||||
o2Count.compareTo(o1Count) // descending
|
||||
}
|
||||
|
||||
fun updateChannelInfo() {
|
||||
flowSet?.metadata?.invalidateData()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addRelaySync(briefInfo: NormalizedRelayUrl) {
|
||||
if (briefInfo !in relays) {
|
||||
relays = relays + Pair(briefInfo, Counter(1))
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: NormalizedRelayUrl) {
|
||||
val counter = relays[relay]
|
||||
if (counter != null) {
|
||||
counter.number++
|
||||
} else {
|
||||
addRelaySync(relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun addNote(
|
||||
note: Note,
|
||||
relay: NormalizedRelayUrl? = null,
|
||||
) {
|
||||
if (!notes.containsKey(note.idHex)) {
|
||||
notes.put(note.idHex, note)
|
||||
note.addGatherer(this)
|
||||
|
||||
if ((note.createdAt() ?: 0) > (lastNote?.createdAt() ?: 0)) {
|
||||
lastNote = note
|
||||
}
|
||||
|
||||
if (relay != null) {
|
||||
addRelay(relay)
|
||||
}
|
||||
|
||||
changesFlow.get()?.tryEmit(ListChange.Addition(note))
|
||||
|
||||
flowSet?.notes?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeNote(note: Note) {
|
||||
if (notes.containsKey(note.idHex)) {
|
||||
notes.remove(note.idHex)
|
||||
note.removeGatherer(this)
|
||||
|
||||
if (note == lastNote) {
|
||||
lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull()
|
||||
}
|
||||
|
||||
changesFlow.get()?.tryEmit(ListChange.Deletion(note))
|
||||
|
||||
flowSet?.notes?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneOldMessages(): Set<Note> {
|
||||
val important =
|
||||
notes
|
||||
.values()
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
.take(500)
|
||||
.toSet()
|
||||
|
||||
val toBeRemoved = notes.filter { key, it -> it !in important }
|
||||
|
||||
toBeRemoved.forEach { notes.remove(it.idHex) }
|
||||
|
||||
changesFlow.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
|
||||
|
||||
flowSet?.notes?.invalidateData()
|
||||
|
||||
return toBeRemoved.toSet()
|
||||
}
|
||||
|
||||
fun pruneHiddenMessages(account: Account): Set<Note> {
|
||||
val hidden =
|
||||
notes
|
||||
.filter { key, it ->
|
||||
it.author?.let { author -> account.isHidden(author) } == true
|
||||
}.toSet()
|
||||
|
||||
hidden.forEach { notes.remove(it.idHex) }
|
||||
|
||||
changesFlow.get()?.tryEmit(ListChange.SetDeletion(hidden))
|
||||
|
||||
flowSet?.notes?.invalidateData()
|
||||
|
||||
return hidden.toSet()
|
||||
}
|
||||
|
||||
var flowSet: ChannelFlowSet? = null
|
||||
|
||||
@Synchronized
|
||||
fun createOrDestroyFlowSync(create: Boolean) {
|
||||
if (create) {
|
||||
if (flowSet == null) {
|
||||
flowSet = ChannelFlowSet(this)
|
||||
}
|
||||
} else {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
flowSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun flow(): ChannelFlowSet {
|
||||
if (flowSet == null) {
|
||||
createOrDestroyFlowSync(true)
|
||||
}
|
||||
return flowSet!!
|
||||
}
|
||||
|
||||
fun clearFlow() {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
createOrDestroyFlowSync(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Counter(
|
||||
var number: Int = 0,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class ChannelFlowSet(
|
||||
u: Channel,
|
||||
) {
|
||||
// Observers line up here.
|
||||
val metadata = ChannelFlow(u)
|
||||
val notes = ChannelFlow(u)
|
||||
|
||||
fun isInUse(): Boolean =
|
||||
metadata.hasObservers() ||
|
||||
notes.hasObservers()
|
||||
}
|
||||
|
||||
class ChannelFlow(
|
||||
val channel: Channel,
|
||||
) {
|
||||
val stateFlow = MutableStateFlow(ChannelState(channel))
|
||||
|
||||
fun invalidateData() {
|
||||
stateFlow.tryEmit(ChannelState(channel))
|
||||
}
|
||||
|
||||
fun hasObservers() = stateFlow.subscriptionCount.value > 0
|
||||
}
|
||||
|
||||
class ChannelState(
|
||||
val channel: Channel,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
object Constants {
|
||||
val nos = RelayUrlNormalizer.normalize("wss://nos.lol")
|
||||
val mom = RelayUrlNormalizer.normalize("wss://nostr.mom")
|
||||
val primal = RelayUrlNormalizer.normalize("wss://relay.primal.net")
|
||||
val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io")
|
||||
val wine = RelayUrlNormalizer.normalize("wss://nostr.wine")
|
||||
val band = RelayUrlNormalizer.normalize("wss://relay.nostr.band")
|
||||
|
||||
val where = RelayUrlNormalizer.normalize("wss://relay.noswhere.com")
|
||||
val elites = RelayUrlNormalizer.normalize("wss://nostrelites.org")
|
||||
|
||||
val bitcoiner = RelayUrlNormalizer.normalize("wss://nostr.bitcoiner.social")
|
||||
val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev")
|
||||
val fmtwiz = RelayUrlNormalizer.normalize("wss://nostr.fmt.wiz.biz")
|
||||
|
||||
val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today")
|
||||
|
||||
val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com")
|
||||
val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com")
|
||||
|
||||
val purplepages = RelayUrlNormalizer.normalize("wss://purplepag.es")
|
||||
val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social")
|
||||
val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es")
|
||||
|
||||
val bootstrapInbox = setOf(band, damus, primal, mom, nos, bitcoiner, oxtr, fmtwiz)
|
||||
val eventFinderRelays = setOf(band, wine, damus, primal, mom, nos, bitcoiner, oxtr, fmtwiz)
|
||||
|
||||
val defaultSearchRelaySet = setOf(band, wine, where)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
@@ -33,7 +33,9 @@ import com.vitorpamplona.amethyst.commons.hashtags.Btc
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Coffee
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Flowerstr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Footstr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Gamestr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Mate
|
||||
@@ -47,29 +49,30 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.ui.components.HashTag
|
||||
import com.vitorpamplona.amethyst.ui.components.RenderRegular
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderHashTagIcons() {
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
fun RenderHashTagIconsPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
ThemeComparisonColumn {
|
||||
RenderRegular(
|
||||
"Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate",
|
||||
"Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain",
|
||||
EmptyTagList,
|
||||
) { word, state ->
|
||||
when (word) {
|
||||
is HashTagSegment -> HashTag(word, nav)
|
||||
is HashTagSegment -> HashTag(word, accountViewModel, EmptyNav)
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? =
|
||||
when (tag.lowercase()) {
|
||||
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
|
||||
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
|
||||
"lightning", "lightningnetwork" -> lightning
|
||||
@@ -81,12 +84,13 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
"skullofsatoshi" -> skull
|
||||
"grownostr", "gardening", "garden" -> growstr
|
||||
"footstr" -> footstr
|
||||
"flowerstr" -> flowerstr
|
||||
"tunestr", "music", "nowplaying" -> tunestr
|
||||
"mate", "matechain", "matestr" -> matestr
|
||||
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
|
||||
"gamestr", "gaming", "gamechain" -> gamestr
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val bitcoin = HashtagIcon(CustomHashTagIcons.Btc, "Bitcoin", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val nostr = HashtagIcon(CustomHashTagIcons.Nostr, "Nostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
@@ -99,9 +103,11 @@ val coffee = HashtagIcon(CustomHashTagIcons.Coffee, "Coffee", Modifier.padding(s
|
||||
val skull = HashtagIcon(CustomHashTagIcons.Skull, "SkullofSatoshi", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val growstr = HashtagIcon(CustomHashTagIcons.Grownostr, "GrowNostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
|
||||
val flowerstr = HashtagIcon(CustomHashTagIcons.Flowerstr, "Flowerstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
|
||||
val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
|
||||
val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
|
||||
val gamestr = HashtagIcon(CustomHashTagIcons.Gamestr, "GameStr", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
|
||||
|
||||
@Immutable
|
||||
class HashtagIcon(
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
|
||||
interface MutableMediaAspectRatioCache {
|
||||
fun get(url: String): Float?
|
||||
|
||||
fun add(
|
||||
url: String,
|
||||
width: Int,
|
||||
height: Int,
|
||||
)
|
||||
}
|
||||
|
||||
object MediaAspectRatioCache : MutableMediaAspectRatioCache {
|
||||
val mediaAspectRatioCacheByUrl = LruCache<String, Float>(1000)
|
||||
|
||||
override fun get(url: String): Float? = mediaAspectRatioCacheByUrl.get(url)
|
||||
|
||||
override fun add(
|
||||
url: String,
|
||||
width: Int,
|
||||
height: Int,
|
||||
) {
|
||||
if (height > 1) {
|
||||
mediaAspectRatioCacheByUrl.put(url, width.toFloat() / height.toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
@@ -40,11 +41,13 @@ import kotlinx.collections.immutable.toImmutableMap
|
||||
JsonSubTypes.Type(value = Bundle::class, name = "Bundle"),
|
||||
JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"),
|
||||
)
|
||||
@Stable
|
||||
open class Resource(
|
||||
var resourceType: String? = null,
|
||||
var id: String = "",
|
||||
)
|
||||
|
||||
@Stable
|
||||
class Practitioner(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -53,6 +56,7 @@ class Practitioner(
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class Patient(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -61,16 +65,16 @@ class Patient(
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class HumanName(
|
||||
var use: String? = null,
|
||||
var family: String? = null,
|
||||
var given: ArrayList<String> = arrayListOf(),
|
||||
) {
|
||||
fun assembleName(): String {
|
||||
return given.joinToString(" ") + " " + family
|
||||
}
|
||||
fun assembleName(): String = given.joinToString(" ") + " " + family
|
||||
}
|
||||
|
||||
@Stable
|
||||
class Bundle(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -79,6 +83,7 @@ class Bundle(
|
||||
var entry: List<Resource> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class VisionPrescription(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -89,15 +94,31 @@ class VisionPrescription(
|
||||
var dateWritten: String? = null,
|
||||
var prescriber: Reference? = Reference(),
|
||||
var lensSpecification: List<LensSpecification> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
) : Resource(resourceType, id) {
|
||||
fun glasses() = lensSpecification.filter { it.product == "lens" }
|
||||
|
||||
fun contacts() = lensSpecification.filter { it.product == "contacts" }
|
||||
|
||||
fun glassesRightEyes() = lensSpecification.filter { it.product == "lens" && it.eye == "right" }
|
||||
|
||||
fun glassesLeftEyes() = lensSpecification.filter { it.product == "lens" && it.eye == "left" }
|
||||
|
||||
fun contactsRightEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "right" }
|
||||
|
||||
fun contactsLeftEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "left" }
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LensSpecification(
|
||||
var product: String? = null,
|
||||
var eye: String? = null,
|
||||
var sphere: Double? = null,
|
||||
var cylinder: Double? = null,
|
||||
var axis: Double? = null,
|
||||
var pd: Double? = null,
|
||||
var interAdd: Double? = null,
|
||||
var add: Double? = null,
|
||||
var prism: List<Prism> = arrayListOf(),
|
||||
var prism: Prism? = null,
|
||||
// contact lenses
|
||||
var power: Double? = null,
|
||||
var backCurve: Double? = null,
|
||||
@@ -107,6 +128,7 @@ class LensSpecification(
|
||||
var note: String? = null,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class Prism(
|
||||
var amount: Double? = null,
|
||||
var base: String? = null,
|
||||
1013
amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
Normal file
1013
amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
class ParticipantListBuilder {
|
||||
private fun addFollowsThatDirectlyParticipateOnToSet(
|
||||
@@ -96,7 +96,7 @@ class ParticipantListBuilder {
|
||||
it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
|
||||
}
|
||||
|
||||
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it ->
|
||||
LocalCache.getPublicChatChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it ->
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@Stable
|
||||
data class Settings(
|
||||
val theme: ThemeType = ThemeType.SYSTEM,
|
||||
val preferredLanguage: String? = null,
|
||||
val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS,
|
||||
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val dontShowPushNotificationSelector: Boolean = false,
|
||||
val dontAskForNotificationPermissions: Boolean = false,
|
||||
val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED,
|
||||
val gallerySet: ProfileGalleryType = ProfileGalleryType.CLASSIC,
|
||||
)
|
||||
|
||||
enum class ThemeType(
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
) {
|
||||
SYSTEM(0, R.string.system),
|
||||
LIGHT(1, R.string.light),
|
||||
DARK(2, R.string.dark),
|
||||
}
|
||||
|
||||
fun parseThemeType(code: Int?): ThemeType =
|
||||
when (code) {
|
||||
ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM
|
||||
ThemeType.LIGHT.screenCode -> ThemeType.LIGHT
|
||||
ThemeType.DARK.screenCode -> ThemeType.DARK
|
||||
else -> {
|
||||
ThemeType.SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
enum class ConnectivityType(
|
||||
val prefCode: Boolean?,
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
) {
|
||||
ALWAYS(null, 0, R.string.connectivity_type_always),
|
||||
WIFI_ONLY(true, 1, R.string.connectivity_type_unmetered_wifi_only),
|
||||
NEVER(false, 2, R.string.connectivity_type_never),
|
||||
}
|
||||
|
||||
enum class FeatureSetType(
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
) {
|
||||
COMPLETE(0, R.string.ui_feature_set_type_complete),
|
||||
SIMPLIFIED(1, R.string.ui_feature_set_type_simplified),
|
||||
PERFORMANCE(2, R.string.ui_feature_set_type_performance),
|
||||
}
|
||||
|
||||
enum class ProfileGalleryType(
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
) {
|
||||
CLASSIC(0, R.string.gallery_type_classic),
|
||||
MODERN(1, R.string.gallery_type_modern),
|
||||
}
|
||||
|
||||
fun parseConnectivityType(code: Boolean?): ConnectivityType =
|
||||
when (code) {
|
||||
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
|
||||
ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY
|
||||
ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER
|
||||
else -> {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
}
|
||||
|
||||
fun parseConnectivityType(screenCode: Int): ConnectivityType =
|
||||
when (screenCode) {
|
||||
ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS
|
||||
ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY
|
||||
ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER
|
||||
else -> {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
}
|
||||
|
||||
fun parseFeatureSetType(screenCode: Int): FeatureSetType =
|
||||
when (screenCode) {
|
||||
FeatureSetType.COMPLETE.screenCode -> FeatureSetType.COMPLETE
|
||||
FeatureSetType.SIMPLIFIED.screenCode -> FeatureSetType.SIMPLIFIED
|
||||
FeatureSetType.PERFORMANCE.screenCode -> FeatureSetType.PERFORMANCE
|
||||
else -> {
|
||||
FeatureSetType.COMPLETE
|
||||
}
|
||||
}
|
||||
|
||||
fun parseGalleryType(screenCode: Int): ProfileGalleryType =
|
||||
when (screenCode) {
|
||||
ProfileGalleryType.CLASSIC.screenCode -> ProfileGalleryType.CLASSIC
|
||||
ProfileGalleryType.MODERN.screenCode -> ProfileGalleryType.MODERN
|
||||
else -> {
|
||||
ProfileGalleryType.CLASSIC
|
||||
}
|
||||
}
|
||||
|
||||
enum class BooleanType(
|
||||
val prefCode: Boolean?,
|
||||
val screenCode: Int,
|
||||
val reourceId: Int,
|
||||
) {
|
||||
ALWAYS(null, 0, R.string.connectivity_type_always),
|
||||
NEVER(false, 1, R.string.connectivity_type_never),
|
||||
}
|
||||
|
||||
fun parseBooleanType(code: Boolean?): BooleanType =
|
||||
when (code) {
|
||||
BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS
|
||||
BooleanType.NEVER.prefCode -> BooleanType.NEVER
|
||||
else -> {
|
||||
BooleanType.ALWAYS
|
||||
}
|
||||
}
|
||||
|
||||
fun parseBooleanType(screenCode: Int): BooleanType =
|
||||
when (screenCode) {
|
||||
BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS
|
||||
BooleanType.NEVER.screenCode -> BooleanType.NEVER
|
||||
else -> {
|
||||
BooleanType.ALWAYS
|
||||
}
|
||||
}
|
||||
|
||||
enum class WarningType(
|
||||
val prefCode: Boolean?,
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
) {
|
||||
WARN(null, 0, R.string.content_warning_see_warnings_option),
|
||||
SHOW(true, 1, R.string.content_warning_show_all_sensitive_content_option),
|
||||
HIDE(false, 2, R.string.content_warning_hide_all_sensitive_content_option),
|
||||
}
|
||||
|
||||
fun parseWarningType(screenCode: Int): WarningType =
|
||||
when (screenCode) {
|
||||
WarningType.WARN.screenCode -> WarningType.WARN
|
||||
WarningType.SHOW.screenCode -> WarningType.SHOW
|
||||
WarningType.HIDE.screenCode -> WarningType.HIDE
|
||||
else -> {
|
||||
WarningType.WARN
|
||||
}
|
||||
}
|
||||
|
||||
fun parseWarningType(code: Boolean?): WarningType =
|
||||
when (code) {
|
||||
WarningType.WARN.prefCode -> WarningType.WARN
|
||||
WarningType.HIDE.prefCode -> WarningType.HIDE
|
||||
WarningType.SHOW.prefCode -> WarningType.SHOW
|
||||
else -> {
|
||||
WarningType.WARN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
class ThreadAssembler {
|
||||
private fun searchRoot(
|
||||
note: Note,
|
||||
testedNotes: MutableSet<Note> = mutableSetOf(),
|
||||
): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
|
||||
val noteEvent = note.event
|
||||
|
||||
if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) return note
|
||||
|
||||
testedNotes.add(note)
|
||||
|
||||
val markedAsRoot =
|
||||
noteEvent
|
||||
?.tags
|
||||
?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }
|
||||
?.getOrNull(1)
|
||||
if (markedAsRoot != null) {
|
||||
// Check to ssee if there is an error in the tag and the root has replies
|
||||
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) {
|
||||
return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
}
|
||||
}
|
||||
|
||||
val hasNoReplyTo =
|
||||
note.replyTo?.lastOrNull {
|
||||
it.replyTo?.isEmpty() == true
|
||||
}
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
|
||||
// recursive
|
||||
val roots =
|
||||
note.replyTo?.mapNotNull {
|
||||
if (it !in testedNotes) {
|
||||
searchRoot(it, testedNotes)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (roots != null && roots.isNotEmpty()) {
|
||||
return roots[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@Stable
|
||||
class ThreadInfo(
|
||||
val root: Note,
|
||||
val allNotes: ImmutableSet<Note>,
|
||||
)
|
||||
|
||||
fun findRoot(noteId: String): Note? {
|
||||
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null
|
||||
|
||||
return if (note.event != null) {
|
||||
val thread = OnlyLatestVersionSet()
|
||||
|
||||
searchRoot(note, thread) ?: note
|
||||
} else {
|
||||
note
|
||||
}
|
||||
}
|
||||
|
||||
fun findThreadFor(noteId: String): ThreadInfo? {
|
||||
checkNotInMainThread()
|
||||
|
||||
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null
|
||||
|
||||
return if (note.event != null) {
|
||||
val thread = OnlyLatestVersionSet()
|
||||
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
|
||||
loadUp(note, 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) }
|
||||
|
||||
ThreadInfo(
|
||||
root = note,
|
||||
allNotes = thread.toImmutableSet(),
|
||||
)
|
||||
} else {
|
||||
ThreadInfo(
|
||||
root = note,
|
||||
allNotes = setOf(note).toImmutableSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUp(
|
||||
note: Note,
|
||||
thread: MutableSet<Note>,
|
||||
) {
|
||||
if (note !in thread) {
|
||||
thread.add(note)
|
||||
|
||||
note.replyTo?.forEach { loadUp(it, thread) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadDown(
|
||||
note: Note,
|
||||
thread: MutableSet<Note>,
|
||||
) {
|
||||
if (note !in thread) {
|
||||
thread.add(note)
|
||||
|
||||
note.replies.forEach { loadDown(it, thread) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OnlyLatestVersionSet : MutableSet<Note> {
|
||||
val map = hashMapOf<Address, Long>()
|
||||
val set = hashSetOf<Note>()
|
||||
|
||||
override fun add(element: Note): Boolean {
|
||||
val loadedCreatedAt = element.createdAt()
|
||||
val noteEvent = element.event
|
||||
|
||||
return if (element is AddressableNote && loadedCreatedAt != null) {
|
||||
innerAdd(element.address, element, loadedCreatedAt)
|
||||
} else if (noteEvent is AddressableEvent && loadedCreatedAt != null) {
|
||||
innerAdd(noteEvent.address(), element, loadedCreatedAt)
|
||||
} else {
|
||||
set.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
private fun innerAdd(
|
||||
address: Address,
|
||||
element: Note,
|
||||
loadedCreatedAt: Long,
|
||||
): Boolean {
|
||||
val existing = map.get(address)
|
||||
return if (existing == null) {
|
||||
map.put(address, loadedCreatedAt)
|
||||
set.add(element)
|
||||
} else {
|
||||
if (loadedCreatedAt > existing) {
|
||||
map.put(address, loadedCreatedAt)
|
||||
set.add(element)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun addAll(elements: Collection<Note>): Boolean = elements.map { add(it) }.any()
|
||||
|
||||
override val size: Int
|
||||
get() = set.size
|
||||
|
||||
override fun clear() {
|
||||
set.clear()
|
||||
map.clear()
|
||||
}
|
||||
|
||||
override fun isEmpty(): Boolean = set.isEmpty()
|
||||
|
||||
override fun containsAll(elements: Collection<Note>): Boolean = set.containsAll(elements)
|
||||
|
||||
override fun contains(element: Note): Boolean = set.contains(element)
|
||||
|
||||
override fun iterator(): MutableIterator<Note> = set.iterator()
|
||||
|
||||
override fun retainAll(elements: Collection<Note>): Boolean = set.retainAll(elements)
|
||||
|
||||
override fun removeAll(elements: Collection<Note>): Boolean = elements.map { remove(it) }.any()
|
||||
|
||||
override fun remove(element: Note): Boolean {
|
||||
element.address()?.let {
|
||||
map.remove(it)
|
||||
}
|
||||
(element.event as? AddressableEvent)?.address()?.let {
|
||||
map.remove(it)
|
||||
}
|
||||
|
||||
return set.remove(element)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import java.lang.Long.min
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
data class LevelSignature(
|
||||
val signature: String,
|
||||
val createdAt: Long?,
|
||||
val author: User?,
|
||||
)
|
||||
|
||||
object ThreadLevelCalculator {
|
||||
val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
|
||||
|
||||
private fun formattedDateTime(timestamp: Long): String =
|
||||
Instant
|
||||
.ofEpochSecond(timestamp)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.format(levelFormatter)
|
||||
|
||||
/**
|
||||
* This method caches signatures during each execution to avoid recalculation in longer threads
|
||||
*/
|
||||
fun replyLevelSignature(
|
||||
note: Note,
|
||||
eventsToConsider: Set<HexKey>,
|
||||
cachedSignatures: MutableMap<Note, LevelSignature>,
|
||||
account: User,
|
||||
accountFollowingSet: Set<String>,
|
||||
now: Long,
|
||||
): LevelSignature {
|
||||
val replyTo = note.replyTo
|
||||
|
||||
// estimates the min date by replies if it doesn't exist.
|
||||
val createdAt =
|
||||
note.createdAt() ?: min(
|
||||
note.replies.minOfOrNull { it.createdAt() ?: now } ?: now,
|
||||
note.reactions.values.minOfOrNull { it.minOfOrNull { it.createdAt() ?: now } ?: now } ?: now,
|
||||
)
|
||||
|
||||
val noteAuthor = note.author
|
||||
|
||||
if (
|
||||
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
) {
|
||||
return LevelSignature(
|
||||
signature = "/" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) + ";",
|
||||
createdAt = createdAt,
|
||||
author = noteAuthor,
|
||||
)
|
||||
}
|
||||
|
||||
val parent =
|
||||
(
|
||||
replyTo
|
||||
.filter {
|
||||
it.idHex in eventsToConsider
|
||||
} // This forces the signature to be based on a branch, avoiding two roots
|
||||
.map {
|
||||
cachedSignatures[it]
|
||||
?: replyLevelSignature(
|
||||
it,
|
||||
eventsToConsider,
|
||||
cachedSignatures,
|
||||
account,
|
||||
accountFollowingSet,
|
||||
now,
|
||||
).apply { cachedSignatures.put(it, this) }
|
||||
}.maxByOrNull { it.signature.length }
|
||||
)
|
||||
|
||||
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
|
||||
|
||||
val threadOrder =
|
||||
if (noteAuthor != null && parent?.author == noteAuthor) {
|
||||
// author of the thread first, in **ascending** order
|
||||
"9" + formattedDateTime((parent.createdAt ?: 0) + (now - createdAt)) + note.idHex.substring(0, 8)
|
||||
} else if (noteAuthor != null && noteAuthor.pubkeyHex == account.pubkeyHex) {
|
||||
"8" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my replies
|
||||
} else if (noteAuthor != null && noteAuthor.pubkeyHex in accountFollowingSet) {
|
||||
"7" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my follows replies.
|
||||
} else {
|
||||
"0" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // everyone else.
|
||||
}
|
||||
|
||||
val mySignature =
|
||||
LevelSignature(
|
||||
signature = "$parentSignature/$threadOrder;",
|
||||
createdAt = createdAt,
|
||||
author = note.author,
|
||||
)
|
||||
|
||||
cachedSignatures[note] = mySignature
|
||||
return mySignature
|
||||
}
|
||||
|
||||
fun replyLevel(
|
||||
note: Note,
|
||||
cachedLevels: MutableMap<Note, Int> = mutableMapOf(),
|
||||
): Int {
|
||||
val replyTo = note.replyTo
|
||||
if (
|
||||
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
) {
|
||||
cachedLevels[note] = 0
|
||||
return 0
|
||||
}
|
||||
|
||||
val thisLevel =
|
||||
replyTo.maxOf {
|
||||
cachedLevels[it] ?: replyLevel(it, cachedLevels)
|
||||
} + 1
|
||||
|
||||
cachedLevels[note] = thisLevel
|
||||
|
||||
return thisLevel
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@Stable
|
||||
object UrlCachedPreviewer {
|
||||
var cache = LruCache<String, UrlPreviewState>(100)
|
||||
private set
|
||||
|
||||
suspend fun previewInfo(
|
||||
url: String,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
onReady: suspend (UrlPreviewState) -> Unit,
|
||||
) {
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
|
||||
UrlPreview().fetch(
|
||||
url,
|
||||
okHttpClient,
|
||||
onComplete = { urlInfo ->
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return@fetch
|
||||
}
|
||||
}
|
||||
|
||||
val state =
|
||||
if (urlInfo.fetchComplete() && urlInfo.url == url) {
|
||||
UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
},
|
||||
onFailed = { throwable ->
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return@fetch
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
381
amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt
Normal file
381
amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
|
||||
import com.vitorpamplona.quartz.lightning.Lud06
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Stable
|
||||
class User(
|
||||
val pubkeyHex: String,
|
||||
val nip65RelayListNote: Note,
|
||||
val dmRelayListNote: Note,
|
||||
) {
|
||||
var info: UserMetadata? = null
|
||||
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
var latestMetadataRelay: NormalizedRelayUrl? = null
|
||||
var latestContactList: ContactListEvent? = null
|
||||
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relaysBeingUsed = mapOf<NormalizedRelayUrl, RelayInfo>()
|
||||
private set
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay()
|
||||
|
||||
fun dmInboxRelayList() = dmRelayListNote.event as? ChatMessageRelayListEvent
|
||||
|
||||
fun authorRelayList() = nip65RelayListNote.event as? AdvertisedRelayListEvent
|
||||
|
||||
fun toNProfile() = NProfile.create(pubkeyHex, relayHints())
|
||||
|
||||
fun outboxRelays() = authorRelayList()?.writeRelaysNorm()
|
||||
|
||||
fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay)
|
||||
|
||||
fun inboxRelays() = authorRelayList()?.readRelaysNorm()
|
||||
|
||||
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
|
||||
|
||||
fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: latestMetadataRelay
|
||||
|
||||
fun toPTag() = PTag(pubkeyHex, bestRelayHint())
|
||||
|
||||
fun toNostrUri() = "nostr:${toNProfile()}"
|
||||
|
||||
fun toBestShortFirstName(): String {
|
||||
val fullName = toBestDisplayName()
|
||||
|
||||
val names = fullName.split(' ')
|
||||
|
||||
val firstName =
|
||||
if (names[0].length <= 3) {
|
||||
// too short. Remove Dr.
|
||||
"${names[0]} ${names.getOrNull(1) ?: ""}"
|
||||
} else {
|
||||
names[0]
|
||||
}
|
||||
|
||||
return firstName
|
||||
}
|
||||
|
||||
fun toBestDisplayName(): String = info?.bestName() ?: pubkeyDisplayHex()
|
||||
|
||||
fun nip05(): String? = info?.nip05
|
||||
|
||||
fun profilePicture(): String? = info?.picture
|
||||
|
||||
fun updateContactList(event: ContactListEvent) {
|
||||
if (event.id == latestContactList?.id) return
|
||||
|
||||
val oldContactListEvent = latestContactList
|
||||
latestContactList = event
|
||||
|
||||
// Update following of the current user
|
||||
flowSet?.follows?.invalidateData()
|
||||
|
||||
// Update Followers of the past user list
|
||||
// Update Followers of the new contact list
|
||||
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache
|
||||
.getUserIfExists(it)
|
||||
?.flowSet
|
||||
?.followers
|
||||
?.invalidateData()
|
||||
}
|
||||
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache
|
||||
.getUserIfExists(it)
|
||||
?.flowSet
|
||||
?.followers
|
||||
?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
val author = note.author ?: return
|
||||
|
||||
val reportsBy = reports[author]
|
||||
if (reportsBy == null) {
|
||||
reports = reports + Pair(author, setOf(note))
|
||||
flowSet?.reports?.invalidateData()
|
||||
} else if (!reportsBy.contains(note)) {
|
||||
reports = reports + Pair(author, reportsBy + note)
|
||||
flowSet?.reports?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReport(deleteNote: Note) {
|
||||
val author = deleteNote.author ?: return
|
||||
|
||||
if (reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
flowSet?.reports?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addZap(
|
||||
zapRequest: Note,
|
||||
zap: Note?,
|
||||
) {
|
||||
if (zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
flowSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZap(zapRequestOrZapEvent: Note) {
|
||||
if (zaps.containsKey(zapRequestOrZapEvent)) {
|
||||
zaps = zaps.minus(zapRequestOrZapEvent)
|
||||
flowSet?.zaps?.invalidateData()
|
||||
} else if (zaps.containsValue(zapRequestOrZapEvent)) {
|
||||
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
|
||||
flowSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
var amount = BigDecimal.ZERO
|
||||
zaps.forEach {
|
||||
val itemValue = (it.value?.event as? LnZapEvent)?.amount
|
||||
if (itemValue != null) {
|
||||
amount += itemValue
|
||||
}
|
||||
}
|
||||
|
||||
return amount
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): Set<Note> = reports[user] ?: emptySet()
|
||||
|
||||
fun countReportAuthorsBy(users: Set<HexKey>): Int = reports.count { it.key.pubkeyHex in users }
|
||||
|
||||
fun reportsBy(users: Set<HexKey>): List<Note> =
|
||||
reports
|
||||
.mapNotNull {
|
||||
if (it.key.pubkeyHex in users) {
|
||||
it.value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.flatten()
|
||||
|
||||
fun addRelayBeingUsed(
|
||||
relay: NormalizedRelayUrl,
|
||||
eventTime: Long,
|
||||
) {
|
||||
val here = relaysBeingUsed[relay]
|
||||
if (here == null) {
|
||||
relaysBeingUsed = relaysBeingUsed + Pair(relay, RelayInfo(relay, eventTime, 1))
|
||||
} else {
|
||||
if (eventTime > here.lastEvent) {
|
||||
here.lastEvent = eventTime
|
||||
}
|
||||
here.counter++
|
||||
}
|
||||
|
||||
flowSet?.usedRelays?.invalidateData()
|
||||
}
|
||||
|
||||
fun updateUserInfo(
|
||||
newUserInfo: UserMetadata,
|
||||
latestMetadata: MetadataEvent,
|
||||
) {
|
||||
info = newUserInfo
|
||||
info?.tags = latestMetadata.tags.toImmutableListOfLists()
|
||||
info?.cleanBlankNames()
|
||||
|
||||
if (newUserInfo.lud16.isNullOrBlank()) {
|
||||
info?.lud06?.let {
|
||||
if (it.lowercase().startsWith("lnurl")) {
|
||||
info?.lud16 = Lud06().toLud16(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flowSet?.metadata?.invalidateData()
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
|
||||
|
||||
fun isFollowingHashtag(tag: String) = latestContactList?.isTaggedHash(tag) ?: false
|
||||
|
||||
fun isFollowingGeohash(geoTag: String) = latestContactList?.isTaggedGeoHash(geoTag) ?: false
|
||||
|
||||
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
|
||||
|
||||
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
|
||||
fun hasReport(
|
||||
loggedIn: User,
|
||||
type: ReportType,
|
||||
): Boolean =
|
||||
reports[loggedIn]?.firstOrNull {
|
||||
(it.event as? ReportEvent)?.reportedAuthor()?.any { it.type == type } ?: false
|
||||
} != null
|
||||
|
||||
fun containsAny(hiddenWordsCase: List<DualCase>): Boolean {
|
||||
if (hiddenWordsCase.isEmpty()) return false
|
||||
|
||||
if (toBestDisplayName().containsAny(hiddenWordsCase)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (profilePicture()?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (info?.banner?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (info?.about?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (info?.lud06?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (info?.lud16?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (info?.nip05?.containsAny(hiddenWordsCase) == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
|
||||
|
||||
var flowSet: UserFlowSet? = null
|
||||
|
||||
@Synchronized
|
||||
fun createOrDestroyFlowSync(create: Boolean) {
|
||||
if (create) {
|
||||
if (flowSet == null) {
|
||||
flowSet = UserFlowSet(this)
|
||||
}
|
||||
} else {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
flowSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun flow(): UserFlowSet {
|
||||
if (flowSet == null) {
|
||||
createOrDestroyFlowSync(true)
|
||||
}
|
||||
return flowSet!!
|
||||
}
|
||||
|
||||
fun clearFlow() {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
createOrDestroyFlowSync(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserFlowSet(
|
||||
u: User,
|
||||
) {
|
||||
// Observers line up here.
|
||||
val metadata = UserBundledRefresherFlow(u)
|
||||
val follows = UserBundledRefresherFlow(u)
|
||||
val followers = UserBundledRefresherFlow(u)
|
||||
val reports = UserBundledRefresherFlow(u)
|
||||
val usedRelays = UserBundledRefresherFlow(u)
|
||||
val zaps = UserBundledRefresherFlow(u)
|
||||
val statuses = UserBundledRefresherFlow(u)
|
||||
|
||||
fun isInUse(): Boolean =
|
||||
metadata.hasObservers() ||
|
||||
follows.hasObservers() ||
|
||||
followers.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
usedRelays.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
statuses.hasObservers()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class RelayInfo(
|
||||
val url: NormalizedRelayUrl,
|
||||
var lastEvent: Long,
|
||||
var counter: Long,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class UserBundledRefresherFlow(
|
||||
val user: User,
|
||||
) {
|
||||
val stateFlow = MutableStateFlow(UserState(user))
|
||||
|
||||
fun invalidateData() {
|
||||
stateFlow.tryEmit(UserState(user))
|
||||
}
|
||||
|
||||
fun hasObservers() = stateFlow.subscriptionCount.value > 0
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class UserState(
|
||||
val user: User,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.edits
|
||||
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
|
||||
|
||||
class PrivateStorageRelayListDecryptionCache(
|
||||
signer: NostrSigner,
|
||||
) : GenericRelayListCache<PrivateOutboxRelayListEvent>(signer)
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.edits
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PrivateStorageRelayListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: PrivateStorageRelayListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val privateOutboxListNote = cache.getOrCreateAddressableNote(getPrivateOutboxRelayListAddress())
|
||||
|
||||
fun getPrivateOutboxRelayListAddress() = PrivateOutboxRelayListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getPrivateOutboxRelayListFlow(): StateFlow<NoteState> = privateOutboxListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? = privateOutboxListNote.event as? PrivateOutboxRelayListEvent
|
||||
|
||||
suspend fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
|
||||
val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList
|
||||
return event?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
val flow =
|
||||
getPrivateOutboxRelayListFlow()
|
||||
.map { normalizePrivateOutboxRelayListWithBackup(it.note) }
|
||||
.onStart {
|
||||
emit(normalizePrivateOutboxRelayListWithBackup(privateOutboxListNote))
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun saveRelayList(relays: List<NormalizedRelayUrl>): PrivateOutboxRelayListEvent {
|
||||
val relayListForPrivateOutbox = getPrivateOutboxRelayList()
|
||||
|
||||
return if (relayListForPrivateOutbox != null) {
|
||||
PrivateOutboxRelayListEvent.updateRelayList(
|
||||
earlierVersion = relayListForPrivateOutbox,
|
||||
relays = relays,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
PrivateOutboxRelayListEvent.create(
|
||||
relays = relays,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupPrivateHomeRelayList?.let { event ->
|
||||
Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LocalCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
Log.d("AccountRegisterObservers", "Private Home Relay List Collector Start")
|
||||
getPrivateOutboxRelayListFlow().collect { noteState ->
|
||||
Log.d("AccountRegisterObservers", "Updating Private Home Relay List for ${signer.pubKey}")
|
||||
(noteState.note.event as? PrivateOutboxRelayListEvent)?.let {
|
||||
settings.updatePrivateHomeRelayList(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.emphChat
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
|
||||
@Stable
|
||||
class EphemeralChatChannel(
|
||||
val roomId: RoomId,
|
||||
) : Channel() {
|
||||
override fun relays() = setOf(roomId.relayUrl)
|
||||
|
||||
override fun toBestDisplayName() = roomId.toDisplayKey()
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean = roomId.id.contains(prefix, true)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.emphChat
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.roomSet
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.rooms
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
|
||||
|
||||
class EphemeralChatListDecryptionCache(
|
||||
val signer: NostrSigner,
|
||||
) {
|
||||
val cachedPrivateLists = PrivateTagArrayEventCache<EphemeralChatListEvent>(signer)
|
||||
|
||||
fun cachedRoomSet(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagListPrecached(event).roomSet()
|
||||
|
||||
fun cachedRooms(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagListPrecached(event).rooms()
|
||||
|
||||
suspend fun roomSet(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagList(event).roomSet()
|
||||
|
||||
suspend fun rooms(event: EphemeralChatListEvent) = cachedPrivateLists.mergeTagList(event).rooms()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.emphChat
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class EphemeralChatListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: EphemeralChatListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val ephemeralChatListNote = cache.getOrCreateAddressableNote(getEphemeralChatListAddress())
|
||||
|
||||
fun getEphemeralChatListAddress() = EphemeralChatListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getEphemeralChatListFlow(): StateFlow<NoteState> = ephemeralChatListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getEphemeralChatList(): EphemeralChatListEvent? = ephemeralChatListNote.event as? EphemeralChatListEvent
|
||||
|
||||
suspend fun ephemeralChatListWithBackup(note: Note): Set<RoomId> {
|
||||
val event = note.event as? EphemeralChatListEvent ?: settings.backupEphemeralChatList
|
||||
return event?.let { decryptionCache.roomSet(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveEphemeralChatList: StateFlow<Set<RoomId>> =
|
||||
getEphemeralChatListFlow()
|
||||
.transformLatest { noteState ->
|
||||
emit(ephemeralChatListWithBackup(noteState.note))
|
||||
}.onStart {
|
||||
emit(ephemeralChatListWithBackup(ephemeralChatListNote))
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun follow(channel: EphemeralChatChannel): EphemeralChatListEvent {
|
||||
val ephemeralChatList = getEphemeralChatList()
|
||||
|
||||
return if (ephemeralChatList == null) {
|
||||
EphemeralChatListEvent.create(
|
||||
room = channel.roomId,
|
||||
isPrivate = true,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
EphemeralChatListEvent.add(
|
||||
earlierVersion = ephemeralChatList,
|
||||
room = channel.roomId,
|
||||
isPrivate = true,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(channel: EphemeralChatChannel): EphemeralChatListEvent? {
|
||||
val ephemeralChatList = getEphemeralChatList()
|
||||
return if (ephemeralChatList != null) {
|
||||
EphemeralChatListEvent.remove(
|
||||
earlierVersion = ephemeralChatList,
|
||||
room = channel.roomId,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupEphemeralChatList?.let { event ->
|
||||
Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LocalCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
Log.d("AccountRegisterObservers", "EphemeralChatList Collector Start")
|
||||
getEphemeralChatListFlow().collect { noteState ->
|
||||
Log.d("AccountRegisterObservers", "EphemeralChatList List for ${signer.pubKey}")
|
||||
(noteState.note.event as? EphemeralChatListEvent)?.let {
|
||||
settings.updateEphemeralChatListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.localRelays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class LocalRelayListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
fun normalizeLocalRelayListWithBackup(relayList: Set<String>): Set<NormalizedRelayUrl> = relayList.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
|
||||
val flow =
|
||||
settings.localRelayServers
|
||||
.map { normalizeLocalRelayListWithBackup(it) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
normalizeLocalRelayListWithBackup(settings.localRelayServers.value),
|
||||
)
|
||||
|
||||
fun saveRelayList(
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
settings.updateLocalRelayServers(relays.map { it.url }.toSet())
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip01UserMetadata
|
||||
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class AccountHomeRelayState(
|
||||
nip65: Nip65RelayListState,
|
||||
privateStorage: PrivateStorageRelayListState,
|
||||
local: LocalRelayListState,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val flow =
|
||||
combine(
|
||||
nip65.outboxFlow,
|
||||
privateStorage.flow,
|
||||
local.flow,
|
||||
) { nip65Outbox, privateOutBox, localRelays ->
|
||||
nip65Outbox + privateOutBox + localRelays
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
nip65.outboxFlow.value +
|
||||
privateStorage.flow.value +
|
||||
local.flow.value,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip01UserMetadata
|
||||
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class AccountOutboxRelayState(
|
||||
nip65: Nip65RelayListState,
|
||||
privateStorage: PrivateStorageRelayListState,
|
||||
local: LocalRelayListState,
|
||||
broadcast: BroadcastRelayListState,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val flow =
|
||||
combine(
|
||||
nip65.outboxFlow,
|
||||
privateStorage.flow,
|
||||
local.flow,
|
||||
broadcast.flow,
|
||||
) { nip65Outbox, privateOutBox, localRelays, broadcastRelays ->
|
||||
nip65Outbox + privateOutBox + localRelays + broadcastRelays
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
nip65.outboxFlow.value +
|
||||
privateStorage.flow.value +
|
||||
local.flow.value +
|
||||
broadcast.flow.value,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip01UserMetadata
|
||||
|
||||
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class NotificationInboxRelayState(
|
||||
nip65RelayList: Nip65RelayListState,
|
||||
localRelayList: LocalRelayListState,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val flow =
|
||||
combine(
|
||||
nip65RelayList.inboxFlow,
|
||||
localRelayList.flow,
|
||||
) { nip65Inbox, localRelays ->
|
||||
nip65Inbox + localRelays
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
nip65RelayList.inboxFlow.value + localRelayList.flow.value,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip01UserMetadata
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class UserMetadataState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// fun getEphemeralChatListAddress() = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
fun getUserMetadataUser(): User = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
fun getUserMetadataFlow(): StateFlow<UserState> = getUserMetadataUser().flow().metadata.stateFlow
|
||||
|
||||
fun getUserMetadataEvent(): MetadataEvent? = getUserMetadataUser().latestMetadata
|
||||
|
||||
suspend fun sendNewUserMetadata(
|
||||
name: String? = null,
|
||||
picture: String? = null,
|
||||
banner: String? = null,
|
||||
website: String? = null,
|
||||
pronouns: String? = null,
|
||||
about: String? = null,
|
||||
nip05: String? = null,
|
||||
lnAddress: String? = null,
|
||||
lnURL: String? = null,
|
||||
twitter: String? = null,
|
||||
mastodon: String? = null,
|
||||
github: String? = null,
|
||||
): MetadataEvent {
|
||||
val latest = getUserMetadataEvent()
|
||||
|
||||
val template =
|
||||
if (latest != null) {
|
||||
MetadataEvent.updateFromPast(
|
||||
latest = latest,
|
||||
name = name,
|
||||
displayName = name,
|
||||
picture = picture,
|
||||
banner = banner,
|
||||
website = website,
|
||||
pronouns = pronouns,
|
||||
about = about,
|
||||
nip05 = nip05,
|
||||
lnAddress = lnAddress,
|
||||
lnURL = lnURL,
|
||||
twitter = twitter,
|
||||
mastodon = mastodon,
|
||||
github = github,
|
||||
)
|
||||
} else {
|
||||
MetadataEvent.createNew(
|
||||
name = name,
|
||||
displayName = name,
|
||||
picture = picture,
|
||||
banner = banner,
|
||||
website = website,
|
||||
pronouns = pronouns,
|
||||
about = about,
|
||||
nip05 = nip05,
|
||||
lnAddress = lnAddress,
|
||||
lnURL = lnURL,
|
||||
twitter = twitter,
|
||||
mastodon = mastodon,
|
||||
github = github,
|
||||
)
|
||||
}
|
||||
|
||||
return signer.sign(template)
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupUserMetadata?.let {
|
||||
Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}")
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
// saves contact list for the next time.
|
||||
scope.launch(Dispatchers.Default) {
|
||||
Log.d("AccountRegisterObservers", "Kind 0 Collector Start")
|
||||
getUserMetadataFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Updating Kind 0 ${it.user.toBestDisplayName()}")
|
||||
settings.updateUserMetadata(it.user.latestMetadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip02FollowLists
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
class FollowListOutboxOrProxyRelays(
|
||||
kind3Follows: FollowListState,
|
||||
blockedRelayList: BlockedRelayListState,
|
||||
proxyRelayList: ProxyRelayListState,
|
||||
val cache: LocalCache,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.createAddress(pubkey)
|
||||
|
||||
fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey))
|
||||
|
||||
fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow<NoteState> = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow
|
||||
|
||||
fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent
|
||||
|
||||
fun allRelayListFlows(followList: Set<HexKey>): List<StateFlow<NoteState>> = followList.map { getNIP65RelayListFlow(it) }
|
||||
|
||||
fun combineAllFlows(flows: List<StateFlow<NoteState>>): Flow<Set<NormalizedRelayUrl>> =
|
||||
combine(flows) { relayListNotes: Array<NoteState> ->
|
||||
relayListNotes
|
||||
.mapNotNull {
|
||||
(it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()
|
||||
}.flatten()
|
||||
.toSet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val outboxRelayFlow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
kind3Follows.flow
|
||||
.transformLatest {
|
||||
emitAll(combineAllFlows(allRelayListFlows(it.authors)))
|
||||
}.onStart {
|
||||
emit(
|
||||
kind3Follows.flow.value.authors
|
||||
.mapNotNull {
|
||||
getNIP65RelayList(it)?.writeRelaysNorm()
|
||||
}.flatten()
|
||||
.toSet(),
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
val outboxRelayMinusBlockedFlow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
combine(outboxRelayFlow, blockedRelayList.flow) { followList, blockedRelays ->
|
||||
followList.minus(blockedRelays)
|
||||
}.onStart {
|
||||
emit(outboxRelayFlow.value.minus(blockedRelayList.flow.value.toSet()))
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flow: StateFlow<Set<NormalizedRelayUrl>> =
|
||||
proxyRelayList.flow
|
||||
.flatMapLatest { proxyRelays ->
|
||||
if (proxyRelays.isEmpty()) {
|
||||
outboxRelayMinusBlockedFlow
|
||||
} else {
|
||||
MutableStateFlow(proxyRelays)
|
||||
}
|
||||
}.onStart {
|
||||
emit(
|
||||
proxyRelayList.flow.value.ifEmpty {
|
||||
outboxRelayMinusBlockedFlow.value
|
||||
},
|
||||
)
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flowSet: StateFlow<Set<String>> =
|
||||
flow
|
||||
.map { relayList ->
|
||||
relayList.mapTo(mutableSetOf()) { it.url }
|
||||
}.onStart {
|
||||
emit(flow.value.mapTo(mutableSetOf()) { it.url })
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip02FollowLists
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FollowListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
fun getFollowListFlow(): StateFlow<UserState> = user.flow().follows.stateFlow
|
||||
|
||||
fun getFollowListEvent(): ContactListEvent? = user.latestContactList
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val innerFlow: Flow<Kind3Follows> =
|
||||
getFollowListFlow().transformLatest {
|
||||
emit(buildKind3Follows(it.user.latestContactList ?: settings.backupContactList))
|
||||
}
|
||||
|
||||
val flow =
|
||||
innerFlow
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
// this has priority.
|
||||
buildKind3Follows(getFollowListEvent() ?: settings.backupContactList),
|
||||
)
|
||||
|
||||
// Creates a long-term reference for all follows of a user
|
||||
val userList =
|
||||
flow
|
||||
.map {
|
||||
it.authors.map {
|
||||
cache.checkGetOrCreateUser(it)
|
||||
}
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
// this has priority.
|
||||
flow.value.authors.map {
|
||||
cache.checkGetOrCreateUser(it)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
This contains a big OR of everything the user wants to see in the a single feed.
|
||||
*/
|
||||
@Immutable
|
||||
class Kind3Follows(
|
||||
val authors: Set<String> = emptySet(),
|
||||
val authorsPlusMe: Set<String>,
|
||||
val hashtags: Set<String> = emptySet(),
|
||||
val geotags: Set<String> = emptySet(),
|
||||
val communities: Set<String> = emptySet(),
|
||||
) {
|
||||
val geotagScopes: Set<String> = geotags.mapTo(mutableSetOf<String>()) { GeohashId.toScope(it) }
|
||||
val hashtagScopes: Set<String> = hashtags.mapTo(mutableSetOf<String>()) { HashtagId.toScope(it) }
|
||||
}
|
||||
|
||||
fun buildKind3Follows(latestContactList: ContactListEvent?): Kind3Follows {
|
||||
// makes sure the output include only valid p tags
|
||||
val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet()
|
||||
|
||||
return Kind3Follows(
|
||||
authors = verifiedFollowingUsers,
|
||||
authorsPlusMe = verifiedFollowingUsers + signer.pubKey,
|
||||
hashtags =
|
||||
latestContactList
|
||||
?.unverifiedFollowTagSet()
|
||||
?.map { it.lowercase() }
|
||||
?.toSet() ?: emptySet(),
|
||||
geotags =
|
||||
latestContactList
|
||||
?.geohashes()
|
||||
?.toSet() ?: emptySet(),
|
||||
communities =
|
||||
latestContactList
|
||||
?.verifiedFollowAddressSet()
|
||||
?.toSet() ?: emptySet(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun follow(user: User): ContactListEvent {
|
||||
val contactList = getFollowListEvent()
|
||||
|
||||
return if (contactList != null) {
|
||||
ContactListEvent.followUser(contactList, user.pubkeyHex, signer)
|
||||
} else {
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)),
|
||||
relayUse = emptyMap(),
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(user: User): ContactListEvent? {
|
||||
val contactList = getFollowListEvent()
|
||||
|
||||
return if (contactList != null && contactList.tags.isNotEmpty()) {
|
||||
ContactListEvent.unfollowUser(
|
||||
contactList,
|
||||
user.pubkeyHex,
|
||||
signer,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupContactList?.let {
|
||||
Log.d("AccountRegisterObservers", "Loading saved contacts ${it.toJson()}")
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
// saves contact list for the next time.
|
||||
scope.launch(Dispatchers.Default) {
|
||||
Log.d("AccountRegisterObservers", "Kind 3 Collector Start")
|
||||
getFollowListFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}")
|
||||
settings.updateContactListTo(it.user.latestContactList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip02FollowLists
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Constants
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
class FollowsPerOutboxRelay(
|
||||
kind3Follows: FollowListState,
|
||||
blockedRelayList: BlockedRelayListState,
|
||||
proxyRelayList: ProxyRelayListState,
|
||||
val cache: LocalCache,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
fun getNIP65RelayListAddress(pubkey: HexKey) = AdvertisedRelayListEvent.createAddress(pubkey)
|
||||
|
||||
fun getNIP65RelayListNote(pubkey: HexKey): AddressableNote = cache.getOrCreateAddressableNote(getNIP65RelayListAddress(pubkey))
|
||||
|
||||
fun getNIP65RelayListFlow(pubkey: HexKey): StateFlow<NoteState> = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow
|
||||
|
||||
fun getNIP65RelayList(pubkey: HexKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent
|
||||
|
||||
fun allRelayListFlows(followList: Set<HexKey>): List<StateFlow<NoteState>> = followList.map { getNIP65RelayListFlow(it) }
|
||||
|
||||
fun combineAllRelayListFlows(flows: List<StateFlow<NoteState>>): Flow<Map<NormalizedRelayUrl, Set<HexKey>>> =
|
||||
combine(flows) { relayListNotes: Array<NoteState> ->
|
||||
mapOfSet {
|
||||
relayListNotes.forEach { noteState ->
|
||||
noteState.note.author?.pubkeyHex?.let { authorHex ->
|
||||
val outboxRelayList =
|
||||
getNIP65RelayList(authorHex)?.writeRelaysNorm()
|
||||
?: LocalCache.relayHints.hintsForKey(authorHex).ifEmpty { null }
|
||||
?: Constants.eventFinderRelays
|
||||
outboxRelayList.forEach { relay ->
|
||||
add(relay, authorHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val outboxPerRelayFlow: StateFlow<Map<NormalizedRelayUrl, Set<HexKey>>> =
|
||||
kind3Follows.flow
|
||||
.transformLatest {
|
||||
emitAll(combineAllRelayListFlows(allRelayListFlows(it.authors)))
|
||||
}.onStart {
|
||||
emit(
|
||||
mapOfSet {
|
||||
kind3Follows.flow.value.authors.map { authorHex ->
|
||||
val outboxRelayList =
|
||||
getNIP65RelayList(authorHex)?.writeRelaysNorm()
|
||||
?: LocalCache.relayHints.hintsForKey(authorHex).ifEmpty { null }
|
||||
?: Constants.eventFinderRelays
|
||||
outboxRelayList.forEach { relay ->
|
||||
add(relay, authorHex)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyMap(),
|
||||
)
|
||||
|
||||
val outboxPerRelayMinusBlockedFlow: StateFlow<Map<NormalizedRelayUrl, Set<HexKey>>> =
|
||||
combine(outboxPerRelayFlow, blockedRelayList.flow) { followList, blockedRelays ->
|
||||
followList.minus(blockedRelays)
|
||||
}.onStart {
|
||||
emit(outboxPerRelayFlow.value.minus(blockedRelayList.flow.value.toSet()))
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyMap(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flow: StateFlow<Map<NormalizedRelayUrl, Set<HexKey>>> =
|
||||
proxyRelayList.flow
|
||||
.flatMapLatest { proxyRelays ->
|
||||
if (proxyRelays.isEmpty()) {
|
||||
outboxPerRelayMinusBlockedFlow
|
||||
} else {
|
||||
kind3Follows.flow.map { follows ->
|
||||
proxyRelays.associateWith { follows.authors }
|
||||
}
|
||||
}
|
||||
}.onStart {
|
||||
if (proxyRelayList.flow.value.isEmpty()) {
|
||||
emit(outboxPerRelayMinusBlockedFlow.value)
|
||||
} else {
|
||||
emit(proxyRelayList.flow.value.associateWith { kind3Follows.flow.value.authors })
|
||||
}
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyMap(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip03Timestamp
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpOtsResolverBuilder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Base64
|
||||
|
||||
class OtsState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val otsResolver: OkHttpOtsResolverBuilder,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
suspend fun updateAttestations(): List<OtsEvent> {
|
||||
Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations")
|
||||
|
||||
return settings.pendingAttestations.value.toList().mapNotNull { (key, value) ->
|
||||
val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver.build())
|
||||
|
||||
if (otsState != null) {
|
||||
val hint = cache.getNoteIfExists(key)?.toEventHint<Event>()
|
||||
val template =
|
||||
if (hint != null) {
|
||||
OtsEvent.build(hint, otsState)
|
||||
} else {
|
||||
OtsEvent.build(key, otsState)
|
||||
}
|
||||
|
||||
val otsEvent = signer.sign(template)
|
||||
|
||||
settings.pendingAttestations.update { it - key }
|
||||
|
||||
otsEvent
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasPendingAttestations(note: Note): Boolean {
|
||||
val id = note.event?.id ?: note.idHex
|
||||
return settings.pendingAttestations.value[id] != null
|
||||
}
|
||||
|
||||
fun timestamp(note: Note) {
|
||||
if (note.isDraft()) return
|
||||
|
||||
val id = note.event?.id ?: note.idHex
|
||||
|
||||
settings.addPendingAttestation(
|
||||
id = id,
|
||||
stamp =
|
||||
Base64.getEncoder().encodeToString(
|
||||
OtsEvent.stamp(
|
||||
id,
|
||||
otsResolver.build(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip17Dms
|
||||
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class DmInboxRelayState(
|
||||
// main relay
|
||||
dmRelayList: DmRelayListState,
|
||||
// backup relays
|
||||
nip65RelayList: Nip65RelayListState,
|
||||
privateOutbox: PrivateStorageRelayListState,
|
||||
localRelayList: LocalRelayListState,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val flow =
|
||||
combine(
|
||||
nip65RelayList.inboxFlow,
|
||||
dmRelayList.flow,
|
||||
privateOutbox.flow,
|
||||
localRelayList.flow,
|
||||
) { nip65Inbox, dmRelayList, privateOutBox, localRelays ->
|
||||
nip65Inbox + dmRelayList + privateOutBox + localRelays
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
nip65RelayList.inboxFlow.value +
|
||||
dmRelayList.flow.value +
|
||||
privateOutbox.flow.value +
|
||||
localRelayList.flow.value,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip17Dms
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DmRelayListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val dmListNote = cache.getOrCreateAddressableNote(getDMRelayListAddress())
|
||||
|
||||
fun getDMRelayListAddress() = ChatMessageRelayListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getDMRelayListFlow(): StateFlow<NoteState> = dmListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getDMRelayList(): ChatMessageRelayListEvent? = dmListNote.event as? ChatMessageRelayListEvent
|
||||
|
||||
fun normalizeDMRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
|
||||
val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList
|
||||
return event?.relays()?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
val flow =
|
||||
getDMRelayListFlow()
|
||||
.map { normalizeDMRelayListWithBackup(it.note) }
|
||||
.onStart { emit(normalizeDMRelayListWithBackup(dmListNote)) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun saveRelayList(dmRelays: List<NormalizedRelayUrl>): ChatMessageRelayListEvent {
|
||||
val relayListForDMs = getDMRelayList()
|
||||
return if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) {
|
||||
ChatMessageRelayListEvent.updateRelayList(
|
||||
earlierVersion = relayListForDMs,
|
||||
relays = dmRelays,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
ChatMessageRelayListEvent.create(
|
||||
relays = dmRelays,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupDMRelayList?.let {
|
||||
Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LocalCache.justConsumeMyOwnEvent(it)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start")
|
||||
getDMRelayListFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Updating DM Relay List for ${signer.pubKey}")
|
||||
(it.note.event as? ChatMessageRelayListEvent)?.let {
|
||||
settings.updateDMRelayList(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip18Reposts
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
|
||||
class RepostAction {
|
||||
companion object {
|
||||
suspend fun repost(
|
||||
note: Note,
|
||||
signer: NostrSigner,
|
||||
): Event? {
|
||||
val noteEvent = note.event ?: return null
|
||||
|
||||
if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) {
|
||||
// has already bosted in the past 5mins
|
||||
return null
|
||||
}
|
||||
|
||||
val noteHint = note.relayHintUrl()
|
||||
val authorHint = note.author?.bestRelayHint()
|
||||
|
||||
val template =
|
||||
if (noteEvent.kind == 1) {
|
||||
RepostEvent.build(noteEvent, noteHint, authorHint)
|
||||
} else {
|
||||
GenericRepostEvent.build(noteEvent, noteHint, authorHint)
|
||||
}
|
||||
|
||||
return signer.sign(template)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip25Reactions
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
|
||||
class ReactionAction {
|
||||
companion object {
|
||||
suspend fun reactTo(
|
||||
note: Note,
|
||||
reaction: String,
|
||||
by: User,
|
||||
signer: NostrSigner,
|
||||
onPublic: (ReactionEvent) -> Unit,
|
||||
onPrivate: suspend (NIP17Factory.Result) -> Unit,
|
||||
) {
|
||||
if (!signer.isWriteable()) return
|
||||
|
||||
if (note.hasReacted(by, reaction)) {
|
||||
// has already liked this note
|
||||
return
|
||||
}
|
||||
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is NIP17Group) {
|
||||
val users = noteEvent.groupMembers().toList()
|
||||
|
||||
if (reaction.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrlTag.decode(reaction)
|
||||
if (emojiUrl != null) {
|
||||
note.toEventHint<Event>()?.let {
|
||||
onPrivate(
|
||||
NIP17Factory().createReactionWithinGroup(
|
||||
emojiUrl = emojiUrl,
|
||||
originalNote = it,
|
||||
to = users,
|
||||
signer = signer,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
note.toEventHint<Event>()?.let {
|
||||
onPrivate(
|
||||
NIP17Factory().createReactionWithinGroup(
|
||||
content = reaction,
|
||||
originalNote = it,
|
||||
to = users,
|
||||
signer = signer,
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
if (reaction.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrlTag.decode(reaction)
|
||||
if (emojiUrl != null) {
|
||||
note.event?.let {
|
||||
val template = ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl()))
|
||||
|
||||
onPublic(signer.sign(template))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
note.toEventHint<Event>()?.let {
|
||||
onPublic(signer.sign(ReactionEvent.build(reaction, it)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip28PublicChats
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHintOptional
|
||||
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
|
||||
import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
|
||||
|
||||
@Stable
|
||||
class PublicChatChannel(
|
||||
val idHex: String,
|
||||
) : Channel() {
|
||||
var creator: User? = null
|
||||
var event: ChannelCreateEvent? = null
|
||||
|
||||
// Important to keep this long-term reference because LocalCache uses WeakReferences.
|
||||
var creationEventNote: Note? = null
|
||||
var updateEventNote: Note? = null
|
||||
|
||||
var info = ChannelDataNorm(null, null, null, null)
|
||||
var infoTags = EmptyTagList
|
||||
var updatedMetadataAt: Long = 0
|
||||
|
||||
override fun relays() = info.relays?.toSet() ?: super.relays()
|
||||
|
||||
fun relayHintUrls() = relays().take(3)
|
||||
|
||||
fun relayHintUrl() = relays().firstOrNull()
|
||||
|
||||
fun toNEvent() = NEvent.create(idHex, event?.pubKey, ChannelCreateEvent.KIND, relayHintUrls())
|
||||
|
||||
fun toNostrUri() = "nostr:${toNEvent()}"
|
||||
|
||||
fun toEventHint() = event?.let { EventHintBundle(it, relayHintUrl(), null) }
|
||||
|
||||
fun toEventId() = EventIdHintOptional(idHex, relayHintUrl())
|
||||
|
||||
fun updateChannelInfo(
|
||||
creator: User,
|
||||
event: ChannelCreateEvent,
|
||||
eventNote: Note? = null,
|
||||
) {
|
||||
this.creator = creator
|
||||
this.event = event
|
||||
this.info = event.channelInfo()
|
||||
|
||||
this.infoTags = event.tags.toImmutableListOfLists()
|
||||
this.updatedMetadataAt = event.createdAt
|
||||
this.creationEventNote = eventNote
|
||||
|
||||
updateChannelInfo()
|
||||
}
|
||||
|
||||
fun updateChannelInfo(
|
||||
creator: User,
|
||||
event: ChannelMetadataEvent,
|
||||
eventNote: Note? = null,
|
||||
) {
|
||||
this.creator = creator
|
||||
this.info = event.channelInfo()
|
||||
|
||||
this.infoTags = event.tags.toImmutableListOfLists()
|
||||
this.updatedMetadataAt = event.createdAt
|
||||
this.updateEventNote = eventNote
|
||||
|
||||
super.updateChannelInfo()
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String = info.name ?: toNEvent().toShortDisplay()
|
||||
|
||||
fun summary(): String? = info.about
|
||||
|
||||
fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) return creator?.info?.banner
|
||||
return info.picture ?: creator?.info?.banner
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean =
|
||||
idHex.startsWith(prefix) ||
|
||||
info.name?.contains(prefix, true) == true ||
|
||||
info.about?.contains(prefix, true) == true
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip28PublicChats
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.channelSet
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.channels
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
|
||||
|
||||
class PublicChatListDecryptionCache(
|
||||
val signer: NostrSigner,
|
||||
) {
|
||||
val cachedPrivateLists = PrivateTagArrayEventCache<ChannelListEvent>(signer)
|
||||
|
||||
fun cachedChannelSet(event: ChannelListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channelSet()
|
||||
|
||||
fun cachedChannels(event: ChannelListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channels()
|
||||
|
||||
suspend fun channelSet(event: ChannelListEvent) = cachedPrivateLists.mergeTagList(event).channelSet()
|
||||
|
||||
suspend fun channels(event: ChannelListEvent) = cachedPrivateLists.mergeTagList(event).channels()
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip28PublicChats
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PublicChatListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: PublicChatListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val publicChatListNote = cache.getOrCreateAddressableNote(getChannelListAddress())
|
||||
|
||||
fun getChannelListAddress() = ChannelListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getChannelListFlow(): StateFlow<NoteState> = publicChatListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getChannelList(): ChannelListEvent? = publicChatListNote.event as? ChannelListEvent
|
||||
|
||||
suspend fun publicChatListWithBackup(note: Note): Set<ChannelTag> {
|
||||
val event = note.event as? ChannelListEvent ?: settings.backupChannelList
|
||||
return event?.let { decryptionCache.channelSet(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flow: StateFlow<Set<ChannelTag>> =
|
||||
getChannelListFlow()
|
||||
.transformLatest { noteState ->
|
||||
emit(publicChatListWithBackup(noteState.note))
|
||||
}.onStart {
|
||||
emit(publicChatListWithBackup(publicChatListNote))
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flowSet: StateFlow<Set<HexKey>> =
|
||||
flow
|
||||
.map {
|
||||
it.mapTo(mutableSetOf()) { it.eventId }
|
||||
}.onStart {
|
||||
emit(flow.value.mapTo(mutableSetOf()) { it.eventId })
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun follow(channel: PublicChatChannel): ChannelListEvent {
|
||||
val publicChatList = getChannelList()
|
||||
|
||||
return if (publicChatList == null) {
|
||||
ChannelListEvent.create(ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer)
|
||||
} else {
|
||||
ChannelListEvent.add(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun follow(channels: List<PublicChatChannel>): ChannelListEvent {
|
||||
val publicChatList = getChannelList()
|
||||
|
||||
val channelTags = channels.map { ChannelTag(it.idHex, it.relayHintUrl()) }
|
||||
return if (publicChatList == null) {
|
||||
ChannelListEvent.create(channelTags, true, signer)
|
||||
} else {
|
||||
ChannelListEvent.add(publicChatList, channelTags, true, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(channel: PublicChatChannel): ChannelListEvent? {
|
||||
val publicChatList = getChannelList()
|
||||
|
||||
return if (publicChatList != null) {
|
||||
ChannelListEvent.remove(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), signer)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupChannelList?.let { event ->
|
||||
Log.d("AccountRegisterObservers", "Loading saved channel list ${event.toJson()}")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LocalCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
Log.d("AccountRegisterObservers", "Channel List Collector Start")
|
||||
getChannelListFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Channel List for ${signer.pubKey}")
|
||||
(it.note.event as? ChannelListEvent)?.let {
|
||||
settings.updateChannelListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip30CustomEmojis
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
class EmojiPackState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
class EmojiMedia(
|
||||
val code: String,
|
||||
val link: MediaUrlImage,
|
||||
)
|
||||
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val emojiPackListNote = cache.getOrCreateAddressableNote(getEmojiPackSelectionAddress())
|
||||
|
||||
fun getEmojiPackSelectionAddress() = EmojiPackSelectionEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getEmojiPackSelection(): EmojiPackSelectionEvent? = emojiPackListNote.event as? EmojiPackSelectionEvent
|
||||
|
||||
fun getEmojiPackSelectionFlow(): StateFlow<NoteState> = emojiPackListNote.flow().metadata.stateFlow
|
||||
|
||||
fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List<StateFlow<NoteState>>? =
|
||||
selection?.taggedAddresses()?.map {
|
||||
cache
|
||||
.getOrCreateAddressableNote(it)
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flow: StateFlow<List<StateFlow<NoteState>>?> =
|
||||
getEmojiPackSelectionFlow()
|
||||
.transformLatest {
|
||||
emit(convertEmojiSelectionPack(it.note.event as? EmojiPackSelectionEvent))
|
||||
}.onStart {
|
||||
emit(convertEmojiSelectionPack(getEmojiPackSelection()))
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
fun convertEmojiPack(pack: EmojiPackEvent): List<EmojiMedia> =
|
||||
pack.taggedEmojis().map {
|
||||
EmojiMedia(it.code, MediaUrlImage(it.url))
|
||||
}
|
||||
|
||||
fun mergePack(list: Array<NoteState>): List<EmojiMedia> =
|
||||
list
|
||||
.mapNotNull {
|
||||
val ev = it.note.event as? EmojiPackEvent
|
||||
if (ev != null) {
|
||||
convertEmojiPack(ev)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.flatten()
|
||||
.distinctBy { it.link }
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val myEmojis =
|
||||
flow
|
||||
.transformLatest { emojiList ->
|
||||
if (emojiList != null) {
|
||||
emitAll(
|
||||
combineTransform(emojiList) {
|
||||
emit(mergePack(it))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
emit(emptyList())
|
||||
}
|
||||
}.onStart {
|
||||
emit(
|
||||
mergePack(
|
||||
convertEmojiSelectionPack(
|
||||
getEmojiPackSelection(),
|
||||
)?.map { it.value }?.toTypedArray() ?: emptyArray(),
|
||||
),
|
||||
)
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent {
|
||||
val emojiPackEvent = emojiPack.event
|
||||
if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.")
|
||||
|
||||
val eventHint = emojiPack.toEventHint<EmojiPackEvent>() ?: throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.")
|
||||
|
||||
val usersEmojiList = getEmojiPackSelection()
|
||||
return if (usersEmojiList == null) {
|
||||
val template = EmojiPackSelectionEvent.build(listOf(eventHint))
|
||||
signer.sign(template)
|
||||
} else {
|
||||
val template = EmojiPackSelectionEvent.add(usersEmojiList, eventHint)
|
||||
signer.sign(template)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeEmojiPack(emojiPack: Note): EmojiPackSelectionEvent? {
|
||||
val usersEmojiList = getEmojiPackSelection() ?: throw IllegalArgumentException("Cannot remove an emoji pack to this kind of event.")
|
||||
|
||||
val emojiPackEvent = emojiPack.event
|
||||
if (emojiPackEvent !is EmojiPackEvent) return null
|
||||
|
||||
val template = EmojiPackSelectionEvent.remove(usersEmojiList, emojiPackEvent)
|
||||
return signer.sign(template)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip38UserStatuses
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
|
||||
|
||||
class UserStatusAction {
|
||||
companion object {
|
||||
suspend fun create(
|
||||
newStatus: String,
|
||||
signer: NostrSigner,
|
||||
): StatusEvent = StatusEvent.create(newStatus, "general", expiration = null, signer)
|
||||
|
||||
suspend fun update(
|
||||
oldStatus: AddressableNote,
|
||||
newStatus: String,
|
||||
signer: NostrSigner,
|
||||
): StatusEvent {
|
||||
val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event")
|
||||
|
||||
return StatusEvent.update(oldEvent, newStatus, signer)
|
||||
}
|
||||
|
||||
suspend fun delete(
|
||||
oldStatus: AddressableNote,
|
||||
signer: NostrSigner,
|
||||
): List<Event> {
|
||||
val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event")
|
||||
|
||||
val event = StatusEvent.clear(oldEvent, signer)
|
||||
|
||||
val deletion =
|
||||
signer.sign(
|
||||
DeletionEvent.buildForVersionOnly(listOf(event)),
|
||||
)
|
||||
|
||||
return listOf(event, deletion)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip47WalletConnect
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentQueryState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectRequestCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.NostrWalletConnectResponseCache
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Response
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Manages NIP-47 (Nostr Wallet Connect) related signing operations and decryption cache for a given account.
|
||||
*
|
||||
* Key Responsibilities:
|
||||
*
|
||||
* - Dynamically creates a NIP-47 signer if the wallet setup changes in the account settings.
|
||||
* - Provides decryption caches to manage decrypted NIP-47 requests and responses efficiently.
|
||||
* - Handles creating of zap payment requests and waits for responses.
|
||||
*
|
||||
* @property signer the main Nostr signer used for general Nostr operations
|
||||
* @property cache the local cache for handling notes and events
|
||||
* @property scope the coroutine scope used for async operations
|
||||
* @property settings the account settings containing NIP-47 configuration
|
||||
*/
|
||||
class NwcSignerState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
/**
|
||||
* Derives a NIP-47 signer from the zap payment request in settings.
|
||||
* If there's no valid configuration, it defaults to the main signer.
|
||||
* Flows updates whenever settings change.
|
||||
*/
|
||||
val nip47Signer =
|
||||
settings.zapPaymentRequest
|
||||
.map {
|
||||
buildSigner(it) ?: signer
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
buildSigner(settings.zapPaymentRequest.value) ?: signer,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a dedicated request decryption cache for the NIP-47 signer.
|
||||
* Flows updates whenever the signer changes.
|
||||
*/
|
||||
val zapPaymentRequestDecryptionCache =
|
||||
nip47Signer
|
||||
.map {
|
||||
NostrWalletConnectRequestCache(it)
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(scope, SharingStarted.Eagerly, NostrWalletConnectRequestCache(nip47Signer.value))
|
||||
|
||||
/**
|
||||
* Creates a dedicated response decryption cache for the NIP-47 signer.
|
||||
* Flows updates whenever the signer changes.
|
||||
*/
|
||||
val zapPaymentResponseDecryptionCache =
|
||||
nip47Signer
|
||||
.map {
|
||||
NostrWalletConnectResponseCache(it)
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(scope, SharingStarted.Eagerly, NostrWalletConnectResponseCache(nip47Signer.value))
|
||||
|
||||
fun buildSigner(uri: Nip47WalletConnect.Nip47URINorm?) =
|
||||
uri?.secret?.hexToByteArray()?.let {
|
||||
NostrSignerInternal(KeyPair(it))
|
||||
}
|
||||
|
||||
fun hasWalletConnectSetup(): Boolean = settings.zapPaymentRequest.value != null
|
||||
|
||||
fun isNIP47Author(pubkeyHex: String?): Boolean = nip47Signer.value.pubKey == pubkeyHex
|
||||
|
||||
/**
|
||||
* Decrypts a NIP-47 payment request using the current signer.
|
||||
*
|
||||
* @param nwcRequest the NIP-47 payment request event to decrypt
|
||||
* @return the decrypted request or null if not set up or decryption fails
|
||||
*/
|
||||
suspend fun decryptRequest(nwcRequest: LnZapPaymentRequestEvent): Request? {
|
||||
if (!hasWalletConnectSetup()) return null
|
||||
return zapPaymentRequestDecryptionCache.value.decryptRequest(nwcRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a NIP-47 payment response using the current signer.
|
||||
*
|
||||
* @param nwsResponse the NIP-47 payment response event to decrypt
|
||||
* @return the decrypted response or null if not set up or decryption fails
|
||||
*/
|
||||
suspend fun decryptResponse(nwsResponse: LnZapPaymentResponseEvent): Response? {
|
||||
if (!hasWalletConnectSetup()) return null
|
||||
return zapPaymentResponseDecryptionCache.value.decryptResponse(nwsResponse)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a zap payment request to a connected Lightning wallet.
|
||||
* Subscribes to responses and waits up to 60s for a reply.
|
||||
*
|
||||
* @param bolt11 the BOLT-11 invoice to pay
|
||||
* @param zappedNote the note being zapped (if any)
|
||||
* @param onResponse callback to handle the response from the wallet
|
||||
* @return a pair containing the payment request event and target relay URL
|
||||
* @throws IllegalArgumentException if no NIP-47 wallet is set up
|
||||
*/
|
||||
suspend fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onResponse: (Response?) -> Unit,
|
||||
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
|
||||
val walletService = settings.zapPaymentRequest.value
|
||||
if (walletService == null) throw IllegalArgumentException("No NIP47 setup")
|
||||
|
||||
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value)
|
||||
|
||||
val filter =
|
||||
NWCPaymentQueryState(
|
||||
fromServiceHex = walletService.pubKeyHex,
|
||||
toUserHex = event.pubKey,
|
||||
replyingToHex = event.id,
|
||||
relay = walletService.relayUri,
|
||||
)
|
||||
|
||||
Amethyst.instance.sources.nwc
|
||||
.subscribe(filter)
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(60000) // waits 1 minute to complete payment.
|
||||
Amethyst.instance.sources.nwc
|
||||
.unsubscribe(filter)
|
||||
}
|
||||
|
||||
cache.consume(event, zappedNote, true, walletService.relayUri) {
|
||||
onResponse(decryptResponse(it))
|
||||
}
|
||||
|
||||
return Pair(event, walletService.relayUri)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class BookmarkListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
class BookmarkList(
|
||||
val public: List<Note> = emptyList(),
|
||||
val private: List<Note> = emptyList(),
|
||||
)
|
||||
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val bookmarkList = cache.getOrCreateAddressableNote(getBookmarkListAddress())
|
||||
|
||||
fun getBookmarkListAddress() = BookmarkListEvent.createBookmarkAddress(signer.pubKey)
|
||||
|
||||
fun getBookmarkListFlow(): StateFlow<NoteState> = bookmarkList.flow().metadata.stateFlow
|
||||
|
||||
fun getBookmarkList(): BookmarkListEvent? = bookmarkList.event as? BookmarkListEvent
|
||||
|
||||
suspend fun publicBookmarks(note: Note): List<BookmarkIdTag> {
|
||||
val noteEvent = note.event as? BookmarkListEvent
|
||||
return noteEvent?.publicBookmarks() ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun privateBookmarks(note: Note): List<BookmarkIdTag> {
|
||||
val noteEvent = note.event as? BookmarkListEvent
|
||||
return noteEvent?.privateBookmarks(signer) ?: emptyList()
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val publicBookmarks: StateFlow<List<BookmarkIdTag>> =
|
||||
getBookmarkListFlow()
|
||||
.map { noteState ->
|
||||
publicBookmarks(noteState.note)
|
||||
}.onStart {
|
||||
emit(publicBookmarks(bookmarkList))
|
||||
}.debounce(100)
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val privateBookmarks: StateFlow<List<BookmarkIdTag>> =
|
||||
getBookmarkListFlow()
|
||||
.map { noteState ->
|
||||
privateBookmarks(noteState.note)
|
||||
}.onStart {
|
||||
emit(privateBookmarks(bookmarkList))
|
||||
}.debounce(100)
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
val publicBookmarkEventIdSet =
|
||||
publicBookmarks
|
||||
.map { bookmark ->
|
||||
bookmark
|
||||
.mapNotNull {
|
||||
if (it is EventBookmark) it.eventId else null
|
||||
}.toSet()
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
val publicBookmarkAddressIdSet =
|
||||
publicBookmarks
|
||||
.map { bookmark ->
|
||||
bookmark
|
||||
.mapNotNull {
|
||||
if (it is AddressBookmark) it.address else null
|
||||
}.toSet()
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
val privateBookmarkEventIdSet =
|
||||
privateBookmarks
|
||||
.map { bookmark ->
|
||||
bookmark
|
||||
.mapNotNull {
|
||||
if (it is EventBookmark) it.eventId else null
|
||||
}.toSet()
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
val privateBookmarkAddressIdSet =
|
||||
privateBookmarks
|
||||
.map { bookmark ->
|
||||
bookmark
|
||||
.mapNotNull {
|
||||
if (it is AddressBookmark) it.address else null
|
||||
}.toSet()
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
fun bookmarkList(
|
||||
privateBookmarks: List<BookmarkIdTag>,
|
||||
publicBookmarks: List<BookmarkIdTag>,
|
||||
): BookmarkList =
|
||||
BookmarkList(
|
||||
public =
|
||||
publicBookmarks
|
||||
.mapNotNull {
|
||||
when (it) {
|
||||
is EventBookmark -> cache.checkGetOrCreateNote(it.eventId)
|
||||
is AddressBookmark -> cache.getOrCreateAddressableNote(it.address)
|
||||
}
|
||||
}.reversed(),
|
||||
private =
|
||||
privateBookmarks
|
||||
.mapNotNull {
|
||||
when (it) {
|
||||
is EventBookmark -> cache.checkGetOrCreateNote(it.eventId)
|
||||
is AddressBookmark -> cache.getOrCreateAddressableNote(it.address)
|
||||
}
|
||||
}.reversed(),
|
||||
)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val bookmarks: StateFlow<BookmarkList> =
|
||||
combineTransform(privateBookmarks, publicBookmarks) { private, public ->
|
||||
emit(bookmarkList(private, public))
|
||||
}.onStart {
|
||||
emit(bookmarkList(privateBookmarks.value, publicBookmarks.value))
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
BookmarkList(),
|
||||
)
|
||||
|
||||
fun isInPrivateBookmarks(note: Note): Boolean {
|
||||
if (!signer.isWriteable()) return false
|
||||
|
||||
return if (note is AddressableNote) {
|
||||
privateBookmarkAddressIdSet.value.contains(note.address)
|
||||
} else {
|
||||
privateBookmarkEventIdSet.value.contains(note.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
fun isInPublicBookmarks(note: Note): Boolean =
|
||||
if (note is AddressableNote) {
|
||||
publicBookmarkAddressIdSet.value.contains(note.address)
|
||||
} else {
|
||||
publicBookmarkEventIdSet.value.contains(note.idHex)
|
||||
}
|
||||
|
||||
suspend fun addBookmark(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): BookmarkListEvent {
|
||||
val bookmarkList = getBookmarkList()
|
||||
|
||||
return if (bookmarkList == null) {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.create(
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.create(
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.add(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.add(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): BookmarkListEvent? {
|
||||
val bookmarkList = getBookmarkList()
|
||||
|
||||
return if (bookmarkList != null) {
|
||||
if (note is AddressableNote) {
|
||||
BookmarkListEvent.remove(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.remove(
|
||||
earlierVersion = bookmarkList,
|
||||
bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()),
|
||||
isPrivate = isPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class HiddenUsersState(
|
||||
val muteList: StateFlow<List<MuteTag>>,
|
||||
val blockList: StateFlow<List<MuteTag>>,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
var transientHiddenUsers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf())
|
||||
|
||||
@Immutable
|
||||
class LiveHiddenUsers(
|
||||
val hiddenUsers: Set<String>,
|
||||
val spammers: Set<String>,
|
||||
val hiddenWords: Set<String>,
|
||||
val showSensitiveContent: Boolean?,
|
||||
) {
|
||||
// speeds up isHidden calculations
|
||||
val hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() }
|
||||
val spammersHashCodes = spammers.mapTo(HashSet()) { it.hashCode() }
|
||||
val hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) }
|
||||
|
||||
fun isUserHidden(userHex: HexKey) = hiddenUsers.contains(userHex) || spammers.contains(userHex)
|
||||
}
|
||||
|
||||
suspend fun assembleLiveHiddenUsers(
|
||||
blockList: List<MuteTag>,
|
||||
muteList: List<MuteTag>,
|
||||
transientHiddenUsers: Set<String>,
|
||||
showSensitiveContent: Boolean?,
|
||||
): LiveHiddenUsers =
|
||||
LiveHiddenUsers(
|
||||
hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null },
|
||||
hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null },
|
||||
spammers = transientHiddenUsers,
|
||||
showSensitiveContent = showSensitiveContent,
|
||||
)
|
||||
|
||||
val flow: StateFlow<LiveHiddenUsers> =
|
||||
combineTransform(
|
||||
blockList,
|
||||
muteList,
|
||||
transientHiddenUsers,
|
||||
settings.syncedSettings.security.showSensitiveContent,
|
||||
) { blockList, muteList, transientHiddenUsers, showSensitiveContent ->
|
||||
checkNotInMainThread()
|
||||
emit(assembleLiveHiddenUsers(blockList, muteList, transientHiddenUsers, showSensitiveContent))
|
||||
}.onStart {
|
||||
emit(
|
||||
assembleLiveHiddenUsers(
|
||||
blockList.value,
|
||||
muteList.value,
|
||||
transientHiddenUsers.value,
|
||||
settings.syncedSettings.security.showSensitiveContent.value,
|
||||
),
|
||||
)
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
LiveHiddenUsers(emptySet(), emptySet(), emptySet(), null),
|
||||
)
|
||||
|
||||
fun resetTransientUsers() {
|
||||
transientHiddenUsers.update {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
fun showUser(pubkeyHex: HexKey) {
|
||||
transientHiddenUsers.update { it - pubkeyHex }
|
||||
}
|
||||
|
||||
fun hideUser(pubkeyHex: HexKey) {
|
||||
transientHiddenUsers.update { it + pubkeyHex }
|
||||
}
|
||||
|
||||
fun isHidden(pubkeyHex: HexKey) = pubkeyHex in transientHiddenUsers.value
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class BlockPeopleListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: PeopleListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val blockListNote = cache.getOrCreateAddressableNote(getBlockListAddress())
|
||||
|
||||
fun getBlockListAddress() = PeopleListEvent.createBlockAddress(signer.pubKey)
|
||||
|
||||
fun getBlockListFlow(): StateFlow<NoteState> = blockListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getBlockList(): PeopleListEvent? = blockListNote.event as? PeopleListEvent
|
||||
|
||||
suspend fun blockListWithBackup(note: Note): List<MuteTag> {
|
||||
val event = note.event as? PeopleListEvent
|
||||
return event?.let { decryptionCache.usersAndWords(it) } ?: emptyList()
|
||||
}
|
||||
|
||||
val flow =
|
||||
getBlockListFlow()
|
||||
.map { blockListWithBackup(it.note) }
|
||||
.onStart { emit(blockListWithBackup(blockListNote)) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
suspend fun hideUser(pubkeyHex: String): PeopleListEvent {
|
||||
val blockList = getBlockList()
|
||||
|
||||
return if (blockList != null) {
|
||||
PeopleListEvent.add(
|
||||
earlierVersion = blockList,
|
||||
person = UserTag(pubkeyHex),
|
||||
isPrivate = true,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
PeopleListEvent.create(
|
||||
name = PeopleListEvent.BLOCK_LIST_D_TAG,
|
||||
person = UserTag(pubkeyHex),
|
||||
isPrivate = true,
|
||||
signer = signer,
|
||||
dTag = PeopleListEvent.BLOCK_LIST_D_TAG,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showUser(pubkeyHex: String): PeopleListEvent? {
|
||||
val blockList = getBlockList()
|
||||
|
||||
return if (blockList != null) {
|
||||
PeopleListEvent.remove(
|
||||
earlierVersion = blockList,
|
||||
person = UserTag(pubkeyHex),
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showWord(word: String): PeopleListEvent? {
|
||||
val blockList = getBlockList()
|
||||
|
||||
return if (blockList != null) {
|
||||
PeopleListEvent.remove(
|
||||
earlierVersion = blockList,
|
||||
person = WordTag(word),
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUserIdSet
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsers
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsersAndWords
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWordSet
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWords
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
|
||||
class PeopleListDecryptionCache(
|
||||
val signer: NostrSigner,
|
||||
) {
|
||||
val cachedPrivateLists = PrivateTagArrayEventCache<PeopleListEvent>(signer)
|
||||
|
||||
fun cachedUsersAndWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsersAndWords()
|
||||
|
||||
fun cachedUsers(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsers()
|
||||
|
||||
fun cachedUserIdSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUserIdSet()
|
||||
|
||||
fun cachedWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWords()
|
||||
|
||||
fun cachedWordSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWordSet()
|
||||
|
||||
suspend fun usersAndWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsersAndWords()
|
||||
|
||||
suspend fun users(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsers()
|
||||
|
||||
suspend fun userIdSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUserIdSet()
|
||||
|
||||
suspend fun words(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedWords()
|
||||
|
||||
suspend fun wordSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
|
||||
class BlockedRelayListDecryptionCache(
|
||||
signer: NostrSigner,
|
||||
) : GenericRelayListCache<BlockedRelayListEvent>(signer)
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BlockedRelayListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: BlockedRelayListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val blockedListNote = cache.getOrCreateAddressableNote(getBlockedRelayListAddress())
|
||||
|
||||
fun getBlockedRelayListAddress() = BlockedRelayListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getBlockedRelayListFlow(): StateFlow<NoteState> = blockedListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getBlockedRelayList(): BlockedRelayListEvent? = blockedListNote.event as? BlockedRelayListEvent
|
||||
|
||||
suspend fun normalizeBlockedRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
|
||||
val event = note.event as? BlockedRelayListEvent ?: settings.backupBlockedRelayList
|
||||
return event?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
val flow =
|
||||
getBlockedRelayListFlow()
|
||||
.map {
|
||||
normalizeBlockedRelayListWithBackup(it.note)
|
||||
}.onStart { emit(normalizeBlockedRelayListWithBackup(blockedListNote)) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun saveRelayList(blockedRelays: List<NormalizedRelayUrl>): BlockedRelayListEvent {
|
||||
if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException()
|
||||
val relayListForBlocked = getBlockedRelayList()
|
||||
|
||||
return if (relayListForBlocked != null && relayListForBlocked.tags.isNotEmpty()) {
|
||||
BlockedRelayListEvent.updateRelayList(
|
||||
earlierVersion = relayListForBlocked,
|
||||
relays = blockedRelays,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BlockedRelayListEvent.create(
|
||||
relays = blockedRelays,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupBlockedRelayList?.let {
|
||||
Log.d("AccountRegisterObservers", "Loading saved Blocked relay list ${it.toJson()}")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
Log.d("AccountRegisterObservers", "Blocked Relay List Collector Start")
|
||||
getBlockedRelayListFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Updating Blocked Relay List for ${signer.pubKey}")
|
||||
(it.note.event as? BlockedRelayListEvent)?.let {
|
||||
settings.updateBlockedRelayList(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
|
||||
|
||||
class BroadcastRelayListDecryptionCache(
|
||||
signer: NostrSigner,
|
||||
) : GenericRelayListCache<BroadcastRelayListEvent>(signer)
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class BroadcastRelayListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: BroadcastRelayListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val broadcastListNote = cache.getOrCreateAddressableNote(getBroadcastRelayListAddress())
|
||||
|
||||
fun getBroadcastRelayListAddress() = BroadcastRelayListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getBroadcastRelayListFlow(): StateFlow<NoteState> = broadcastListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getBroadcastRelayList(): BroadcastRelayListEvent? = broadcastListNote.event as? BroadcastRelayListEvent
|
||||
|
||||
suspend fun normalizeBroadcastRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
|
||||
val event = note.event as? BroadcastRelayListEvent
|
||||
return event?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
val flow =
|
||||
getBroadcastRelayListFlow()
|
||||
.map { normalizeBroadcastRelayListWithBackup(it.note) }
|
||||
.onStart { emit(normalizeBroadcastRelayListWithBackup(broadcastListNote)) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun saveRelayList(broadcastRelays: List<NormalizedRelayUrl>): BroadcastRelayListEvent {
|
||||
val relayListForBroadcast = getBroadcastRelayList()
|
||||
|
||||
return if (relayListForBroadcast != null && relayListForBroadcast.tags.isNotEmpty()) {
|
||||
BroadcastRelayListEvent.updateRelayList(
|
||||
earlierVersion = relayListForBroadcast,
|
||||
relays = broadcastRelays,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BroadcastRelayListEvent.create(
|
||||
relays = broadcastRelays,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.geohashLists
|
||||
|
||||
data class GeohashListCard(
|
||||
val relays: List<String>,
|
||||
)
|
||||
|
||||
val EmptyGeohashListCard = GeohashListCard(emptyList())
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.geohashLists
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.geohashSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
|
||||
class GeohashListDecryptionCache(
|
||||
val signer: NostrSigner,
|
||||
) {
|
||||
val cachedPrivateLists = PrivateTagArrayEventCache<GeohashListEvent>(signer)
|
||||
|
||||
fun cachedGeohashes(event: GeohashListEvent) = cachedPrivateLists.mergeTagListPrecached(event).geohashSet()
|
||||
|
||||
suspend fun geohashes(event: GeohashListEvent) = cachedPrivateLists.mergeTagList(event).geohashSet()
|
||||
|
||||
fun fastStartValueForGeohashList(note: Note): GeohashListCard {
|
||||
val noteEvent = note.event as? GeohashListEvent
|
||||
return if (noteEvent != null) {
|
||||
GeohashListCard(cachedGeohashes(noteEvent).toList())
|
||||
} else {
|
||||
EmptyGeohashListCard
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun observeDecryptedGeohashList(note: Note): Flow<GeohashListCard> =
|
||||
note
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.mapLatest { noteState ->
|
||||
val event = noteState.note.event as? GeohashListEvent
|
||||
GeohashListCard(event?.let { geohashes(it).toList() } ?: emptyList())
|
||||
}.onStart {
|
||||
val event = note.event as? GeohashListEvent
|
||||
if (event != null) {
|
||||
val list = geohashes(event)
|
||||
if (list.isNotEmpty()) {
|
||||
emit(GeohashListCard(list.toList()))
|
||||
} else {
|
||||
emit(EmptyGeohashListCard)
|
||||
}
|
||||
} else {
|
||||
emit(EmptyGeohashListCard)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.Default)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.geohashLists
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GeohashListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: GeohashListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val geohashListNote = cache.getOrCreateAddressableNote(getGeohashListAddress())
|
||||
|
||||
fun getGeohashListAddress() = GeohashListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getGeohashListFlow(): StateFlow<NoteState> = geohashListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getGeohashList(): GeohashListEvent? = geohashListNote.event as? GeohashListEvent
|
||||
|
||||
suspend fun geohashListWithBackup(note: Note): Set<String> {
|
||||
val event = note.event as? GeohashListEvent ?: settings.backupGeohashList
|
||||
return event?.let { decryptionCache.geohashes(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flow: StateFlow<Set<String>> =
|
||||
getGeohashListFlow()
|
||||
.transformLatest { noteState ->
|
||||
emit(geohashListWithBackup(noteState.note))
|
||||
}.onStart {
|
||||
emit(geohashListWithBackup(geohashListNote))
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun follow(geohashes: List<String>): GeohashListEvent {
|
||||
val geohashList = getGeohashList()
|
||||
|
||||
return if (geohashList == null) {
|
||||
GeohashListEvent.create(geohashes, true, signer)
|
||||
} else {
|
||||
GeohashListEvent.add(geohashList, geohashes, true, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun follow(geohash: String): GeohashListEvent {
|
||||
val geohashList = getGeohashList()
|
||||
|
||||
return if (geohashList == null) {
|
||||
GeohashListEvent.create(geohash, true, signer)
|
||||
} else {
|
||||
GeohashListEvent.add(geohashList, geohash, true, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(geohash: String): GeohashListEvent? {
|
||||
val geohashList = getGeohashList()
|
||||
|
||||
return if (geohashList != null) {
|
||||
GeohashListEvent.remove(geohashList, geohash, signer)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupGeohashList?.let { event ->
|
||||
Log.d("AccountRegisterObservers", "Loading saved Geohash list ${event.toJson()}")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LocalCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
Log.d("AccountRegisterObservers", "Geohash List Collector Start")
|
||||
getGeohashListFlow().collect { noteState ->
|
||||
Log.d("AccountRegisterObservers", "Geohash List for ${signer.pubKey}")
|
||||
(noteState.note.event as? GeohashListEvent)?.let {
|
||||
settings.updateGeohashListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.hashtagSet
|
||||
|
||||
class HashtagListDecryptionCache(
|
||||
val signer: NostrSigner,
|
||||
) {
|
||||
val cachedPrivateLists = PrivateTagArrayEventCache<HashtagListEvent>(signer)
|
||||
|
||||
fun cachedHashtags(event: HashtagListEvent) = cachedPrivateLists.mergeTagListPrecached(event).hashtagSet()
|
||||
|
||||
suspend fun hashtags(event: HashtagListEvent) = cachedPrivateLists.mergeTagList(event).hashtagSet()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HashtagListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: HashtagListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val hashtagListNote = cache.getOrCreateAddressableNote(getHashtagListAddress())
|
||||
|
||||
fun getHashtagListAddress() = HashtagListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getHashtagListFlow(): StateFlow<NoteState> = hashtagListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getHashtagList(): HashtagListEvent? = hashtagListNote.event as? HashtagListEvent
|
||||
|
||||
suspend fun hashtagListWithBackup(note: Note): Set<String> {
|
||||
val event = note.event as? HashtagListEvent ?: settings.backupHashtagList
|
||||
return event?.let { decryptionCache.hashtags(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flow: StateFlow<Set<String>> =
|
||||
getHashtagListFlow()
|
||||
.transformLatest { noteState ->
|
||||
emit(hashtagListWithBackup(noteState.note))
|
||||
}.onStart {
|
||||
emit(hashtagListWithBackup(hashtagListNote))
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Companion.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun follow(hashtags: List<String>): HashtagListEvent {
|
||||
val hashtagList = getHashtagList()
|
||||
|
||||
return if (hashtagList == null) {
|
||||
HashtagListEvent.Companion.create(hashtags, true, signer)
|
||||
} else {
|
||||
HashtagListEvent.Companion.add(hashtagList, hashtags, true, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun follow(hashtag: String): HashtagListEvent {
|
||||
val hashtagList = getHashtagList()
|
||||
|
||||
return if (hashtagList == null) {
|
||||
HashtagListEvent.Companion.create(hashtag, true, signer)
|
||||
} else {
|
||||
HashtagListEvent.Companion.add(hashtagList, hashtag, true, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(hashtag: String): HashtagListEvent? {
|
||||
val hashtagList = getHashtagList()
|
||||
|
||||
return if (hashtagList != null) {
|
||||
HashtagListEvent.Companion.remove(hashtagList, hashtag, signer)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupHashtagList?.let { event ->
|
||||
Log.d("AccountRegisterObservers", "Loading saved Hashtag list ${event.toJson()}")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LocalCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
Log.d("AccountRegisterObservers", "Hashtag List Collector Start")
|
||||
getHashtagListFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Hashtag List for ${signer.pubKey}")
|
||||
(it.note.event as? HashtagListEvent)?.let {
|
||||
settings.updateHashtagListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
|
||||
class IndexerRelayListDecryptionCache(
|
||||
signer: NostrSigner,
|
||||
) : GenericRelayListCache<IndexerRelayListEvent>(signer)
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class IndexerRelayListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: IndexerRelayListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val indexerListNote = cache.getOrCreateAddressableNote(getIndexerRelayListAddress())
|
||||
|
||||
fun getIndexerRelayListAddress() = IndexerRelayListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getIndexerRelayListFlow(): StateFlow<NoteState> = indexerListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getIndexerRelayList(): IndexerRelayListEvent? = indexerListNote.event as? IndexerRelayListEvent
|
||||
|
||||
suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
|
||||
val event = note.event as? IndexerRelayListEvent
|
||||
return event?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
val flow =
|
||||
getIndexerRelayListFlow()
|
||||
.map { normalizeIndexerRelayListWithBackup(it.note) }
|
||||
.onStart { emit(normalizeIndexerRelayListWithBackup(indexerListNote)) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
suspend fun saveRelayList(indexerRelays: List<NormalizedRelayUrl>): IndexerRelayListEvent {
|
||||
val relayListForIndexer = getIndexerRelayList()
|
||||
|
||||
return if (relayListForIndexer != null && relayListForIndexer.tags.isNotEmpty()) {
|
||||
IndexerRelayListEvent.updateRelayList(
|
||||
earlierVersion = relayListForIndexer,
|
||||
relays = indexerRelays,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
IndexerRelayListEvent.create(
|
||||
relays = indexerRelays,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.muteList
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUserIdSet
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsers
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsersAndWords
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWordSet
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWords
|
||||
|
||||
class MuteListDecryptionCache(
|
||||
val signer: NostrSigner,
|
||||
) {
|
||||
val cachedPrivateLists = PrivateTagArrayEventCache<MuteListEvent>(signer)
|
||||
|
||||
fun cachedUsersAndWords(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsersAndWords()
|
||||
|
||||
fun cachedUsers(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsers()
|
||||
|
||||
fun cachedUserIdSet(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUserIdSet()
|
||||
|
||||
fun cachedWords(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWords()
|
||||
|
||||
fun cachedWordSet(event: MuteListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWordSet()
|
||||
|
||||
suspend fun mutedUsersAndWords(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsersAndWords()
|
||||
|
||||
suspend fun mutedUsers(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsers()
|
||||
|
||||
suspend fun mutedUserIdSet(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedUserIdSet()
|
||||
|
||||
suspend fun mutedWords(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedWords()
|
||||
|
||||
suspend fun mutedWordSet(event: MuteListEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet()
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.muteList
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MuteListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: MuteListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val muteListNote = cache.getOrCreateAddressableNote(getMuteListAddress())
|
||||
|
||||
fun getMuteListAddress() = MuteListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getMuteListFlow(): StateFlow<NoteState> = muteListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getMuteList(): MuteListEvent? = muteListNote.event as? MuteListEvent
|
||||
|
||||
suspend fun muteListWithBackup(note: Note): List<MuteTag> {
|
||||
val event = note.event as? MuteListEvent ?: settings.backupMuteList
|
||||
return event?.let { decryptionCache.mutedUsersAndWords(it) } ?: emptyList()
|
||||
}
|
||||
|
||||
val flow =
|
||||
getMuteListFlow()
|
||||
.map { muteListWithBackup(it.note) }
|
||||
.onStart { emit(muteListWithBackup(muteListNote)) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
suspend fun hideUser(pubkeyHex: String): MuteListEvent {
|
||||
val muteList = getMuteList()
|
||||
|
||||
return if (muteList != null) {
|
||||
MuteListEvent.add(
|
||||
earlierVersion = muteList,
|
||||
mute = UserTag(pubkeyHex),
|
||||
isPrivate = true,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
MuteListEvent.create(
|
||||
mute = UserTag(pubkeyHex),
|
||||
isPrivate = true,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showUser(pubkeyHex: String): MuteListEvent? {
|
||||
val muteList = getMuteList()
|
||||
|
||||
return if (muteList != null) {
|
||||
MuteListEvent.remove(
|
||||
earlierVersion = muteList,
|
||||
mute = UserTag(pubkeyHex),
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun hideWord(word: String): MuteListEvent {
|
||||
val muteList = getMuteList()
|
||||
|
||||
return if (muteList != null) {
|
||||
MuteListEvent.add(
|
||||
earlierVersion = muteList,
|
||||
mute = WordTag(word),
|
||||
isPrivate = true,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
MuteListEvent.create(
|
||||
mute = WordTag(word),
|
||||
isPrivate = true,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showWord(word: String): MuteListEvent? {
|
||||
val muteList = getMuteList()
|
||||
|
||||
return if (muteList != null) {
|
||||
MuteListEvent.remove(
|
||||
earlierVersion = muteList,
|
||||
mute = WordTag(word),
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupMuteList?.let { event ->
|
||||
Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LocalCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
Log.d("AccountRegisterObservers", "Mute List Collector Start")
|
||||
getMuteListFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Updating Mute List for ${signer.pubKey}")
|
||||
(it.note.event as? MuteListEvent)?.let {
|
||||
settings.updateMuteList(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
|
||||
|
||||
class ProxyRelayListDecryptionCache(
|
||||
signer: NostrSigner,
|
||||
) : GenericRelayListCache<ProxyRelayListEvent>(signer)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user