mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
2941 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
3b7344e894 | ||
|
|
d307bb0e49 | ||
|
|
1bbcb97d54 | ||
|
|
3a9637ccb9 | ||
|
|
071da53a6a | ||
|
|
9d190f93bb | ||
|
|
7c65a93896 | ||
|
|
d9b96d2fd5 | ||
|
|
3333fdc237 | ||
|
|
e178ff988d | ||
|
|
df649445d0 | ||
|
|
a38346c7d3 | ||
|
|
71da587426 | ||
|
|
6ecb5ecc47 | ||
|
|
b2a19efcd0 | ||
|
|
1123586ef6 | ||
|
|
ab037e61aa | ||
|
|
4a43b57f79 | ||
|
|
f81c83326c | ||
|
|
fdd1dd1855 | ||
|
|
9f3d4db9dd | ||
|
|
c23a9f8ad6 | ||
|
|
36b0c8acb5 | ||
|
|
dc12789166 | ||
|
|
942b0fa531 | ||
|
|
8ae7f31c3d | ||
|
|
bc1bbc2f70 | ||
|
|
632c78792a | ||
|
|
e2a719164f | ||
|
|
ff94f45d04 | ||
|
|
7e1b1c7d2c | ||
|
|
f6f9d82665 | ||
|
|
3638059c23 | ||
|
|
a781e733f2 | ||
|
|
56d6376efc | ||
|
|
3ac9d3a4f7 | ||
|
|
521da9a774 | ||
|
|
18ebdaf96d | ||
|
|
19d394a383 | ||
|
|
b198b4c081 | ||
|
|
28bc46f120 | ||
|
|
7a6de4ea49 | ||
|
|
bc108875e6 | ||
|
|
aa38b41c58 | ||
|
|
a4c26b1fd8 | ||
|
|
550cfe8bb4 | ||
|
|
56664afa4e | ||
|
|
90c9deecd4 | ||
|
|
add65b094d | ||
|
|
c7a4161799 | ||
|
|
f6a16e4394 | ||
|
|
44a615020d | ||
|
|
288aafc3a2 | ||
|
|
aa97c7ee9b | ||
|
|
51a6eb4974 | ||
|
|
f8dfc06a06 | ||
|
|
2d4db9fea8 | ||
|
|
34b743146e | ||
|
|
13eb8b1abc | ||
|
|
e98a77b37f | ||
|
|
6a17a8a871 | ||
|
|
3ce726bbb7 | ||
|
|
593bf24f5b | ||
|
|
7a857711eb | ||
|
|
420e325d1f | ||
|
|
610657a9b6 | ||
|
|
ea891367e9 | ||
|
|
0c22e66e8f | ||
|
|
cd008d78a0 | ||
|
|
2c2e4a1694 | ||
|
|
d7790cd31e | ||
|
|
0bb571b52e | ||
|
|
c65a4b8fe7 | ||
|
|
abb0c73c71 | ||
|
|
224bcf97ec | ||
|
|
04c449072a | ||
|
|
b26aba92a1 | ||
|
|
5ce9df9dce | ||
|
|
7426600dc6 | ||
|
|
9d4a88d26f | ||
|
|
85aaf25561 | ||
|
|
1cd5845960 | ||
|
|
e38f6be20c | ||
|
|
c78c00acd9 | ||
|
|
11fa762028 | ||
|
|
599fddb369 | ||
|
|
76103ac057 | ||
|
|
6538cbd9da | ||
|
|
566ca0e390 | ||
|
|
c5f7ea801d | ||
|
|
469e7abd99 | ||
|
|
e540954125 | ||
|
|
f73052ed48 | ||
|
|
a9437d9001 | ||
|
|
02f6422bab | ||
|
|
da2b59af49 | ||
|
|
7acdf56e68 | ||
|
|
24be6cd90d | ||
|
|
afee0ddc53 | ||
|
|
c85b8a1d83 | ||
|
|
cfeedfa4e2 | ||
|
|
dd112d28ae | ||
|
|
fc27526113 | ||
|
|
666635811b | ||
|
|
4e43938f96 | ||
|
|
2a744205f0 | ||
|
|
7e6ca34d2a | ||
|
|
24f7991116 | ||
|
|
beb901120e | ||
|
|
0936df9851 | ||
|
|
9ceb8866ed | ||
|
|
c88b21b547 | ||
|
|
5c366d5cfc | ||
|
|
ea70d44ac7 | ||
|
|
67f10920f6 | ||
|
|
654632a585 | ||
|
|
794b05106b | ||
|
|
5812e290c9 | ||
|
|
c279c04858 | ||
|
|
01016525d3 | ||
|
|
72c6e93524 | ||
|
|
d6988ad4e1 | ||
|
|
4c1cd1c9ab | ||
|
|
9fb8d4821e | ||
|
|
8b052567c4 | ||
|
|
4a6ea550d6 | ||
|
|
5c88e7993f | ||
|
|
1bd1493bf4 | ||
|
|
404278a4e3 | ||
|
|
edca55b0b6 | ||
|
|
738187d32b | ||
|
|
d9de0d2798 | ||
|
|
b20515b1a0 | ||
|
|
e6d8291f07 | ||
|
|
18d08bf6e0 | ||
|
|
b2193f48d5 | ||
|
|
de391f03b1 | ||
|
|
c494cf8ac1 | ||
|
|
193e9a5adb | ||
|
|
4fb9c93cf0 | ||
|
|
6bd98201f8 | ||
|
|
cbc6697631 | ||
|
|
d48634ac0e | ||
|
|
5e2c8de15e | ||
|
|
926a721c53 | ||
|
|
5bc6da3bfc | ||
|
|
eae066b003 | ||
|
|
b74fa975ba | ||
|
|
7f2b8519f3 | ||
|
|
51335e06f1 | ||
|
|
0b6cd08c4a | ||
|
|
1bfe57da63 | ||
|
|
283d52ac2f | ||
|
|
d1646761d2 | ||
|
|
dc8209c90d | ||
|
|
420323bcb0 | ||
|
|
142aca40ce | ||
|
|
4fa3d60638 | ||
|
|
a9eeb04014 | ||
|
|
f09b00ff01 | ||
|
|
ceaae398c2 | ||
|
|
39f87af072 | ||
|
|
a34a3cbc83 | ||
|
|
f8afb4b783 | ||
|
|
fad4248539 | ||
|
|
13a53876d0 | ||
|
|
ea8affaebf | ||
|
|
df44e172ab | ||
|
|
8a50b3938d | ||
|
|
0245c907ff | ||
|
|
3bdc75b1be | ||
|
|
9d02361d01 | ||
|
|
6e6a13c5bf | ||
|
|
1f45a63081 | ||
|
|
11a5f4a67e | ||
|
|
52e79580bf | ||
|
|
561b19c447 | ||
|
|
9b21c3c964 | ||
|
|
baaa984d0d | ||
|
|
2b7ef79d21 | ||
|
|
22c96d2489 | ||
|
|
0c1187e4f5 | ||
|
|
7310ef175f | ||
|
|
53ba65cac2 | ||
|
|
58ed27dc75 | ||
|
|
065ba1c165 | ||
|
|
896d227fea | ||
|
|
6bac18c5df | ||
|
|
9ad62ef263 | ||
|
|
fe45e188bd | ||
|
|
334b948900 | ||
|
|
fb9ad2b457 | ||
|
|
921bb41596 | ||
|
|
ab4d01583b | ||
|
|
314531e938 | ||
|
|
991eed9bdf | ||
|
|
357981f266 | ||
|
|
119e9b7281 | ||
|
|
8ca53e9707 | ||
|
|
326e38f293 | ||
|
|
6232e2682f | ||
|
|
a2363221c6 | ||
|
|
1eef457b4e | ||
|
|
fbd88bdeab | ||
|
|
5d25bec1c9 | ||
|
|
201a6d4462 | ||
|
|
1345ad3745 | ||
|
|
b5f2a2b428 | ||
|
|
fe7b9b7930 | ||
|
|
b24d3d863d | ||
|
|
3b7252616b | ||
|
|
c76ed3bb53 | ||
|
|
cf775eebfb | ||
|
|
72018dc208 | ||
|
|
fef635ab39 | ||
|
|
7a243af45c | ||
|
|
94af0eb220 | ||
|
|
c0aea75c16 | ||
|
|
175b79b291 | ||
|
|
e5b8523bee | ||
|
|
1aecd9cf45 | ||
|
|
6600a49564 | ||
|
|
cdb65640ba | ||
|
|
0c9e76eeaf | ||
|
|
cfdbd0a9b6 | ||
|
|
09b8178a7c | ||
|
|
5ea793eb51 | ||
|
|
8cf04967c3 | ||
|
|
ef363457e8 | ||
|
|
e87394f3f7 | ||
|
|
86ebfe8564 | ||
|
|
a2b3cfb991 | ||
|
|
1b6aa621cd | ||
|
|
b7f73c6eab | ||
|
|
e35fb88ff1 | ||
|
|
6ecb3c8e1f | ||
|
|
ff20f0a266 | ||
|
|
a4cc6337f9 | ||
|
|
202b897029 | ||
|
|
79c174b92e | ||
|
|
bdd3f19b2c | ||
|
|
afcc775d1b | ||
|
|
8bbf308619 | ||
|
|
df378937fe | ||
|
|
02ab7a3f3f | ||
|
|
68ba9b3b91 | ||
|
|
f62833d1be | ||
|
|
3be246c9cc | ||
|
|
b28546b172 | ||
|
|
0fccfd7f80 | ||
|
|
3f35b57571 | ||
|
|
32b9b6c37a | ||
|
|
2342da114d | ||
|
|
ef0d77f8eb | ||
|
|
5559b69bdb | ||
|
|
eda25b4cfe | ||
|
|
9ce14e08fd | ||
|
|
b046fd91cb | ||
|
|
8c9800664f | ||
|
|
b90a57220d | ||
|
|
d16b0f58bb | ||
|
|
a538b66db3 | ||
|
|
f04631b0dd | ||
|
|
6e31cff99c | ||
|
|
1553640c18 | ||
|
|
68b8f9c82a | ||
|
|
f6cce42028 | ||
|
|
0cbddad9c0 | ||
|
|
b14154e2b5 | ||
|
|
c4250ccd35 | ||
|
|
31516964c8 | ||
|
|
4722b2a617 | ||
|
|
eca5b47ab0 | ||
|
|
d38b57025c | ||
|
|
fa7aa3cf24 | ||
|
|
d8e9b4773b | ||
|
|
f9a7b13ba1 | ||
|
|
ecbf0e404d | ||
|
|
c2f8df963a | ||
|
|
ff20960bb5 | ||
|
|
8dd1fc2077 | ||
|
|
bb2fb2b103 | ||
|
|
00a9c49915 | ||
|
|
d2872cc8bb | ||
|
|
d33a1ce14f | ||
|
|
31958215be | ||
|
|
bbbb614718 | ||
|
|
776a23c256 | ||
|
|
0854bd34ff | ||
|
|
1738a775ef | ||
|
|
a6953872ea | ||
|
|
d48714456c | ||
|
|
c25aad482b | ||
|
|
cbebfd263b | ||
|
|
89dbe82191 | ||
|
|
7bb72d0c2d | ||
|
|
9be4895080 | ||
|
|
6250db01d1 | ||
|
|
48f9045f1b | ||
|
|
818ca7e39e | ||
|
|
e8675b8e45 | ||
|
|
cef7e17447 | ||
|
|
6b15a0db8e | ||
|
|
50c5845a11 | ||
|
|
1b7ba3de01 | ||
|
|
712063f5d2 | ||
|
|
d92f23e274 | ||
|
|
3b7f530c0b | ||
|
|
623a8d377c | ||
|
|
79489d0b07 | ||
|
|
827512b225 | ||
|
|
6acfadeb9b | ||
|
|
e159af2cd7 | ||
|
|
89c2e9d2e0 | ||
|
|
06f6ab6719 | ||
|
|
98c48e8b6b | ||
|
|
25cde455d8 | ||
|
|
ef0fdf553c | ||
|
|
719b950272 | ||
|
|
2d02fad6b9 | ||
|
|
a39db5bf7b | ||
|
|
7fd37367fc | ||
|
|
e1c134830e | ||
|
|
621d1c7731 | ||
|
|
7475143506 | ||
|
|
2509d639bd | ||
|
|
85dd5cf698 | ||
|
|
274ce6ad77 | ||
|
|
0e8d2fc33a | ||
|
|
b88723b68b | ||
|
|
638dba770d | ||
|
|
4d7de6bc19 | ||
|
|
fbf676bdb2 | ||
|
|
793780f02c | ||
|
|
adf31ed115 | ||
|
|
96e434bfcf | ||
|
|
c7563c938d | ||
|
|
4380393c5b | ||
|
|
8125a7dabb | ||
|
|
e898d58239 | ||
|
|
29a43f82e6 | ||
|
|
469b9c6acb | ||
|
|
38d1bf9aec | ||
|
|
18b57b8ac8 | ||
|
|
7fc43c96d6 | ||
|
|
fc7d3a9519 | ||
|
|
1667a78bb9 | ||
|
|
5fbd6c25d0 | ||
|
|
d079d511e8 | ||
|
|
6e1418cd54 | ||
|
|
cd84c07fcc | ||
|
|
6eb2fbfa2f | ||
|
|
fc6f460063 | ||
|
|
442cdfdf2a | ||
|
|
539433014e | ||
|
|
d3f54a7082 | ||
|
|
d3a0ae743a | ||
|
|
6690d5391c | ||
|
|
d61d684a27 | ||
|
|
4f84fad0cd | ||
|
|
cd32c4db72 | ||
|
|
a71ce69cab | ||
|
|
b45f9bd460 | ||
|
|
75ac17b57d | ||
|
|
fa4745038f | ||
|
|
0182011487 | ||
|
|
37fdb8b2aa | ||
|
|
9ade18e1c1 | ||
|
|
aff6588791 | ||
|
|
4274d2ddbd | ||
|
|
63600f4782 | ||
|
|
5deb9af477 | ||
|
|
38e701a363 | ||
|
|
6e6fa66c53 | ||
|
|
1f60d39cbf | ||
|
|
8ba474e79a | ||
|
|
1123b3b3c1 | ||
|
|
bffb9f3778 | ||
|
|
e11961695f | ||
|
|
042579ddfb | ||
|
|
d0aa7430ca | ||
|
|
3434c31487 | ||
|
|
7eefbee0e3 | ||
|
|
ed4d867622 | ||
|
|
27db2b91ab | ||
|
|
a2316b6ed0 | ||
|
|
a26b5490b2 | ||
|
|
9990f458bf | ||
|
|
0b40b6d1d8 | ||
|
|
5a1c9f5a4a | ||
|
|
eb6d31cf2b | ||
|
|
2c5e07de87 | ||
|
|
8d70664cc1 | ||
|
|
102a34afca | ||
|
|
c843e07709 | ||
|
|
3faa4983f0 | ||
|
|
6f9dbbb8c3 | ||
|
|
62a114b981 | ||
|
|
ff5612203d | ||
|
|
6f0e4f1f19 | ||
|
|
8f5820f46d | ||
|
|
3086d3957d | ||
|
|
c00319812a | ||
|
|
67202c32d4 | ||
|
|
64909bfb32 | ||
|
|
c1756b75a7 | ||
|
|
bdf41f53fb | ||
|
|
3b982b8962 | ||
|
|
fd39ff24e1 | ||
|
|
e659153730 | ||
|
|
f3c4b3255b | ||
|
|
f35f54166d | ||
|
|
538c0493ed | ||
|
|
ceca149eb7 | ||
|
|
97cdc0bc7a | ||
|
|
f2a8e51b20 | ||
|
|
8d7a3f4d5e | ||
|
|
c087042f7d | ||
|
|
644d2fc2bb | ||
|
|
b819f24790 | ||
|
|
ea33cc77ed | ||
|
|
27fbf1c1ed | ||
|
|
3226e4e024 | ||
|
|
090b643f43 | ||
|
|
943a4260ff | ||
|
|
9867ac1689 | ||
|
|
d26de39749 | ||
|
|
1072b7a5c5 | ||
|
|
220ce75f19 | ||
|
|
bc180ae210 | ||
|
|
5910ef199f | ||
|
|
499939ed68 | ||
|
|
d15beb2ae5 | ||
|
|
87fafd9451 | ||
|
|
bfbfcb6ed2 | ||
|
|
ffc0a7c6ed | ||
|
|
77789379c0 | ||
|
|
a0a10b2cd0 | ||
|
|
d59b98089a | ||
|
|
160e52bd91 | ||
|
|
d7e1a80465 | ||
|
|
a5496445d2 | ||
|
|
b75c3e3031 | ||
|
|
e9830c61aa | ||
|
|
25f28d38d5 | ||
|
|
410d6bd690 | ||
|
|
99270662ee | ||
|
|
e40cd8d932 | ||
|
|
9858d98722 | ||
|
|
6ab5488852 | ||
|
|
9651563b16 | ||
|
|
9012bdad27 | ||
|
|
ef859c85a4 | ||
|
|
959b2ac4e4 | ||
|
|
55a6f8829e | ||
|
|
940fa2ee8d | ||
|
|
92d9c682f8 | ||
|
|
f5a1007f88 | ||
|
|
d83acab84b | ||
|
|
f6e5af3e98 | ||
|
|
ac56d02b9d | ||
|
|
46a120450e | ||
|
|
33e0107d3b | ||
|
|
8b3e3e7af8 | ||
|
|
84faa7557e | ||
|
|
f7ab925b1d | ||
|
|
204eaa4606 | ||
|
|
1c249eed20 | ||
|
|
3cc32ecd9a | ||
|
|
0a20d5484b | ||
|
|
6e4f1269dd | ||
|
|
eba0837e52 | ||
|
|
d682518ddb | ||
|
|
8c43f3492b | ||
|
|
688abee205 | ||
|
|
d96ea0f8a2 | ||
|
|
b19b60128f | ||
|
|
78be5a9ecc | ||
|
|
36d2a3e42c | ||
|
|
fa7c2efaab | ||
|
|
6edc634b82 | ||
|
|
6bdf3e2625 | ||
|
|
2d17200f03 | ||
|
|
c74176684f | ||
|
|
1b9742597a | ||
|
|
f949d5624e | ||
|
|
2bc2890d08 | ||
|
|
f3f8bc1b65 | ||
|
|
99e9514d6c | ||
|
|
8ade5b7e5f | ||
|
|
e292affbe6 | ||
|
|
fbf4f6dd08 | ||
|
|
b2508b3db5 | ||
|
|
1abdb42552 | ||
|
|
2c54ba1a92 | ||
|
|
f941397cc4 | ||
|
|
69e31be37f | ||
|
|
86c328c8a7 | ||
|
|
837865a699 | ||
|
|
1014e29289 | ||
|
|
c1c5bc2039 | ||
|
|
2d6aab954f | ||
|
|
eadb28321c | ||
|
|
99850573f7 | ||
|
|
45974fb09b | ||
|
|
f2dc2ef0d0 | ||
|
|
8641bd36c3 | ||
|
|
a034d2f96e | ||
|
|
fa5d992010 | ||
|
|
0d47e8b823 | ||
|
|
e289730be5 | ||
|
|
91b0d5b7fc | ||
|
|
0dbd58d8d7 | ||
|
|
e094b56b72 | ||
|
|
efb9d9268b | ||
|
|
82c4bf89df | ||
|
|
4a069fa73b | ||
|
|
1cda191035 | ||
|
|
e5d4b2a145 | ||
|
|
6ff5787e60 | ||
|
|
ffb73b1780 | ||
|
|
9a6a857a11 | ||
|
|
abb9edc46b | ||
|
|
5d78d9b046 | ||
|
|
f372a256c5 | ||
|
|
e2fe1538b0 | ||
|
|
7560a737cd | ||
|
|
24a7812432 | ||
|
|
df99c56c4a | ||
|
|
e984487d9e | ||
|
|
afb5169ede | ||
|
|
64d6d6753c | ||
|
|
0f69030cdf | ||
|
|
dd81c51fab | ||
|
|
6cd04a7617 | ||
|
|
632bd77db3 | ||
|
|
4938ba03a6 | ||
|
|
e6da340879 | ||
|
|
c796cbd7be | ||
|
|
2038994613 | ||
|
|
5f76cdf721 | ||
|
|
b8619e3b61 | ||
|
|
da41fbb4c9 | ||
|
|
0dd553ae55 | ||
|
|
563663c131 | ||
|
|
dc3574bb6e | ||
|
|
f6ffb87e20 | ||
|
|
3b3ca06c1c | ||
|
|
f73c9b5773 | ||
|
|
5848255e72 | ||
|
|
1cf828b165 | ||
|
|
2e1f7d6587 | ||
|
|
c6977d97d3 | ||
|
|
d12e886e9e | ||
|
|
8299f4cfca | ||
|
|
432b1e4902 | ||
|
|
e9fd62dc26 | ||
|
|
f42ec3c149 | ||
|
|
43e1e0f23e | ||
|
|
7bc393143c | ||
|
|
23718f51dd | ||
|
|
8b2efecfbe | ||
|
|
9081d5a54b | ||
|
|
709e3bdd3a | ||
|
|
1f66ef717b | ||
|
|
cd2b5d78a1 | ||
|
|
2f45067949 | ||
|
|
5a680beabb | ||
|
|
779ce110ba | ||
|
|
43bcee5642 | ||
|
|
64c4f7dc95 | ||
|
|
2b6cd07805 | ||
|
|
875d538581 | ||
|
|
f0c53efebd | ||
|
|
8c5aea46f1 | ||
|
|
e507e0bb6a | ||
|
|
2f3df616f3 | ||
|
|
75934b5b98 | ||
|
|
4cbdf11ecb | ||
|
|
bbf0d36cdf | ||
|
|
2bef5f015d | ||
|
|
708bd00d0d | ||
|
|
2158e36032 | ||
|
|
08a103d332 | ||
|
|
57bcae6a0d | ||
|
|
4897c28ed3 | ||
|
|
b3211cbb35 | ||
|
|
fa6adcde75 | ||
|
|
ad65e02fc9 | ||
|
|
249ac4e039 | ||
|
|
a92ef7a375 | ||
|
|
e65faa2076 | ||
|
|
5f53fc6aa9 | ||
|
|
218e133857 | ||
|
|
fcd61d87be | ||
|
|
fb280f4880 | ||
|
|
5342c4cfba | ||
|
|
dd106f315f | ||
|
|
3ddd06fafc | ||
|
|
4fb68dd014 | ||
|
|
a301421915 | ||
|
|
16c171ec40 | ||
|
|
6725114b48 | ||
|
|
861ad218c8 | ||
|
|
b694ac7259 | ||
|
|
81cc985e3b | ||
|
|
786802b708 | ||
|
|
c53fc03157 | ||
|
|
48a6923537 | ||
|
|
c34811e05b | ||
|
|
3f500ef5b3 | ||
|
|
cbce22c994 | ||
|
|
bc7a578cfe | ||
|
|
af784a5bda | ||
|
|
b1a355691a | ||
|
|
4f2b2992e0 | ||
|
|
77d30b6e7c | ||
|
|
228de99ccf | ||
|
|
dfec7ae7f5 | ||
|
|
f8c4242843 | ||
|
|
34150b6725 | ||
|
|
21a18cfa38 | ||
|
|
8fa6d1d490 | ||
|
|
c23510e05e | ||
|
|
1cc4fd5183 | ||
|
|
7468352795 | ||
|
|
238e577b93 | ||
|
|
0653a69bbd | ||
|
|
87fcba02b3 | ||
|
|
3fb8a5938f | ||
|
|
f2a9c2b34b | ||
|
|
cf1e5f952d | ||
|
|
b6e096ec96 | ||
|
|
ee4e20bac8 | ||
|
|
4d2a7bbc32 | ||
|
|
600aab5e8d | ||
|
|
1efef5887a | ||
|
|
3495866017 | ||
|
|
0755bd2474 | ||
|
|
d257f7e253 | ||
|
|
7497237763 | ||
|
|
d506cf13a9 | ||
|
|
05454f5420 | ||
|
|
8d8990899b | ||
|
|
fd37826663 | ||
|
|
0084c2b532 | ||
|
|
c686e775bf | ||
|
|
6e0701b65d | ||
|
|
417c29bd72 | ||
|
|
3eb6983a94 | ||
|
|
d4ba135368 | ||
|
|
7f35d7d416 | ||
|
|
9f4a30657b | ||
|
|
86aab2e842 | ||
|
|
73e3b60721 | ||
|
|
fdb0830d1b | ||
|
|
bc833fadcf | ||
|
|
4d2c17cd1c | ||
|
|
53987336c0 | ||
|
|
d36efd6a2c | ||
|
|
0d92384d8c | ||
|
|
466929a0f7 | ||
|
|
85059d775b | ||
|
|
328ed226c6 | ||
|
|
e305f3c198 | ||
|
|
f78a252c8f | ||
|
|
6e65bdbb91 | ||
|
|
9639514a82 | ||
|
|
87fabd54de | ||
|
|
529403d92f | ||
|
|
e2d86c1727 | ||
|
|
11ff0736e4 | ||
|
|
fbc63a6df1 | ||
|
|
7dccac41e9 | ||
|
|
8ebad524ff | ||
|
|
bf67ff64b4 | ||
|
|
85ecaf18cc | ||
|
|
3c36f52baf | ||
|
|
782926fbea | ||
|
|
21d141c5c2 | ||
|
|
de17aab7ab | ||
|
|
d1b0707b54 | ||
|
|
2ea80272e3 | ||
|
|
6a7d33e58f | ||
|
|
880915e702 | ||
|
|
d6fd407632 | ||
|
|
1c0854eeaa | ||
|
|
9eefb65047 | ||
|
|
31c9041461 | ||
|
|
c4d6b1a5d1 | ||
|
|
ad03d355b0 | ||
|
|
5ad86e5604 | ||
|
|
6afd9a0191 | ||
|
|
58ff0b5500 | ||
|
|
3f33871c9c | ||
|
|
ca4cc93b31 | ||
|
|
49c2d4f486 | ||
|
|
b9c3123f18 | ||
|
|
452911bf27 | ||
|
|
173e82d236 | ||
|
|
2819c71e85 | ||
|
|
3eac579638 | ||
|
|
d606cef90e | ||
|
|
de8870f240 | ||
|
|
13031223a4 | ||
|
|
553500460a | ||
|
|
a7e601beee | ||
|
|
d21792a46f | ||
|
|
b8b9aaf4aa | ||
|
|
dd160ecb71 | ||
|
|
2ee4b24c9b | ||
|
|
606c483d09 | ||
|
|
b1d1c4089c | ||
|
|
5740b75a64 | ||
|
|
8c9971d9f2 | ||
|
|
860c3773c3 | ||
|
|
8ff4ac3709 | ||
|
|
5b77e39c8b | ||
|
|
5886c866d3 | ||
|
|
96f29fc5ca | ||
|
|
90175198f0 | ||
|
|
4b9a55e178 | ||
|
|
0429c4dc3a | ||
|
|
e3f3350c85 | ||
|
|
5cd1c5ab90 | ||
|
|
d982f029e4 | ||
|
|
f44bc02767 | ||
|
|
2e096069b9 | ||
|
|
f10dd38126 | ||
|
|
74b1cad578 | ||
|
|
dbcf59779e | ||
|
|
f4cee92702 | ||
|
|
b70407f447 | ||
|
|
444857ad95 | ||
|
|
ae291d278f | ||
|
|
0d44c44c6e | ||
|
|
a03f9d2b98 | ||
|
|
49a090da00 | ||
|
|
c8d11fdcac | ||
|
|
33342b85d1 | ||
|
|
a148da4b7b | ||
|
|
1b93fc57cb | ||
|
|
2514b74883 | ||
|
|
a97ade41fa | ||
|
|
e44ee1e549 | ||
|
|
b170d04d5e | ||
|
|
dc55d71486 | ||
|
|
dd071dc65a | ||
|
|
dd7427971c | ||
|
|
d715ea44df | ||
|
|
7af2fcdb27 | ||
|
|
879f7cf8e3 | ||
|
|
88033c2acd | ||
|
|
e7fc5545ba | ||
|
|
e7240c0acf | ||
|
|
1fa8cd95dc | ||
|
|
9424147a08 | ||
|
|
26356d6060 | ||
|
|
56bc10b513 | ||
|
|
67a1aae1d0 | ||
|
|
b88ab06154 | ||
|
|
7d9784ee44 | ||
|
|
8034fd0975 | ||
|
|
42cc04c194 | ||
|
|
52c6ce4849 | ||
|
|
6553f3bec5 | ||
|
|
03a9ec8ed2 | ||
|
|
133d7d5ce0 | ||
|
|
215989108b | ||
|
|
ed7dcf94f8 | ||
|
|
b2c7f150ec | ||
|
|
c85d74a688 | ||
|
|
cbf23fedf1 | ||
|
|
5ed64bd5b5 | ||
|
|
b79f9c8395 | ||
|
|
02da606461 | ||
|
|
7074446cae | ||
|
|
4746b5b18a | ||
|
|
103a61f35c | ||
|
|
d9c48d4039 | ||
|
|
9479e86b66 | ||
|
|
bb7ec1ee4b | ||
|
|
7a213545a2 | ||
|
|
48f27f656d | ||
|
|
47176421e0 | ||
|
|
9c84a538d6 | ||
|
|
8bc1c2ec68 | ||
|
|
b94c1e2792 | ||
|
|
74f0393706 | ||
|
|
0ec4b9b59f | ||
|
|
1592d55db5 | ||
|
|
629c8b8167 | ||
|
|
9d8f70e04b | ||
|
|
f75104e616 | ||
|
|
c9d59fd5ac | ||
|
|
d41eb4ce31 | ||
|
|
60e87d943a | ||
|
|
1deed59d5b | ||
|
|
bbc4ec2625 | ||
|
|
5109b65e37 | ||
|
|
c0a9ce8e21 | ||
|
|
bdff8c6c0d | ||
|
|
a3ac8a445d | ||
|
|
84d4d68116 | ||
|
|
27248bff17 | ||
|
|
dc9a9f288b | ||
|
|
88662a4e55 | ||
|
|
9d269f7cba | ||
|
|
04fe439e0b | ||
|
|
72503b9061 | ||
|
|
f7ab963db9 | ||
|
|
f97128e11c | ||
|
|
e2d4107297 | ||
|
|
18709a1b22 | ||
|
|
ddfe2abb42 | ||
|
|
0cf82f7edd | ||
|
|
d1c40efd90 | ||
|
|
68e90ce3ed | ||
|
|
95e19cf681 | ||
|
|
3298fd4493 | ||
|
|
248bdffd50 | ||
|
|
cce7a20918 | ||
|
|
845c99b798 | ||
|
|
b13b832696 | ||
|
|
eb6e235f1b | ||
|
|
36c49b2171 | ||
|
|
5cd3096221 | ||
|
|
5bdc59d0e3 | ||
|
|
be43f2025f | ||
|
|
0bb39f91b9 | ||
|
|
63a7b44264 | ||
|
|
b5341f67e0 | ||
|
|
3a2045eccc | ||
|
|
c78239a6b7 | ||
|
|
4be4306540 | ||
|
|
48887f23c1 | ||
|
|
103809d35b | ||
|
|
9d92976eb9 | ||
|
|
d3d19b497b | ||
|
|
316c32f076 | ||
|
|
6f27201244 | ||
|
|
e56377f8c3 | ||
|
|
44e04864ac | ||
|
|
eea727570c | ||
|
|
54155a3c30 | ||
|
|
34d373c293 | ||
|
|
d7563032c4 | ||
|
|
ed75489975 | ||
|
|
145b0d8f0a | ||
|
|
eae07e2fe0 | ||
|
|
6b60b1434f | ||
|
|
3ff4db264a | ||
|
|
21f042fa95 | ||
|
|
e26bbc2e61 | ||
|
|
5135026e6a | ||
|
|
a72f5ae01f | ||
|
|
f2ed2d94c4 | ||
|
|
75d703ac0a | ||
|
|
277bf0a603 | ||
|
|
1ac990b67c | ||
|
|
e59f676d47 | ||
|
|
312d82fdf6 | ||
|
|
08659738bd | ||
|
|
f1b0958903 | ||
|
|
998aa2aed1 | ||
|
|
370a18f716 | ||
|
|
6638defbb9 | ||
|
|
d88eb014b7 | ||
|
|
14e9a91d40 | ||
|
|
3ed45f282c | ||
|
|
2ff090dac1 | ||
|
|
281d86b4bf | ||
|
|
504e55a93f | ||
|
|
c7b2c20119 | ||
|
|
cdd620987b | ||
|
|
2c086f76e2 | ||
|
|
99965ecd2d | ||
|
|
ba7c59fdd5 | ||
|
|
76a93f84c3 | ||
|
|
3e102c064b | ||
|
|
e0ec8a741c | ||
|
|
26a1624399 | ||
|
|
f1388b46d5 | ||
|
|
0f24e458d8 | ||
|
|
b8f3625954 | ||
|
|
0babc46f7c | ||
|
|
6d368148cc | ||
|
|
3241f0ada1 | ||
|
|
49ad972af8 | ||
|
|
e061575454 | ||
|
|
694642b42b | ||
|
|
f4684ed078 | ||
|
|
db12ff0532 | ||
|
|
13d9c1ab10 | ||
|
|
ac8f0e8600 | ||
|
|
21472daab5 | ||
|
|
c6a00386c8 | ||
|
|
9e3aa31c25 | ||
|
|
47987fac0f | ||
|
|
46c782cf37 | ||
|
|
fd67f4875d | ||
|
|
4e2ff591e6 | ||
|
|
0e46145e2d | ||
|
|
383d859544 | ||
|
|
81400eb863 | ||
|
|
422446f53e | ||
|
|
f1e721f323 | ||
|
|
0fc72eb84d | ||
|
|
9234f4b7a8 | ||
|
|
0198d56099 | ||
|
|
8f31c60cd1 | ||
|
|
7cb17954d9 | ||
|
|
baf35b202f | ||
|
|
7b7e3624ac | ||
|
|
d27b9ae5a8 | ||
|
|
4c48463091 | ||
|
|
eb08d485e1 | ||
|
|
6e99e6f4a2 | ||
|
|
d269e9e597 | ||
|
|
f8e7dd78d9 | ||
|
|
0be1f89368 | ||
|
|
5c7e9258c1 | ||
|
|
f877b6ff68 | ||
|
|
f5f43c5094 | ||
|
|
ec867ae8a2 | ||
|
|
de61f7f383 | ||
|
|
ed6c1e62e4 | ||
|
|
620b2bfa9f | ||
|
|
6715d48a9d | ||
|
|
4291a58f85 | ||
|
|
00cffc8b6b | ||
|
|
7143d78663 | ||
|
|
eaaa9a6446 | ||
|
|
41270c3a98 | ||
|
|
a08a052b30 | ||
|
|
9403f12d2d | ||
|
|
89fb83cd9a | ||
|
|
2355a95202 | ||
|
|
53a320b4dc | ||
|
|
2de3d19a34 | ||
|
|
57430c4366 | ||
|
|
941ed77f22 | ||
|
|
005b082873 | ||
|
|
9f219e31ab | ||
|
|
9101fb1e9e | ||
|
|
c215beeb8e | ||
|
|
20b730318f | ||
|
|
abef3d13f2 | ||
|
|
77eb066362 | ||
|
|
ec4981852b | ||
|
|
56c2c60c58 | ||
|
|
48aa9f950d | ||
|
|
37ae06347f | ||
|
|
78616c07b1 | ||
|
|
73eff8a6c3 | ||
|
|
4afb0027fb | ||
|
|
1cedc8a6c8 | ||
|
|
216ee59fae | ||
|
|
adc4599adf | ||
|
|
8ed960530c | ||
|
|
8f13a76870 | ||
|
|
adaab4ced8 | ||
|
|
5a2e149fa0 | ||
|
|
560c1f797c | ||
|
|
2d5be92f34 | ||
|
|
e9285fdac0 | ||
|
|
4f12c127af | ||
|
|
be7522382f | ||
|
|
987fb02be1 | ||
|
|
bbd8a34f44 | ||
|
|
720ebfd0ea | ||
|
|
159228972c | ||
|
|
2a65944eeb | ||
|
|
e7902c4565 | ||
|
|
f12588ef7c | ||
|
|
5fc197a40b | ||
|
|
64608f771e | ||
|
|
a9cebaf3d9 | ||
|
|
6c33dae498 | ||
|
|
e42d2dca04 | ||
|
|
35cd9cb6c5 | ||
|
|
2ce16325a1 | ||
|
|
275aeb7833 | ||
|
|
34737be5eb | ||
|
|
6fb5c873cc | ||
|
|
e0ffba5295 | ||
|
|
82f03e0769 | ||
|
|
b223cc4028 | ||
|
|
550a9abdbb | ||
|
|
a121d1b301 | ||
|
|
e794ff44a1 | ||
|
|
a22b6db130 | ||
|
|
1f7fc236db | ||
|
|
1edaea7905 | ||
|
|
e8cae710c7 | ||
|
|
489d92f9bb | ||
|
|
4d4c568afc | ||
|
|
e452c33a98 | ||
|
|
fdfd875580 | ||
|
|
a6e8ee20ae | ||
|
|
065dba2431 | ||
|
|
5f581c2f5e | ||
|
|
f7bfb68e7c | ||
|
|
2371e0678a | ||
|
|
97c78aaa3c | ||
|
|
9026c877ac | ||
|
|
238ade9ccb | ||
|
|
016bd1ed81 | ||
|
|
ed21d4cafb | ||
|
|
ecc0f39f7d | ||
|
|
26846fa91a | ||
|
|
fe3a0568f2 | ||
|
|
56252d33f1 | ||
|
|
fccfb2aaba | ||
|
|
7b55b69c12 | ||
|
|
23fad2a818 | ||
|
|
2dc23b601a | ||
|
|
504980d263 | ||
|
|
7cf7c9f082 | ||
|
|
dd8904de15 | ||
|
|
d1c43f8147 | ||
|
|
68506fd6af | ||
|
|
b795ca06d1 | ||
|
|
8695acb742 | ||
|
|
ed491f5e3a | ||
|
|
3a76c2a2de | ||
|
|
91b3b9e0e9 | ||
|
|
fb280650a5 | ||
|
|
e4baebf2f9 | ||
|
|
1a33da2537 | ||
|
|
3734ee58ea | ||
|
|
9554b36f80 | ||
|
|
5225c6ad07 | ||
|
|
c7be0e4a95 | ||
|
|
d4f060d509 | ||
|
|
9ba9ddee50 | ||
|
|
7bbdd5e877 | ||
|
|
6cc65ce84a | ||
|
|
6c711f8acc | ||
|
|
acefff80ee | ||
|
|
0e5b5d657f | ||
|
|
c2532322fd | ||
|
|
dc73aec334 | ||
|
|
aa909184fd | ||
|
|
205dd75e7e | ||
|
|
be9100dc5e | ||
|
|
b2e35cdb3e | ||
|
|
827e168bf2 | ||
|
|
a14d4e6f4e | ||
|
|
de61c0350e | ||
|
|
3e3c97076d | ||
|
|
f62c986016 | ||
|
|
04108e893d | ||
|
|
042394796a | ||
|
|
bfce57f686 | ||
|
|
5bb983ba5a | ||
|
|
debae02421 | ||
|
|
6fe93fdf33 | ||
|
|
3d225423ef | ||
|
|
49e1da8812 | ||
|
|
04addc4cba | ||
|
|
c5cf2c5442 | ||
|
|
ddb7e6b7f2 | ||
|
|
e5af34efca | ||
|
|
b3bdbbed98 | ||
|
|
656b066d3a | ||
|
|
4248fadc69 | ||
|
|
18281cbc49 | ||
|
|
1d67af328a | ||
|
|
72b4f6acef | ||
|
|
26273270ba | ||
|
|
db3824c3c7 | ||
|
|
d6de1d03d8 | ||
|
|
8e4b7c66b5 | ||
|
|
45c750069a | ||
|
|
a411a72024 | ||
|
|
219d163680 | ||
|
|
a4c9f42205 | ||
|
|
a8936c54d8 | ||
|
|
74875bb564 | ||
|
|
61d412d941 | ||
|
|
5bb1e47edf | ||
|
|
b023614b5f | ||
|
|
7245718003 | ||
|
|
ba1f98d930 | ||
|
|
ceeabffcb7 | ||
|
|
2a943493b3 | ||
|
|
332a2f41b6 | ||
|
|
ce2cfb7c86 | ||
|
|
41ef1abf0e | ||
|
|
fce27c3c55 | ||
|
|
6662966ab3 | ||
|
|
f458f00edc | ||
|
|
8f1fbe10e9 | ||
|
|
69efa78fbc | ||
|
|
64a07d8760 | ||
|
|
cb672d92d0 | ||
|
|
95bcbfdded | ||
|
|
ac269727bc | ||
|
|
b1a7d18c0a | ||
|
|
619b59357b | ||
|
|
86dcc3ccd9 | ||
|
|
6772360ae7 | ||
|
|
05cbbd79cb | ||
|
|
a80c03a2d2 | ||
|
|
6c3abe17ed | ||
|
|
7cd09261de | ||
|
|
6ef1bee8a0 | ||
|
|
66b8b04db3 | ||
|
|
3b06abb2bc | ||
|
|
138fafa204 | ||
|
|
9cbdbb8592 | ||
|
|
1c0ec93cbb | ||
|
|
c6fa2b5f80 | ||
|
|
dd55ad18e3 | ||
|
|
284da365a1 | ||
|
|
7a06c2e9c4 | ||
|
|
61449774a1 | ||
|
|
81a3baa41c | ||
|
|
4b7e58dd1c | ||
|
|
14ce42401b | ||
|
|
ecb8344287 | ||
|
|
9bc5549d76 | ||
|
|
8ac8894ccd | ||
|
|
efb9814f1b | ||
|
|
3582f587b3 | ||
|
|
4fbc9f6d53 | ||
|
|
e9ec1c45a0 | ||
|
|
b2b7cba352 | ||
|
|
393fbd0359 | ||
|
|
47ae42e3fe | ||
|
|
5db3ede09e | ||
|
|
eca420d789 | ||
|
|
5347373cde | ||
|
|
05596cb9bf | ||
|
|
3d1310295b | ||
|
|
35c780f81b | ||
|
|
b021920eaa | ||
|
|
2b27ac3aec | ||
|
|
c692c9125b | ||
|
|
7322fd7aae | ||
|
|
07a92cd70d | ||
|
|
21a797be9d | ||
|
|
fe49cbabce | ||
|
|
9f674a0088 | ||
|
|
b44e94cb16 | ||
|
|
a8da645be3 | ||
|
|
c6e6bf6032 | ||
|
|
7596f2a43e | ||
|
|
6b7bb209d4 | ||
|
|
fcdb863c2b | ||
|
|
4e5a9f7366 | ||
|
|
758f3636f3 | ||
|
|
7dafb8317a | ||
|
|
21a5827719 | ||
|
|
b9486eb3e6 | ||
|
|
dbbfaae02c | ||
|
|
44b25b7c7c | ||
|
|
0dabf3f2d3 | ||
|
|
a85ea70e4f | ||
|
|
a02519b021 | ||
|
|
04e70a7886 | ||
|
|
3bfaef2b45 | ||
|
|
e992e72f4d | ||
|
|
1c545ee33a | ||
|
|
ab93a39ad6 | ||
|
|
7f25e1c434 | ||
|
|
67b7bd3258 | ||
|
|
e2aaa8efe0 | ||
|
|
5356d869fd | ||
|
|
d87542a949 | ||
|
|
98d29ac6dc | ||
|
|
532048c35e | ||
|
|
386960dc62 | ||
|
|
ae3cc5da2a | ||
|
|
34c2ba4922 | ||
|
|
074a7d41fd | ||
|
|
7d4fbc3e04 | ||
|
|
a2d38381b1 | ||
|
|
19fb129f7c | ||
|
|
5bfc53136b | ||
|
|
1677606b15 | ||
|
|
8c10173b68 | ||
|
|
285ae056a2 | ||
|
|
8cdd3be085 | ||
|
|
8f586780a1 | ||
|
|
dc3c730e3b | ||
|
|
df823102da | ||
|
|
1ab857d72b | ||
|
|
78863b26d0 | ||
|
|
0f74bc7b74 | ||
|
|
e7893cacd5 | ||
|
|
8a18781048 | ||
|
|
ad52855f5a | ||
|
|
50fbde17df | ||
|
|
c6d47ca9c6 | ||
|
|
072b14bdda | ||
|
|
70964e680a | ||
|
|
954330064d | ||
|
|
3666a0b980 | ||
|
|
61f52ef62e | ||
|
|
74d66f2035 | ||
|
|
0de4cd5c18 | ||
|
|
d1bef11f88 | ||
|
|
3253b8528f | ||
|
|
81661c003a | ||
|
|
fbe395f071 | ||
|
|
d5cf64f048 | ||
|
|
7212d566c9 | ||
|
|
17490e948c | ||
|
|
b392ea0dca | ||
|
|
9fc0c37845 | ||
|
|
6db031b208 | ||
|
|
7246a545af | ||
|
|
c8365439ee | ||
|
|
003d6bf018 | ||
|
|
060286c292 | ||
|
|
270935a30a | ||
|
|
05e9a1ac3f | ||
|
|
9acad8b3f5 | ||
|
|
5a9d6929e5 | ||
|
|
e45f02c110 | ||
|
|
4f1ae72ab5 | ||
|
|
d4ed6661dd | ||
|
|
2976349114 | ||
|
|
b1cfa2219d | ||
|
|
df9b764c1d | ||
|
|
69941002df | ||
|
|
afc6a91c6b | ||
|
|
c8b9858e4d | ||
|
|
c4ea6f3619 | ||
|
|
872d70c175 | ||
|
|
5ad6a2c70b | ||
|
|
408b6793c0 | ||
|
|
43d03bc109 | ||
|
|
9377c902a1 | ||
|
|
57d97447f4 | ||
|
|
5493ce0dcf | ||
|
|
499149b7d9 | ||
|
|
3fd0c61c0e | ||
|
|
1d62d8ed73 | ||
|
|
2c3a7daa65 | ||
|
|
1771b6e667 | ||
|
|
853dcb2127 | ||
|
|
b25f346370 | ||
|
|
edd13fbe5b | ||
|
|
92c72e5a7b | ||
|
|
e8e676afd5 | ||
|
|
bb16ee7f0f | ||
|
|
4eb1c101b9 | ||
|
|
eb67b6e6cf | ||
|
|
29541bd1e3 | ||
|
|
08f94e9199 | ||
|
|
a7c7773a81 | ||
|
|
75f2d5710a | ||
|
|
2bff9fe891 | ||
|
|
4d2c405aea | ||
|
|
ca24afd23e | ||
|
|
47e124c7fc | ||
|
|
885aded701 | ||
|
|
e0729b7236 | ||
|
|
a058b75ee4 | ||
|
|
0a5e84fd3c | ||
|
|
6fa36255b2 | ||
|
|
3de52a6c0d | ||
|
|
6d17c8d79f | ||
|
|
26c4677b75 | ||
|
|
c5882a9f05 | ||
|
|
f99dc471a1 | ||
|
|
25e31b00d2 | ||
|
|
c1a59a1d21 | ||
|
|
e0d2a71b47 | ||
|
|
ef90b82396 | ||
|
|
9ec6e6d955 | ||
|
|
6b7ec2caa9 | ||
|
|
17130c81c8 | ||
|
|
0571312dca | ||
|
|
717c3bc45e | ||
|
|
c304f7c69d | ||
|
|
6d2a1ec1b9 | ||
|
|
59f7f5257b | ||
|
|
ba1f2dc17d | ||
|
|
820def620c | ||
|
|
9fef3e9e3e | ||
|
|
e8998a26d7 | ||
|
|
221ab1ca1a | ||
|
|
2f1e67779b | ||
|
|
96663b822c | ||
|
|
c7895d1cdd | ||
|
|
85762c581a | ||
|
|
e189a4d68d | ||
|
|
3306dd88d2 | ||
|
|
18952bc8a7 | ||
|
|
f6cfba6e1f | ||
|
|
fb37ccd14f | ||
|
|
3858c57816 | ||
|
|
572c11e847 | ||
|
|
bd70bd708b | ||
|
|
9a2ecfc85d | ||
|
|
f20fd1aebe | ||
|
|
2f2914077b | ||
|
|
712d498788 | ||
|
|
0e51731f69 | ||
|
|
2cc9bb8b72 | ||
|
|
553bebb6cf | ||
|
|
e91e5c366d | ||
|
|
3222f88999 | ||
|
|
9d32718c13 | ||
|
|
9664e2f042 | ||
|
|
979c04c303 | ||
|
|
725baa8776 | ||
|
|
64c9505620 | ||
|
|
55db7221ad | ||
|
|
75dc55858c | ||
|
|
1f28d3f32f | ||
|
|
c606212043 | ||
|
|
2ccf7ec2f0 | ||
|
|
2baad54660 | ||
|
|
46cca5fd49 | ||
|
|
bb32477671 | ||
|
|
e89384ee61 | ||
|
|
b213137e7a | ||
|
|
8fab00300a | ||
|
|
92a39c4305 | ||
|
|
116bb19bc6 | ||
|
|
ce54d47f46 | ||
|
|
6a8c756f7a | ||
|
|
64921ab240 | ||
|
|
ab01113d0e | ||
|
|
26752084ae | ||
|
|
84bc9745be | ||
|
|
b535fde0ff | ||
|
|
424cbfcf1a | ||
|
|
56af44b00b | ||
|
|
3b93198219 | ||
|
|
98bb30fa59 | ||
|
|
3ffb43539f | ||
|
|
e3e989de53 | ||
|
|
86e34d4681 | ||
|
|
a0b79d1d3b | ||
|
|
75ec92aff4 | ||
|
|
ab38a369da | ||
|
|
460a99d95d | ||
|
|
a66e14f3eb | ||
|
|
dd1ade2f99 | ||
|
|
177c98efb7 | ||
|
|
8830ad3c27 | ||
|
|
903384d02a | ||
|
|
4a8c020c3d | ||
|
|
4aaf25a384 | ||
|
|
a2f0c7f548 | ||
|
|
75bd97d0ae | ||
|
|
f072b98fd7 | ||
|
|
d3d2845f10 | ||
|
|
6831349f2f | ||
|
|
5b0fc7982b | ||
|
|
10a0dc7f8a | ||
|
|
f92e13270d | ||
|
|
9dfb4d1a1b | ||
|
|
91bfb60613 | ||
|
|
191254d920 | ||
|
|
6dc82815b6 | ||
|
|
eb6e81f81e | ||
|
|
19b3bca86a | ||
|
|
80df2eefed | ||
|
|
884a124c7e | ||
|
|
a284c1b9c6 | ||
|
|
ccf428eff6 | ||
|
|
96faac5aad | ||
|
|
7060aa3ed0 | ||
|
|
45a749e4bf | ||
|
|
3321448dd2 | ||
|
|
893b88246e | ||
|
|
8065b942bd | ||
|
|
a1a2118b35 | ||
|
|
17e59c5ae8 | ||
|
|
39d84e33bb | ||
|
|
87e8948d9a | ||
|
|
8317a06b6f | ||
|
|
1ab93adc22 | ||
|
|
af100fa0b4 | ||
|
|
66443d029b | ||
|
|
6fe713107e | ||
|
|
800c0b0131 | ||
|
|
e1a3975a07 | ||
|
|
33bf305ab5 | ||
|
|
14ff6f03bf | ||
|
|
68e91e405a | ||
|
|
7b22b96968 | ||
|
|
26107ff5e9 | ||
|
|
18931fb743 | ||
|
|
f68f32f942 | ||
|
|
2892fa6fca | ||
|
|
27286c7ffe | ||
|
|
d9b0fbe928 | ||
|
|
b15f203bc1 | ||
|
|
5d266cca20 | ||
|
|
904b070306 | ||
|
|
37d4efd7ba | ||
|
|
ece8998ba6 | ||
|
|
3f5000f278 | ||
|
|
68f4752c66 | ||
|
|
904a50be1f | ||
|
|
35f6ecad59 | ||
|
|
2a328ca120 | ||
|
|
1e83dbfbe3 | ||
|
|
e703c08634 | ||
|
|
f8a574375a | ||
|
|
cfcafe5b2c | ||
|
|
3ea7b8194d | ||
|
|
1a7ae33fa8 | ||
|
|
2bbe126c8d | ||
|
|
f2e6efe2b7 | ||
|
|
404e6cd862 | ||
|
|
4ab04477f8 | ||
|
|
611e8f1a6d | ||
|
|
c8fd810bd3 | ||
|
|
c619a9000c | ||
|
|
3d70614797 | ||
|
|
c29b4b8e5f | ||
|
|
4286b64b41 | ||
|
|
e8c50cbf54 | ||
|
|
407807d871 | ||
|
|
202af650de | ||
|
|
e1e42ed500 | ||
|
|
1767cc74a9 | ||
|
|
4028018605 | ||
|
|
33f8b6d6d8 | ||
|
|
c738436ddc | ||
|
|
467174ebf8 | ||
|
|
f9fed8a04f | ||
|
|
00194c2f0d | ||
|
|
37be1e8c46 | ||
|
|
357f8f6f94 | ||
|
|
ba4a594a41 | ||
|
|
928a87363d | ||
|
|
1708c48b0c | ||
|
|
17adbf3603 | ||
|
|
57ff5c63b0 | ||
|
|
4f4cab6a6c | ||
|
|
e848b58a60 | ||
|
|
5c8f0ad5ce | ||
|
|
173245d900 | ||
|
|
dd2e590475 | ||
|
|
5bc4aab8d8 | ||
|
|
94308a6098 | ||
|
|
c7861dfac4 | ||
|
|
071df0b6d3 | ||
|
|
ac0fbc7435 | ||
|
|
275051ed30 | ||
|
|
3c4cf03684 | ||
|
|
2f1c221a7c | ||
|
|
6748abbf70 | ||
|
|
e04a35afed | ||
|
|
f574a1dae5 | ||
|
|
f67ff43eb3 | ||
|
|
55a4b1937f | ||
|
|
b8b41f840a | ||
|
|
9f5a71020d | ||
|
|
e8c9e73985 | ||
|
|
e0dba87286 | ||
|
|
3c308788d8 | ||
|
|
3d28715713 | ||
|
|
61d2030934 | ||
|
|
56c67b9ec6 | ||
|
|
77ed529c26 | ||
|
|
7eddf4a12a | ||
|
|
ff69a4168b | ||
|
|
cec204b7ae | ||
|
|
188ef3762d | ||
|
|
64c9acb4b2 | ||
|
|
c25018bb64 | ||
|
|
c1a5b855df | ||
|
|
0da031fae4 | ||
|
|
72dff060d2 | ||
|
|
d6f4ffafa1 | ||
|
|
4b36217699 | ||
|
|
9a109e6fc1 | ||
|
|
227aac5b76 | ||
|
|
82ea72d2d1 | ||
|
|
f1637c3d20 | ||
|
|
07be841246 | ||
|
|
3ce5d92556 | ||
|
|
8f11396e00 | ||
|
|
2259523387 | ||
|
|
d67711b123 | ||
|
|
c89ca51a1b | ||
|
|
c5c951dd59 | ||
|
|
9f68f0227e | ||
|
|
7faed2ddd3 | ||
|
|
7dceb701b2 | ||
|
|
e454ccc326 | ||
|
|
bac50668e8 | ||
|
|
ff9e1556d5 | ||
|
|
dffb49b071 | ||
|
|
7387d9060f | ||
|
|
52e1842174 | ||
|
|
d738e5cbaf | ||
|
|
386b6db5ff | ||
|
|
47d57e5a44 | ||
|
|
92068ca64f | ||
|
|
349510e900 | ||
|
|
948d0f4bea | ||
|
|
b9a5586167 | ||
|
|
54edddbd2d | ||
|
|
b9c83426dc | ||
|
|
40dae875b8 | ||
|
|
e762ad5e18 | ||
|
|
9b1bd2127f | ||
|
|
88701a97c7 | ||
|
|
600c927594 | ||
|
|
1387a0f62b | ||
|
|
e3df94338f | ||
|
|
ee466aa1e4 | ||
|
|
9704d61a7a | ||
|
|
69d7e82b71 | ||
|
|
288d80d163 | ||
|
|
b1edf4e934 | ||
|
|
af9b0b444c | ||
|
|
a3865e68bd | ||
|
|
ed70585e90 | ||
|
|
f707fd007b | ||
|
|
d92e30dba3 | ||
|
|
4afa5c1789 | ||
|
|
e89e8e5d01 | ||
|
|
52f600485b | ||
|
|
b5a0d65f3c | ||
|
|
fc4433e7ae | ||
|
|
f2b6a9bedb | ||
|
|
35f0b1291f | ||
|
|
045699ecd9 | ||
|
|
74a2b46508 | ||
|
|
feaa594e52 | ||
|
|
d966541ea7 | ||
|
|
ecfc287871 | ||
|
|
c10461126a | ||
|
|
20903cefc8 | ||
|
|
e1caf40c3a | ||
|
|
6e70034758 | ||
|
|
ee30ff28f6 | ||
|
|
03654b5ebd | ||
|
|
7071174aed | ||
|
|
2c27531056 | ||
|
|
57d5556728 | ||
|
|
f4da2ae6be | ||
|
|
57dfe3af8c | ||
|
|
618d14a965 | ||
|
|
ee95942289 | ||
|
|
c6c15c3ec7 | ||
|
|
ed4c51b664 | ||
|
|
b3f39434ee | ||
|
|
30ded17581 | ||
|
|
19557bf358 | ||
|
|
bdc2cbbb99 | ||
|
|
82e2d15aaf | ||
|
|
6aff31886e | ||
|
|
7cc089f0ee | ||
|
|
6c025f7274 | ||
|
|
d5220324c4 | ||
|
|
a0308bd3a1 | ||
|
|
856b4ca2c1 | ||
|
|
8abfd10f0c | ||
|
|
1bc4d33bb7 | ||
|
|
35c67f692a | ||
|
|
a319902cf7 | ||
|
|
2bc8d26b4f | ||
|
|
402da58391 | ||
|
|
b533afe27f | ||
|
|
6a4afb45dc | ||
|
|
73a718e6c7 | ||
|
|
140f6e3318 | ||
|
|
073add9e71 | ||
|
|
35ecf1f7a7 | ||
|
|
71db62e5a5 | ||
|
|
ff82b52a5b | ||
|
|
18e81de26d | ||
|
|
27f37fc158 | ||
|
|
6638ae1d7b | ||
|
|
42094c2a70 | ||
|
|
932abdd412 | ||
|
|
b4bb6f2b15 | ||
|
|
4c7b523de1 | ||
|
|
59608e9acc | ||
|
|
77afedb2d0 | ||
|
|
ccb2c626a7 | ||
|
|
9b6eec5fdd | ||
|
|
02786fb7d3 | ||
|
|
384594ba5c | ||
|
|
dab02f2cbd | ||
|
|
978f13fbf3 | ||
|
|
831386a656 | ||
|
|
8b7406e25a | ||
|
|
9e7fed6646 | ||
|
|
89d88326e8 | ||
|
|
f18dd02161 | ||
|
|
e00db19485 | ||
|
|
a706b71d7c | ||
|
|
a83b26cc6f | ||
|
|
dc73843447 | ||
|
|
3e6aa2b943 | ||
|
|
909d3f4fbe | ||
|
|
fb0748f4a7 | ||
|
|
1ec4823122 | ||
|
|
cce5e0bf21 | ||
|
|
0006595786 | ||
|
|
6b884f645e | ||
|
|
946723a80e | ||
|
|
791debbaca | ||
|
|
3bb5a4e50d | ||
|
|
3efa3a68fe | ||
|
|
dc314a94ff | ||
|
|
11b062c41f | ||
|
|
faeb2a3894 | ||
|
|
b1f45a346d | ||
|
|
0b16de6a85 | ||
|
|
66d4b75373 | ||
|
|
3b376a615c | ||
|
|
c8da570c89 | ||
|
|
38f4a5b7fc | ||
|
|
b03ff36cf3 | ||
|
|
d145938652 | ||
|
|
1993388d9f | ||
|
|
15e7540b26 | ||
|
|
c2beaf5f80 | ||
|
|
5d395419ff | ||
|
|
f672a51470 | ||
|
|
f741239e23 | ||
|
|
3442194b01 | ||
|
|
0b025d7679 | ||
|
|
227de74317 | ||
|
|
0b9fced8bc | ||
|
|
274e4f7498 | ||
|
|
f6496b5e7e | ||
|
|
ee5ff1845f | ||
|
|
c84e835ce5 | ||
|
|
cdb5be2fe9 | ||
|
|
2d0c356aa5 | ||
|
|
0a92c253b8 | ||
|
|
92347863bc | ||
|
|
a146ec5e1a | ||
|
|
83be43e94e | ||
|
|
d726b3b5b5 | ||
|
|
c845aac8e5 | ||
|
|
1cdf114fff | ||
|
|
4d1a99d076 | ||
|
|
b1debd9879 | ||
|
|
3843917bd1 | ||
|
|
2fa4281b6f | ||
|
|
c42944e15b | ||
|
|
9f9f158709 | ||
|
|
3a753f0d5d | ||
|
|
13c94112d4 | ||
|
|
dd46f12a05 | ||
|
|
9a648d7e84 | ||
|
|
614dbab1fd | ||
|
|
3d80ee1984 | ||
|
|
536fb49bc3 | ||
|
|
edd93141bc | ||
|
|
20c2d19a9c | ||
|
|
17b2fe9cd8 | ||
|
|
134e7cee0c | ||
|
|
108ddffcc7 | ||
|
|
20e76daff8 | ||
|
|
6876a7f0d2 | ||
|
|
87dbf86129 | ||
|
|
175d1ae33b | ||
|
|
bf9d318209 | ||
|
|
7bee44c5ad | ||
|
|
a32e90aed0 | ||
|
|
310db1008e | ||
|
|
21560d098e | ||
|
|
33f7c5c82e | ||
|
|
52f17e9f46 | ||
|
|
1e7ffa365b | ||
|
|
e00fc83e5c | ||
|
|
a2fcb3539e | ||
|
|
cb69d056b8 | ||
|
|
e7f950cd32 | ||
|
|
3b2686ffeb | ||
|
|
2387a609df | ||
|
|
6457a84f4b | ||
|
|
dee231d43a | ||
|
|
8c4af4ac85 | ||
|
|
58cf0d9f5d | ||
|
|
de5c0d4681 | ||
|
|
780832d785 | ||
|
|
b8808a17b5 | ||
|
|
5001e58c2b | ||
|
|
95514b66ea | ||
|
|
90c6d6c8f8 | ||
|
|
88d2ce3ad0 | ||
|
|
484f3a6842 | ||
|
|
866caf8bc5 | ||
|
|
be9e3c3bf7 | ||
|
|
49a3cb1978 | ||
|
|
c3ddc4f7ad | ||
|
|
ff4d704bde | ||
|
|
a773be12c4 | ||
|
|
7ff6f16df0 | ||
|
|
d9053f580f | ||
|
|
fee2764338 | ||
|
|
61cb4fdd74 | ||
|
|
d89869ebff | ||
|
|
8518819dfa | ||
|
|
0a8923be6b | ||
|
|
cda821e5af | ||
|
|
be2402702f | ||
|
|
d723a8ab5c | ||
|
|
5c882c81c8 | ||
|
|
24d2709564 | ||
|
|
ce15bc46a7 | ||
|
|
de4bc30730 | ||
|
|
64f6811c00 | ||
|
|
e05ab104b9 | ||
|
|
b4f1c4d13e | ||
|
|
36a3c4123c | ||
|
|
939eb1bd8d | ||
|
|
854469cc7d | ||
|
|
338ae41baa | ||
|
|
6cae89d276 | ||
|
|
15c1e25122 | ||
|
|
c5716416b6 | ||
|
|
65e4a766ab | ||
|
|
c2a4c9bbb3 | ||
|
|
b042110154 | ||
|
|
da97b5593b | ||
|
|
44b4b0cdb9 | ||
|
|
190b7fe819 | ||
|
|
8eb89f0fe9 | ||
|
|
3085353729 | ||
|
|
1465ce112c | ||
|
|
5c797858ba | ||
|
|
8c51c4be0c | ||
|
|
4e8ecff67b | ||
|
|
6303573dd9 | ||
|
|
83f8c51aca | ||
|
|
e675348461 | ||
|
|
9026f5bf07 | ||
|
|
a7aa3705ef | ||
|
|
a64c941f36 | ||
|
|
02f18b5b35 | ||
|
|
db740f4747 | ||
|
|
6a3206b938 | ||
|
|
23414b0bc6 | ||
|
|
0234cfca30 | ||
|
|
f707714611 | ||
|
|
7b90230c45 | ||
|
|
d3468f3eba | ||
|
|
fc9500b45c | ||
|
|
df0b5fcd19 | ||
|
|
59cde5699e | ||
|
|
bd836a9580 | ||
|
|
90aade338c | ||
|
|
40e2dba490 | ||
|
|
d13e8bacec | ||
|
|
d3fa05a4df | ||
|
|
734dd2e119 | ||
|
|
a9ea9ea2ae | ||
|
|
1b521bc140 | ||
|
|
364e3c5f5f | ||
|
|
647cda3f90 | ||
|
|
bcca4d4bcc | ||
|
|
0b038dca17 | ||
|
|
ad6f63df56 | ||
|
|
b5b8b62a16 | ||
|
|
8d3066b377 | ||
|
|
18933b4326 | ||
|
|
672841c0ed | ||
|
|
872c7cb257 | ||
|
|
6d691c4741 | ||
|
|
bbba27752d | ||
|
|
d9cf090cc9 | ||
|
|
46effa572c | ||
|
|
f76ab01c11 | ||
|
|
bfae5871c8 | ||
|
|
451c5d1648 | ||
|
|
3423626c60 | ||
|
|
8982f02a4a | ||
|
|
f06b5fdacf | ||
|
|
8ea8cf2985 | ||
|
|
2771d34951 | ||
|
|
669b33a3e1 | ||
|
|
c934a1cdfe | ||
|
|
0ccd41f5e8 | ||
|
|
1af3bb7dfc | ||
|
|
f0fcf1ea8a | ||
|
|
5325542c81 | ||
|
|
3d9675841b | ||
|
|
0d475fba0e | ||
|
|
05714de945 | ||
|
|
89c1b53c95 | ||
|
|
d1cb699027 | ||
|
|
9ee1250f3a | ||
|
|
7a68cf867e | ||
|
|
1c9041f12b | ||
|
|
d3de5e0a42 | ||
|
|
b1bd21533d | ||
|
|
51f997a34a | ||
|
|
d5607faadd | ||
|
|
1a9c3ccb79 | ||
|
|
bc8fa00608 | ||
|
|
329d806ffd | ||
|
|
e040d0f971 | ||
|
|
053de4d06f | ||
|
|
4016458540 | ||
|
|
8b98efaad7 | ||
|
|
217ed90d9a | ||
|
|
b82aefbd5d | ||
|
|
63b954d38d | ||
|
|
76cdea0087 | ||
|
|
da1a3a8f85 | ||
|
|
d11e92e166 | ||
|
|
61efd237c2 | ||
|
|
6099948605 | ||
|
|
45b743e7c8 | ||
|
|
108c6cbeb4 | ||
|
|
da02891c6a | ||
|
|
a03e9ea946 | ||
|
|
dd4a6f8da0 | ||
|
|
c01033cbb0 | ||
|
|
1fe93395c5 | ||
|
|
cf960fe59d | ||
|
|
2416d41131 | ||
|
|
0699095d77 | ||
|
|
6ed3e84d95 | ||
|
|
6c196cfeb7 | ||
|
|
987784fb89 | ||
|
|
d60ac5d73b | ||
|
|
c2361c034c | ||
|
|
199514e7f3 | ||
|
|
a1364d19c2 | ||
|
|
e73428b156 | ||
|
|
a684a222cb | ||
|
|
1646cbbd68 | ||
|
|
035a48a0cd | ||
|
|
71be8ce239 | ||
|
|
1ccf9bf467 | ||
|
|
42a0cd0b1b | ||
|
|
39547b1d11 | ||
|
|
c223c1372b | ||
|
|
a450dcbac1 | ||
|
|
ee5fbce2ce | ||
|
|
559b88be18 | ||
|
|
dfbc5fa556 | ||
|
|
c69969947b | ||
|
|
e1738a25a1 | ||
|
|
6c54082a12 | ||
|
|
bc5500731d | ||
|
|
0665c552aa | ||
|
|
1ab8463578 | ||
|
|
e016e68e42 | ||
|
|
d116159ed9 | ||
|
|
3c24c011e3 | ||
|
|
536bdfe43a | ||
|
|
0b41110938 | ||
|
|
da1d4018cf | ||
|
|
b569c67d3b | ||
|
|
50a5a68b71 | ||
|
|
d2d26e4e57 | ||
|
|
df1a017eb3 | ||
|
|
b2b7799a0f | ||
|
|
e817f94045 | ||
|
|
46571a6029 | ||
|
|
4f4d689f5a | ||
|
|
a0308938de | ||
|
|
b10e875941 | ||
|
|
632400f52c | ||
|
|
b63479c45f | ||
|
|
7ea6495f28 | ||
|
|
453e63025d | ||
|
|
17ecc98e1e | ||
|
|
f9272f5cfc | ||
|
|
348e07a307 | ||
|
|
2009f5bd7b | ||
|
|
7ea92bc068 | ||
|
|
3b07b5d8dd | ||
|
|
f06e79cfd3 | ||
|
|
584ceb1f0f | ||
|
|
8053119d42 | ||
|
|
b8e97525eb | ||
|
|
66b1e4b15d | ||
|
|
e6f6f64512 | ||
|
|
d2eea9f0ae | ||
|
|
012fdc8cc6 | ||
|
|
74054aab5a | ||
|
|
11908b9e0c | ||
|
|
570cccfc62 | ||
|
|
de9d6877e1 | ||
|
|
c31fa116ca | ||
|
|
10b15226a1 | ||
|
|
7a46376bc1 | ||
|
|
b761aaf970 | ||
|
|
3a08db2dfb | ||
|
|
41fd2e087c | ||
|
|
ba476bcdca |
14
.editorconfig
Normal file
14
.editorconfig
Normal file
@@ -0,0 +1,14 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{kt,kts}]
|
||||
max_line_length = 5000
|
||||
ij_kotlin_allow_trailing_comma=true
|
||||
ij_kotlin_allow_trailing_comma_on_call_site=true
|
||||
ktlint_function_naming_ignore_when_annotated_with=Composable, Test
|
||||
|
||||
[*.gradle]
|
||||
indent_size = 4
|
||||
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -24,9 +24,10 @@ 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. 33]:
|
||||
- App Version [e.g. v0.20.3]:
|
||||
- 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.
|
||||
|
||||
18
.github/workflows/build.yml
vendored
18
.github/workflows/build.yml
vendored
@@ -12,15 +12,17 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 17
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v1
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
|
||||
@@ -28,7 +30,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Linter (gradle)
|
||||
run: ./gradlew ktlintCheck
|
||||
run: ./gradlew spotlessCheck
|
||||
|
||||
- name: Test (gradle)
|
||||
run: ./gradlew test --no-daemon
|
||||
@@ -41,14 +43,14 @@ jobs:
|
||||
run: ./gradlew assembleDebug --no-daemon
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Debug APK
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
path: amethyst/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Build Reports
|
||||
path: app/build/reports
|
||||
path: amethyst/build/reports
|
||||
|
||||
|
||||
89
.github/workflows/create-release.yml
vendored
89
.github/workflows/create-release.yml
vendored
@@ -10,15 +10,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 17
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v1
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
|
||||
@@ -31,20 +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"
|
||||
|
||||
- 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"
|
||||
|
||||
- name: Build APK
|
||||
run: ./gradlew assembleRelease --stacktrace
|
||||
@@ -52,20 +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"
|
||||
|
||||
- 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"
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
@@ -86,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
|
||||
|
||||
@@ -97,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
|
||||
|
||||
@@ -108,10 +121,32 @@ 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
|
||||
|
||||
- name: Upload Play APK arm64-v8a Asset
|
||||
id: upload-release-asset-play-arm64-v8a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: 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
|
||||
|
||||
- name: Upload Play APK armeabi-v7a Asset
|
||||
id: upload-release-asset-play-armeabi-v7a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: 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
|
||||
|
||||
# F-Droid APK
|
||||
- name: Upload F-Droid APK Universal Asset
|
||||
id: upload-release-asset-fdroid-universal-apk
|
||||
@@ -120,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
|
||||
|
||||
@@ -131,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
|
||||
|
||||
@@ -142,10 +177,32 @@ 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
|
||||
|
||||
- name: Upload F-Droid APK arm64-v8a Asset
|
||||
id: upload-release-asset-fdroid-arm64-v8a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: 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
|
||||
|
||||
- name: Upload F-Droid APK armeabi-v7a Asset
|
||||
id: upload-release-asset-fdroid-armeabi-v7a-apk
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: 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
|
||||
|
||||
|
||||
|
||||
# Google Play AAB
|
||||
@@ -156,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
|
||||
|
||||
@@ -168,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
|
||||
asset_content_type: application/zip
|
||||
|
||||
30
.github/workflows/crowdin.yml
vendored
Normal file
30
.github/workflows/crowdin.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Crowdin Action
|
||||
|
||||
on:
|
||||
push:
|
||||
# paths: ["app/src/main/res/**/strings.xml"] // removes filter to allow downloads at any moment.
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v1
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: 'New Crowdin Translations'
|
||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||
pull_request_base_branch_name: 'main'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -7,6 +7,13 @@
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/androidTestResultsUserPreferences.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/deploymentTargetSelector.xml
|
||||
/.idea/appInsightsSettings.xml
|
||||
/.idea/ktlint-plugin.xml
|
||||
/.idea/ktfmt.xml
|
||||
/.idea/studiobot.xml
|
||||
/.idea/other.xml
|
||||
/.idea/runConfigurations.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
@@ -36,6 +43,7 @@ out/
|
||||
.gradle
|
||||
.gradle/
|
||||
build/
|
||||
.kotlin/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
69
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
69
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,69 @@
|
||||
<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" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" 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" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.0" />
|
||||
<option name="version" value="2.0.0" />
|
||||
</component>
|
||||
</project>
|
||||
285
README.md
285
README.md
@@ -1,139 +1,188 @@
|
||||
# Amethyst: Nostr client for Android
|
||||
<div align="center">
|
||||
|
||||
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px"/>
|
||||
<a href="https://amethyst.social">
|
||||
<img src="./docs/design/3rd%20Logo%20-%20Zitron/amethyst.svg" alt="Amethyst Logo" title="Amethyst logo" width="80"/>
|
||||
</a>
|
||||
|
||||
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
|
||||
# Amethyst
|
||||
|
||||
## Nostr Client for Android
|
||||
|
||||
Join the social network you control.
|
||||
|
||||
[](https://github.com/vitorpamplona/amethyst/releases)
|
||||
[](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
|
||||
[](https://github.com/vitorpamplona/amethyst)
|
||||
[](https://jitpack.io/#vitorpamplona/amethyst)
|
||||
[](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
|
||||
[](/LICENSE)
|
||||
|
||||
## Download and Install
|
||||
|
||||
[<img src="./docs/design/obtainium.png"
|
||||
alt="Get it on Obtaininum"
|
||||
height="80">](https://github.com/ImranR98/Obtainium)
|
||||
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="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
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="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
|
||||
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="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
height="70">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
|
||||
# Current Features
|
||||
</div>
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
|
||||
- [x] Home Feed
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
- [x] Replies and mentions (NIP-10)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [x] Reposts (NIP-18)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] View Threads
|
||||
## Supported Features
|
||||
|
||||
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px">
|
||||
|
||||
- [x] Events / Relay Subscriptions (NIP-01)
|
||||
- [x] Follow List (NIP-02)
|
||||
- [x] OpenTimestamps Attestations (NIP-03)
|
||||
- [x] Private Messages (NIP-04)
|
||||
- [x] User Profiles (edit/follow/unfollow - NIP-02)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [x] Bech Encoding support (NIP-19)
|
||||
- [x] Notification Dots
|
||||
- [x] Reporting and Hide User capability (NIP-56)
|
||||
- [x] Automatic Translations
|
||||
- [x] Relay Sets (home, dms, public chats, global)
|
||||
- [x] User/Note Tagging (NIP-08, NIP-10)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Created_at Limits (NIP-22)
|
||||
- [x] DNS Address (NIP-05)
|
||||
- [x] Mnemonic seed phrase (NIP-06)
|
||||
- [ ] WebBrowser Signer (NIP-07, Not applicable)
|
||||
- [x] Old-style mentions (NIP-08)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
- [x] Nostr Address (NIP-05)
|
||||
- [x] Replies, mentions, Threads, and Notifications (NIP-10)
|
||||
- [x] Relay Information Document (NIP-11)
|
||||
- [x] Generic Tag Queries (NIP-12)
|
||||
- [x] Proof of Work Display (NIP-13)
|
||||
- [ ] Proof of Work Calculations (NIP-13)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [x] Event Treatment (NIP-16)
|
||||
- [x] Private Direct Messages (NIP-17)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
|
||||
- [x] Bech Encoding support (NIP-19)
|
||||
- [x] Command Results (NIP-20)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] User Profile Fields / Relay list (NIP-24)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [ ] Delegated Event Signing (NIP-26, Will not implement)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [ ] Relay-based Groups (NIP-29)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Event kind summaries (NIP-31)
|
||||
- [ ] Labeling (NIP-32)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Internationalization
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Verifiable static content in URLs (NIP-94)
|
||||
- [x] Login with QR
|
||||
- [x] Git Stuff (NIP-34)
|
||||
- [ ] Torrents (NIP-35)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] Edits (NIP-37/Draft)
|
||||
- [x] User Status Event (NIP-38)
|
||||
- [x] External Identities (NIP-39)
|
||||
- [x] Expiration Support (NIP-40)
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44)
|
||||
- [ ] Event Counts (NIP-45, Will not implement)
|
||||
- [ ] Nostr Connect (NIP-46)
|
||||
- [x] Wallet Connect API (NIP-47)
|
||||
- [x] Accessible uploads
|
||||
- [ ] Proxy Tags (NIP-48, Not applicable)
|
||||
- [x] Private key encryption for import/export (NIP-49)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Lists (NIP-51)
|
||||
- [ ] Calendar Events (NIP-52)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Wiki (NIP-54)
|
||||
- [x] Inline Metadata (NIP-55 - Draft)
|
||||
- [x] Reporting (NIP-56)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [x] Private Zaps
|
||||
- [x] Zap Splits (NIP-57)
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Gift Wraps & Seals (NIP-59)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Video Events (NIP-71)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [ ] Zap Goals (NIP-75)
|
||||
- [ ] Arbitrary Custom App Data (NIP-78)
|
||||
- [x] Highlights (NIP-84)
|
||||
- [x] Notify Request (NIP-88/Draft)
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [x] Data Vending Machine (NIP-90)
|
||||
- [x] Inline Metadata (NIP-92)
|
||||
- [x] Verifiable file URLs (NIP-94)
|
||||
- [x] Binary Blobs (NIP-95/Draft)
|
||||
- [x] HTTP File Storage Integration (NIP-96)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Classifieds (NIP-99)
|
||||
- [x] Audio Tracks (zapstr.live) (kind:31337)
|
||||
- [x] Push Notifications (Google and Unified Push)
|
||||
- [x] In-Device Automatic Translations
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Login with QR
|
||||
- [x] Bounty support (nostrbounties.com)
|
||||
- [x] De-googled F-Droid flavor
|
||||
- [x] External Identity Support (NIP-39)
|
||||
- [x] Multiple Accounts
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Zap Forwarding (NIP-57)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Audio Tracks (zapstr.live) (Kind:31337)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
- [x] Generic Tags (NIP-12)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [x] Generic Reposts (kind:16)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Relay Pages (NIP-11)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [x] Emoji Packs (Kind:30030)
|
||||
- [x] Personal Emoji Lists (Kind:10030)
|
||||
- [x] Classifieds (Kind:30403)
|
||||
- [x] Private Messages and Small Groups (NIP-24)
|
||||
- [x] Gift Wraps & Seals (NIP-59)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44)
|
||||
- [x] Expiration Support (NIP-40)
|
||||
- [x] Status Event (NIP-38)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [x] Medical Data (NIP-xx/Draft)
|
||||
- [x] Embed events (NIP-xx/Draft)
|
||||
- [x] Draft Events (NIP-xx/Draft)
|
||||
- [ ] Event Sets (NIP-xx/Draft)
|
||||
- [ ] Topical Notes (NIP-xx/Draft)
|
||||
- [ ] Relationship Status (NIP-xx/Draft)
|
||||
- [ ] Signed Filters (NIP-xx/Draft)
|
||||
- [ ] Key Migration (NIP-xx/Draft)
|
||||
- [ ] Time-based Sync (NIP-xx/Draft)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Workspaces
|
||||
- [ ] Infinity Scroll
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [ ] Signing Requests (NIP-46)
|
||||
- [ ] Delegated Event Signing (NIP-26)
|
||||
- [ ] Account Creation / Backup Guidance
|
||||
- [ ] Mnemonic seed phrase (NIP-06)
|
||||
- [ ] Message Sent feedback (NIP-20)
|
||||
- [ ] OpenTimestamps Attestations (NIP-03)
|
||||
|
||||
## Privacy and Information Permanence
|
||||
|
||||
Relays know your IP address, 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) except for Private Zaps and Private DMs. While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other.
|
||||
|
||||
If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN or Tor) from trackers online.
|
||||
|
||||
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
|
||||
|
||||
Information shared on Nostr can be re-broadcasted to other servers and should be assumed permanent for privacy purposes. There is no way to guarantee the deletion of any content once posted.
|
||||
|
||||
# Development Overview
|
||||
|
||||
## Overall Architecture
|
||||
This repository is split between Amethyst and Quartz:
|
||||
- Amethyst is a native Android app made with Kotlin and Jetpack Compose.
|
||||
- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients.
|
||||
|
||||
This is a native Android app made with Kotlin and Jetpack Compose.
|
||||
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
|
||||
The overall architecture consists in the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
|
||||
The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
|
||||
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
|
||||
|
||||
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to
|
||||
allow the UI and other parts of the app to subscribe to each individual Note/User and receive updates when they happen.
|
||||
They are also responsible for updating viewModels when needed. Filters react to changes in the screen. As the user
|
||||
sees different Events, the Datasource classes are used to receive more information about those particular Events.
|
||||
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData and Flow objects to
|
||||
allow the UI and other parts of the app to subscribe to each Note/User and receive updates when they happen.
|
||||
They are also responsible for updating viewModels when needed. As the user scrolls through Events, the Datasource classes
|
||||
are updated to receive more information about those particular Events.
|
||||
|
||||
Most of the UI is reactive to changes in the repository classes. The service layer assembles Nostr filters for each need of the app,
|
||||
receives the data from the Relay, and sends it to the repository. Connection with relays is never closed during the use of the app.
|
||||
The UI receives a notification that objects were updated. Instances of User and Notes are mutable directly.
|
||||
The UI receives a notification that objects have been updated. Instances of User and Notes are mutable directly.
|
||||
There will never be two Notes with the same ID or two User instances with the same pubkey.
|
||||
|
||||
Lastly, the user's account information (priv key/pub key) is stored in the Android KeyStore for security.
|
||||
Lastly, the user's account information (private key/pub key) is stored in the Android KeyStore for security.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to have the following pre-requisites installed:
|
||||
1. Java 17
|
||||
1. Java 17+
|
||||
2. Android Studio
|
||||
3. Android 8.0+ Phone or Emulation setup
|
||||
|
||||
Fork and clone this repository and import into Android Studio
|
||||
Fork and clone this repository and import it into Android Studio
|
||||
```bash
|
||||
git clone https://github.com/vitorpamplona/amethyst.git
|
||||
```
|
||||
|
||||
Use one of the Android Studio builds to install and run the app in your device or a simulator.
|
||||
Use an Android Studio build action to install and run the app on your device or a simulator.
|
||||
|
||||
## Building
|
||||
Build the app:
|
||||
@@ -149,8 +198,8 @@ Build the app:
|
||||
|
||||
## Linting
|
||||
```bash
|
||||
./gradlew ktlintCheck
|
||||
./gradlew ktlintFormat
|
||||
./gradlew spotlessCheck
|
||||
./gradlew spotlessApply
|
||||
```
|
||||
|
||||
## Installing on device
|
||||
@@ -165,54 +214,64 @@ For the Play build:
|
||||
./gradlew installPlayDebug
|
||||
```
|
||||
|
||||
## How to Deploy
|
||||
## Deploying
|
||||
|
||||
1. Generate a new signing key
|
||||
```
|
||||
keytool -genkey -v -keystore <my-release-key.keystore> -alias <alias_name> -keyalg RSA -keysize 2048 -validity 10000
|
||||
openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.jks.base64.txt
|
||||
```
|
||||
2. Create 4 Secret Key variables on your GitHub repository and fill in with the signing key information
|
||||
2. Create four Secret Key variables on your GitHub repository and fill in the signing key information
|
||||
- `KEY_ALIAS` <- `<alias_name>`
|
||||
- `KEY_PASSWORD` <- `<your password>`
|
||||
- `KEY_STORE_PASSWORD` <- `<your key store password>`
|
||||
- `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.
|
||||
7. Add your CHANGE LOG to the description of the new release
|
||||
8. Download the `aab` file and upload it to the` PlayStore.
|
||||
8. Download the `aab` file and upload it to the PlayStore.
|
||||
|
||||
# Privacy on Relays & nostr
|
||||
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
|
||||
## Using the Quartz library
|
||||
|
||||
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
|
||||
Setup [JitPack.io](https://jitpack.io/#vitorpamplona/amethyst/v0.84.3) to your build file
|
||||
|
||||
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs.
|
||||
Add `maven { url 'https://jitpack.io' }` to settings.gradle at the end of repositories:
|
||||
|
||||
# DM Privacy #
|
||||
While the content of direct messages (DMs) is only visible to you, and your DM nostr counterparty, everyone can see that and when you and your counterparty are DM-ing each other.
|
||||
```gradle
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url 'https://jitpack.io' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# Visibility & Permanence of Your Content on nostr
|
||||
## Information Visibility ##
|
||||
Content that you share can be shared to other relays.
|
||||
Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
|
||||
Add the dependency
|
||||
|
||||
## 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.
|
||||
```gradle
|
||||
implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1')
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst)
|
||||
|
||||
[GitHub issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are also welcome. Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
|
||||
|
||||
You can also send patches through Nostr using [GitStr](https://github.com/fiatjaf/gitstr) to [this nostr address](https://patch34.pages.dev/naddr1qqyxzmt9w358jum5qyg8v6t5daezumn0wd68yvfwvdhk6qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap0qy2hwumn8ghj7un9d3shjtnwdaehgu3wvfnj7q3qgcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqxpqqqpmej720gac)
|
||||
|
||||
By contributing to this repository, you agree to license your work under the MIT license. Any work contributed where you are not the original author must contain its license header with the original author(s) and source.
|
||||
|
||||
# Screenshots
|
||||
|
||||
| Home Feed | Messages | Live Streams | Notifications |
|
||||
| FollowFeeds | ChatsGroup | LiveStreams | Notifications |
|
||||
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
|
||||
|  |  |  |  |
|
||||
|
||||
# Contributing
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
|
||||
|
||||
## Contributors
|
||||
# Contributors
|
||||
|
||||
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
|
||||
@@ -220,6 +279,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|
||||
|
||||
# MIT License
|
||||
|
||||
<pre>
|
||||
Copyright (c) 2023 Vitor Pamplona
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
@@ -239,3 +299,4 @@ 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.
|
||||
</pre>
|
||||
|
||||
0
app/.gitignore → amethyst/.gitignore
vendored
0
app/.gitignore → amethyst/.gitignore
vendored
313
amethyst/build.gradle
Normal file
313
amethyst/build.gradle
Normal file
@@ -0,0 +1,313 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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 407
|
||||
versionName "0.92.2"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"98f2acaf9b3eb70d87607942e576f9a086ac28adf37869da9a29f4815cecac86\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resourceConfigurations += [
|
||||
'ar',
|
||||
'bn-rBD',
|
||||
'cs',
|
||||
'cy-rGB',
|
||||
'da-rDK',
|
||||
'de',
|
||||
'el-rGR',
|
||||
'en-rGB',
|
||||
'eo',
|
||||
'es',
|
||||
'es-rES',
|
||||
'es-rMX',
|
||||
'es-rUS',
|
||||
'et-rEE',
|
||||
'fa',
|
||||
'fi-rFI',
|
||||
'fo-rFO',
|
||||
'fr',
|
||||
'fr-rCA',
|
||||
'gu-rIN',
|
||||
'hi-rIN',
|
||||
'hr-rHR',
|
||||
'hu',
|
||||
'in',
|
||||
'in-rID',
|
||||
'it-rIT',
|
||||
'iw-rIL',
|
||||
'ja',
|
||||
'kk-rKZ',
|
||||
'ko-rKR',
|
||||
'ks-rIN',
|
||||
'ku-rTR',
|
||||
'lt-rLT',
|
||||
'ne-rNP',
|
||||
'night',
|
||||
'nl',
|
||||
'nl-rBE',
|
||||
'pcm-rNG',
|
||||
'pl-rPL',
|
||||
'pt-rBR',
|
||||
'pt-rPT',
|
||||
'ru',
|
||||
'ru-rUA',
|
||||
'sa-rIN',
|
||||
'sl-rSI',
|
||||
'so-rSO',
|
||||
'sr-rSP',
|
||||
'ss-rZA',
|
||||
'sv-rSE',
|
||||
'sw-rKE',
|
||||
'sw-rTZ',
|
||||
'ta',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'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
|
||||
resValue "string", "app_name", "@string/app_name_release"
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix '.debug'
|
||||
versionNameSuffix '-DEBUG'
|
||||
resValue "string", "app_name", "@string/app_name_debug"
|
||||
}
|
||||
create("benchmark") {
|
||||
initWith(getByName("release"))
|
||||
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_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Observe Live data as State
|
||||
implementation libs.androidx.runtime.livedata
|
||||
|
||||
// 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
|
||||
implementation libs.androidx.lifecycle.livedata.ktx
|
||||
|
||||
// Zoomable images
|
||||
implementation libs.zoomable
|
||||
|
||||
// Biometrics
|
||||
implementation libs.androidx.biometric.ktx
|
||||
|
||||
// Websockets API
|
||||
implementation libs.okhttp
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation libs.androidx.security.crypto.ktx
|
||||
|
||||
// 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
|
||||
|
||||
// create blurhash
|
||||
implementation libs.trbl.blurhash
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
# JNA For Libsodium
|
||||
-keep class com.goterl.lazysodium.** { *; }
|
||||
|
||||
# libscrypt
|
||||
-keep class com.lambdaworks.codec.** { *; }
|
||||
-keep class com.lambdaworks.crypto.** { *; }
|
||||
-keep class com.lambdaworks.jni.** { *; }
|
||||
|
||||
# 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 { *; }
|
||||
@@ -58,3 +63,5 @@
|
||||
-keep class com.vitorpamplona.quartz.events.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.model.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.** { *; }
|
||||
-keep class com.vitorpamplona.ammolite.service.** { *; }
|
||||
-keep class com.vitorpamplona.ammolite.relays.** { *; }
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.CashuProcessor
|
||||
import com.vitorpamplona.amethyst.service.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 = (CashuProcessor().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 = (CashuProcessor().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 = (CashuProcessor().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,205 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.Nip96Retriever
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
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 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 {
|
||||
private suspend fun testBase(server: Nip96MediaServers.ServerName) {
|
||||
val serverInfo =
|
||||
Nip96Retriever()
|
||||
.loadInfo(
|
||||
server.baseUrl,
|
||||
false,
|
||||
)
|
||||
|
||||
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)
|
||||
val bytes = baos.toByteArray()
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
val account =
|
||||
Account(
|
||||
AccountSettings(KeyPair()),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
)
|
||||
|
||||
val result =
|
||||
Nip96Uploader(account)
|
||||
.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/png",
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverInfo,
|
||||
forceProxy = { false },
|
||||
onProgress = {},
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
)
|
||||
|
||||
val url = result.tags!!.first { it[0] == "url" }.get(1)
|
||||
val size =
|
||||
result.tags!!
|
||||
.firstOrNull { it[0] == "size" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val dim =
|
||||
result.tags!!
|
||||
.firstOrNull { it[0] == "dim" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val hash =
|
||||
result.tags!!
|
||||
.firstOrNull { it[0] == "x" }
|
||||
?.get(1)
|
||||
?.ifBlank { null }
|
||||
val contentType = result.tags!!.first { it[0] == "m" }.get(1)
|
||||
val ox = result.tags!!.first { it[0] == "ox" }.get(1)
|
||||
|
||||
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
|
||||
|
||||
val imageData: ByteArray =
|
||||
ImageDownloader().waitAndGetImage(url, false)
|
||||
?: run {
|
||||
fail("${server.name}: Should not be null")
|
||||
return
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
"image/png",
|
||||
null,
|
||||
onReady = {
|
||||
if (dim != null) {
|
||||
// assertEquals("${server.name}: Invalid dimensions", it.dim, dim)
|
||||
}
|
||||
if (size != null) {
|
||||
// assertEquals("${server.name}: Invalid size", it.size.toString(), size)
|
||||
}
|
||||
if (hash != null) {
|
||||
assertEquals("${server.name}: Invalid hash", it.hash, hash)
|
||||
}
|
||||
},
|
||||
onError = { fail("${server.name}: It should not fail") },
|
||||
)
|
||||
|
||||
// delay(1000)
|
||||
|
||||
// assertTrue(Nip96Uploader(account).delete(ox, contentType, serverInfo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runTestOnDefaultServers() =
|
||||
runBlocking {
|
||||
Nip96MediaServers.DEFAULT.forEach {
|
||||
testBase(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrCheck() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostrcheck.me", "https://nostrcheck.me"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testNostrage() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostrage", "https://nostrage.com"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testSove() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sove", "https://sove.rent"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrBuild() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostr.build", "https://nostr.build"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testSovbit() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sovbit", "https://files.sovbit.host"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testVoidCat() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("void.cat", "https://void.cat"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrPic() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostpic.com", "https://nostpic.com"))
|
||||
}
|
||||
|
||||
@Test(expected = RuntimeException::class)
|
||||
fun testSprovoostNl() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("sprovoost.nl", "https://img.sprovoost.nl/"))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testNostrOnch() =
|
||||
runBlocking {
|
||||
testBase(Nip96MediaServers.ServerName("nostr.onch.services", "https://nostr.onch.services"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.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 junit.framework.TestCase.assertEquals
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OkHttpOtsTest {
|
||||
val otsEvent = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqCiiW0FlsU9lqK5f1A+cL6CGJ1Ah4V/A1yNJY/stUE3wECJz6ng/QxU5Z6xwaMx97qkI//AQqJv8bEMrGTplGWRv5qm4DgjxIHkcQqzpL0Fjr9VBAAijDe0IsQYpOhw1SIjZIgQa6i16CPEEZck7CvAIxR0AloJzCZoAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEOwPtjIkKI1hmtv9t1kuxZcI8QRlyTsK8Ahl0wrCSggZzgCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEE1dVGa8JCuf2ek0c5ybDKII8SCBoVz8Sal45Kd1O8STWIGJTcl5JPtAZBZitqk3BE9MqAjxBGXJOwrwCHoGVgAZi9q9AIPf4w0u+QyOKShodHRwczovL2Zpbm5leS5jYWxlbmRhci5ldGVybml0eXdhbGwuY29t8BAFZFXFYg7DJJ0OzjmJ0FKWCPEEZck7CvAI0M49IcBR5bf/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPEgoE3IfYTmxxo4W/x/QYp/NGX6Wu93gSQkwbpjpOhZORcI8SDWLLurVQaXHdUuwivCfTfuxYCaq+AzypSGqLDAVocrEgjwIBfgjta16y13Gp4etQOCa9YiKEcM+/9AieG/vZolr3IDCPAgMR2zFCb384CEi8tVuI2fHgLT3I9zpe7oqJTzCcEqxWEI8SAJSdgeeosr7IxdOt8r7f0ipWc8FI6GAhgep8zSRgWikAjxIGxYmtCsC79Tx4z4YsT1WuMo+ycMkwhGQsQltF597cchCPAgCLrBf9vR1Aex6yY+vSkXAvLjMKdMqM/a1g8zNPwLeJcI8SBDCbTk4CczTuiIyZeUyYRVh31BZdjaSd2nU/pBQxu+6QjwIOwqE9/WqGC6CHH0i+tr7edvYX5PstSDf08KmnMqsqCoCPEgQIdEBg358vfek3Qjfyrgl51iCU6WUWmThsGLPDTcB0QI8SAX4dp64iI8pBx+zBqAQwUN6XgZ1cEfT8+2vha/9I1vzwjxWQEAAAAB8YJxvxJJth8OnxIV6UOXveIZAcJPTHcAkWgnucpuYqYAAAAAAP3///8CRhoMAAAAAAAWABTUUZK82uAvbU3vVyaaPIOddZBicwAAAAAAAAAAImog8ARCqgwACAjwIB9TcMDLhzgeS1Uw647lNvCfWECkkUvfrrOe6nay0sGdCAjwICYfs90sbPggoMICyOHGYbmOzop2L9mlnh4xqiBLY7yPCAjxINDiVWOBHnRmGJleQdB9myvJAJbNJ9kciZlTOkgJy89mCAjwIHfxqDLdwycj1Vtyth2CaSDdLQwiey9oV6Xov4stLpWNCAjxIGurJYpKJnKp9+y7MAdC+gXgHOiAu5P3RRUFW9l5hGaCCAjxICXqs9hdY0QMP/MNeqlt6s7xaIYtEXZ1CLvou5gaZNEICAjxIHdYxVeI76NXbT2zHcv6lw+v819Ooib7KWxc1GAsiX2fCAjwICPWdi3uBXOlIdmYi+V9C7wAqLyGE4DMoHD+GtvLizqiCAjwIOF2vENtWN5okEMMS+JSf1SGTY9yYP9j0JjXLbC1s+N1CAjxINWsgCtsPxhRNbe372k8/20WDbiL9e8934hGF256DvRECAjwII3mi+Li06j10ORxg0dYMkcsyGb115Jiqq1YEV3K/u+aCAgABYiWDXPXGQEDw9Qy\",\"created_at\":1707690688,\"id\":\"759f9da5846e936fab06766a524b36ba71c03bbc69ad0944fb8ee4bb1f3dd705\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"07c7896c8cbb97b5d7483097590c9d31b73f35c1ad9e752002bb5c1776cbd852e1d32704333d6930c9bc3e40f8b899a1f2e9f91cc3bf797d86acdecba7792576\",\"tags\":[[\"e\",\"a828a25b4165b14f65a8ae5fd40f9c2fa086275021e15fc0d7234963fb2d504d\"],[\"p\",\"595ca8eaace5899cb6ab7e2542bfc972136376f2eabc09287f1857eb8f167e53\"],[\"alt\",\"NIP-03 time stamp\"]]}"
|
||||
val otsEvent2 = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwENyCNtiEN98IzIZgEu3cl6YI//AQ6TkSRd3BTGhDHCK1KkJc+AjxIAHaizG++NNL3Vm13BJrIhT7Br6tEYpb0TVRGaadgiUMCPAgOSDREH9v1Y50UHu79LfC4Lcd9WklQJzRQpw+Unb/pyII8QRltDD58AgqrxfAVrLw7QCD3+MNLvkMji4taHR0cHM6Ly9hbGljZS5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQQMq/CLpGwY60nmddPS7OVgjxIDKxqd9nl+Mej41vP52Wd7gv7004r3n1rFGDObS8icRvCPAgH9TB/kwvXJEEw+h9Ce6fLaI3MORjtTEge0GbAefT6W4I8QRltDD58AhRcoU3gAo/swCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wECWtWsKo0uvSr8BYonjs3DEI8CBlsh2ng1Spl0K4oStYElGuMJsjd2uo5nXB+apo5A7ipwjxIM8oxynBwNA+QS/X7Ebtl1kyhFgfoOQioASNfCBzZ4gaCPEEZbQw+fAId6Yd5cw5gioAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb23wEJmPzXQbxv0AFTIyjTWjMskI8CAurbkrfrBtlinZXSDxj+m/oIkze57hGjTSxu1Xs87XYQjwIPk/LMD0zIgKoEE2dfeoYrrdHuO6dwmghTwUFajH2QzkCPEEZbQw+fAIE+Pq1/Wmdpj/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPAgB2CbqkV7VpjRKIl3Ea6cBmB/EHcSN/YCgcc1E+mc07QI8CAfpkZ2Hh4Rukz3x4il3tZqQtlDlbna+I6so2t2YSEmMQjwIJOv32jbsMa2HJwpleRCKLEhgYOoHCSfpv1ZO0YNNNFsCPAgLMM7eFfCjokQfU4gdU5WpG/wBLkO9lDRF0GktL6ujt8I8SCRxJ0bC1PQ8qFmI/1jh8AS5d1/6VRJNMt1Hz41QmNr3QjwIBmgrKBF+OZ3y+XOMv2E7IZ4WwLr2u2H+ehsBfy7cPlICPEgm4ZMCSXzZVWu40d+zk2edaur6KOauo8X7V2KaFBR1VoI8SBKVVOiyq6IFqGn/15kLwk7L8upMAIZ0znjhYxYqSTQCQjwIMBD1twPZ33GxbwTiuOCeJPkoP++6R2wYpCii8UBTdgwCPAg84VkgMXwrt2xxRoeC1/6CtsFctki+w3m8Rs5/6g/IhEI8CCnzZQDhJyicX7bS7U8PMUObuC9Y4TXe+4THoXBMMXkxwjxIMZ0oAvshpcwowR3qPEDbwKZ6B4NPSU4Hz/+4PnD74gnCPAgIfLZEKqAvkMNXfakXoNq1UVqGSzL4Z86z5GzUfbvw8UI8SC8KoIeLvjd4vJ/xhNVphakPRd80YKeNkeYEuVH8k2EtAjwIPRtinLLxzt8iuw0XZtpTDzEstZOTNYVm+Bi3fEzdeIuCPFZAQAAAAE8vsasINN5DKon0KakX2HNdCB126ZLKXrw4PfvyEfqbwAAAAAA/f///wLPGw8AAAAAABYAFCvgxlh6msa4ZOtgvlc5KiZCx7IvAAAAAAAAAAAiaiDwBKygDAAICPEgB/2DJ3s6gMky/PceGZocTFRXjZUiCCAhHGYwQk/8yrUICPEgDuSd6+PJHUMuEHyLcKFxw7xfvRHRfInjkV3/Zy3BxqAICPEgZDgQ+4VXzlOIkGoO8EVxDgs2cWaeh4EEiaqa/y50gKAICPAgFI+zIuYcMF69GmPQVsXa9oy8eng7MeRZdIxArQyeX3oICPEgX5HYIuImpiSTEapgEssEW4l+W+4aRfNCG3pZf7z0hCoICPEgPkAbOSjFdtS4NT7MXgMYVQoQhI1JZtdFxUu4J3NTt7IICPEguW3qyuyGjctu5d9rM9P9ZCs/ZK4vAc+z21b9ygklgWAICPEgu4e2645xtvGhI1Zzuiv23vRhwE8uC9vj1TAgNg/C8UcICPAgwoQX8X0nY4HoQLRsJ0z8JCWQDzRh2iL2QXEb8z3gbjIICPAgzTwlPRtStsLJWhz3Q/0l8tMnrPSHVuh+zCiGk95dW2MICPAgGcBEuYZyzFNapHOfnJ9Q515QzO2VbIRhlVI0vIhd4jwICPAgidRMoM2pA+KmVJenVrLcbollsbUg9lL9bmv1C1dSxswICPAgZinGakwhbHdanTaRJeBkEUlbhfNokvj8b5KneyG+wzIICAAFiJYNc9cZAQOtwTI=\",\"created_at\":1706324334,\"id\":\"2ad074ddb7724eb13b4244b49cf2321b1057f37fdf8ce102e6329b839cf763a9\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"ad7274bb32ba9e9cfdbd52f4887e8a2fda1047c75a7185b2ab7ff254ebac14ed48a2b60737494d655e24c9400eeeec7e29293a77bfcaafaecd94b350c9a2c22b\",\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"p\",\"c31e22c3715c1bde5608b7e0d04904f22f5fc453ba1806d21c9f2382e1e58c6c\"],[\"alt\",\"NIP-03 time stamp\"]]}"
|
||||
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"
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(forceProxy = { false }), OkHttpCalendarBuilder(forceProxy = { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyNostrEvent() {
|
||||
val ots = Event.fromJson(otsEvent) as OtsEvent
|
||||
println(ots.info())
|
||||
assertEquals(1707688818L, ots.verify())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyNostrEvent2() {
|
||||
val ots = Event.fromJson(otsEvent2) as OtsEvent
|
||||
println(ots.info())
|
||||
assertEquals(1706322179L, ots.verify())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyNostrPendingEvent() {
|
||||
val ots = Event.fromJson(otsPendingEvent) as OtsEvent
|
||||
println(ots.info())
|
||||
assertEquals(null, ots.verify())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createOTSEventAndVerify() {
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
var ots: OtsEvent? = null
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
|
||||
val otsFile = OtsEvent.stamp(otsEvent2Digest)
|
||||
|
||||
OtsEvent.create(otsEvent2Digest, otsFile, signer) {
|
||||
ots = it
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
|
||||
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
|
||||
|
||||
println(ots!!.toJson())
|
||||
println(ots!!.info())
|
||||
|
||||
// Should not be valid because we need to wait for confirmations
|
||||
assertEquals(null, ots!!.verify())
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* 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 @
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
@@ -10,17 +30,27 @@ import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TranslationsTest {
|
||||
|
||||
fun translateTo(text: String, translateTo: String): String? {
|
||||
fun translateTo(
|
||||
text: String,
|
||||
translateTo: String,
|
||||
): String? {
|
||||
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
|
||||
return Tasks.await(task).result
|
||||
}
|
||||
|
||||
fun assertTranslate(expected: String, input: String, translateTo: String) {
|
||||
fun assertTranslate(
|
||||
expected: String,
|
||||
input: String,
|
||||
translateTo: String,
|
||||
) {
|
||||
assertEquals(null, expected, translateTo(input, translateTo))
|
||||
}
|
||||
|
||||
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
|
||||
fun assertTranslateContains(
|
||||
expected: String,
|
||||
input: String,
|
||||
translateTo: String,
|
||||
) {
|
||||
val translated = translateTo(input, translateTo)!!
|
||||
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
|
||||
}
|
||||
@@ -44,23 +74,47 @@ class TranslationsTest {
|
||||
fun testTranslationUrl() {
|
||||
assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt")
|
||||
assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt")
|
||||
assertTranslateContains("http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg", "pt")
|
||||
assertTranslateContains("http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg", "pt")
|
||||
assertTranslateContains(
|
||||
"http://bananas.com/myimage.jpg",
|
||||
"Have you seen this http://bananas.com/myimage.jpg",
|
||||
"pt",
|
||||
)
|
||||
assertTranslateContains(
|
||||
"http://bananas.com?search=true&image=myimage.jpg",
|
||||
"Have you seen this http://bananas.com?search=true&image=myimage.jpg",
|
||||
"pt",
|
||||
)
|
||||
assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt")
|
||||
assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt")
|
||||
assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt")
|
||||
assertTranslate("https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg", "pt")
|
||||
assertTranslateContains("https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "pt")
|
||||
assertTranslate(
|
||||
"https://i.imgur.com/asdEZ3QPsw.jpg",
|
||||
"https://i.imgur.com/asdEZ3QPsw.jpg",
|
||||
"pt",
|
||||
)
|
||||
assertTranslateContains(
|
||||
"https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg",
|
||||
"Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg",
|
||||
"pt",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChineseWithUrlDetector() {
|
||||
assertTranslate("I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端", "en")
|
||||
assertTranslate(
|
||||
"I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client",
|
||||
"我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端",
|
||||
"en",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTranslationEmail() {
|
||||
assertTranslateContains("vitor@amethyst.social", "Have you seen this vitor@amethyst.social", "pt")
|
||||
assertTranslateContains(
|
||||
"vitor@amethyst.social",
|
||||
"Have you seen this vitor@amethyst.social",
|
||||
"pt",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,13 +122,13 @@ class TranslationsTest {
|
||||
assertTranslateContains(
|
||||
"lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn",
|
||||
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay",
|
||||
"pt"
|
||||
"pt",
|
||||
)
|
||||
|
||||
assertTranslateContains(
|
||||
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
|
||||
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
|
||||
"pt"
|
||||
"pt",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,7 +137,7 @@ class TranslationsTest {
|
||||
assertTranslateContains(
|
||||
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
|
||||
"sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
|
||||
"en"
|
||||
"en",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +150,7 @@ class TranslationsTest {
|
||||
"https://youtu.be/wMYFmCDy_Eg\n" +
|
||||
"\n" +
|
||||
"先輩がうざい後輩の話と似たような話かと思ったけど、もっとオタクの妄想あるある的なものを詰め込んだやつだ。ワードとかシチュエーションとか、ヒロインのサイズ感とか。知らんけど",
|
||||
"en"
|
||||
"en",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,7 +159,7 @@ class TranslationsTest {
|
||||
assertTranslateContains(
|
||||
"https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg",
|
||||
"\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ",
|
||||
"pt"
|
||||
"pt",
|
||||
)
|
||||
}
|
||||
}
|
||||
23
amethyst/src/fdroid/AndroidManifest.xml
Normal file
23
amethyst/src/fdroid/AndroidManifest.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:name=".Amethyst">
|
||||
|
||||
<receiver
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".service.notifications.PushMessageReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
|
||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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() {}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
interface PushDistributorActions {
|
||||
fun getSavedDistributor(): String
|
||||
|
||||
fun getInstalledDistributors(): List<String>
|
||||
|
||||
fun saveDistributor(distributor: String)
|
||||
|
||||
fun removeSavedDistributor()
|
||||
}
|
||||
|
||||
object PushDistributorHandler : PushDistributorActions {
|
||||
private val unifiedPush: UnifiedPush = UnifiedPush
|
||||
|
||||
private var endpointInternal = ""
|
||||
|
||||
fun getSavedEndpoint() = endpointInternal
|
||||
|
||||
fun setEndpoint(newEndpoint: String) {
|
||||
endpointInternal = newEndpoint
|
||||
Log.d("PushHandler", "New endpoint saved : $endpointInternal")
|
||||
}
|
||||
|
||||
fun removeEndpoint() {
|
||||
endpointInternal = ""
|
||||
}
|
||||
|
||||
fun appContext(): Context = Amethyst.instance.applicationContext
|
||||
|
||||
override fun getSavedDistributor(): String = unifiedPush.getSavedDistributor(appContext()) ?: ""
|
||||
|
||||
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
|
||||
|
||||
override fun getInstalledDistributors(): List<String> = unifiedPush.getDistributors(appContext())
|
||||
|
||||
fun formattedDistributorNames(): List<String> {
|
||||
val distributorsArray = getInstalledDistributors().toTypedArray()
|
||||
val distributorsNameArray =
|
||||
distributorsArray
|
||||
.map {
|
||||
try {
|
||||
val ai =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appContext().packageManager.getApplicationInfo(
|
||||
it,
|
||||
PackageManager.ApplicationInfoFlags.of(
|
||||
PackageManager.GET_META_DATA.toLong(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
appContext().packageManager.getApplicationInfo(it, 0)
|
||||
}
|
||||
appContext().packageManager.getApplicationLabel(ai)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
it
|
||||
}
|
||||
as String
|
||||
}.toTypedArray()
|
||||
return distributorsNameArray.toList()
|
||||
}
|
||||
|
||||
override fun saveDistributor(distributor: String) {
|
||||
unifiedPush.saveDistributor(appContext(), distributor)
|
||||
unifiedPush.registerApp(appContext())
|
||||
}
|
||||
|
||||
override fun removeSavedDistributor() {
|
||||
unifiedPush.safeRemoveDistributor(appContext())
|
||||
}
|
||||
|
||||
fun forceRemoveDistributor(context: Context) {
|
||||
unifiedPush.forceRemoveDistributor(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
|
||||
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: ByteArray,
|
||||
instance: String,
|
||||
) {
|
||||
val messageStr = message.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: String,
|
||||
instance: String,
|
||||
) {
|
||||
val sanitizedEndpoint = if (endpoint.endsWith("?up=1")) endpoint.dropLast(5) else endpoint
|
||||
if (sanitizedEndpoint != pushHandler.getSavedEndpoint()) {
|
||||
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint")
|
||||
pushHandler.setEndpoint(sanitizedEndpoint)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
|
||||
notificationManager().getOrCreateZapChannel(appContext)
|
||||
notificationManager().getOrCreateDMChannel(appContext)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(
|
||||
context: Context,
|
||||
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,47 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
object PushNotificationUtils {
|
||||
var hasInit: Boolean = false
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
suspend fun init(accounts: List<AccountInfo>) {
|
||||
if (hasInit) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (pushHandler.savedDistributorExists()) {
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor()
|
||||
PushDistributorHandler.saveDistributor(currentDistributor)
|
||||
|
||||
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
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,54 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.ui.navigation.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.events.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,
|
||||
)
|
||||
@@ -3,6 +3,11 @@
|
||||
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>
|
||||
</queries>
|
||||
|
||||
<!-- Doesn't require a camera -->
|
||||
@@ -11,6 +16,9 @@
|
||||
<!-- To connect with relays -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- To take pictures -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- To Upload media (newer devices) -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
@@ -106,7 +114,7 @@
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<service
|
||||
android:name=".PlaybackService"
|
||||
android:name=".service.playback.PlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:stopWithTask="true"
|
||||
android:exported="true">
|
||||
@@ -116,6 +124,16 @@
|
||||
</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>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 androidx.compose.material3.adaptive
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.toComposeRect
|
||||
import androidx.window.layout.FoldingFeature
|
||||
|
||||
/**
|
||||
* Calculates the [Posture] for a given list of [FoldingFeature]s. This methods converts framework
|
||||
* folding info into the Material-opinionated posture info.
|
||||
*/
|
||||
fun calculatePosture(foldingFeatures: List<FoldingFeature>): Posture {
|
||||
var isTableTop = false
|
||||
val hingeList = mutableListOf<HingeInfo>()
|
||||
@Suppress("ListIterator")
|
||||
foldingFeatures.forEach {
|
||||
if (it.orientation == FoldingFeature.Orientation.HORIZONTAL &&
|
||||
it.state == FoldingFeature.State.HALF_OPENED
|
||||
) {
|
||||
isTableTop = true
|
||||
}
|
||||
hingeList.add(
|
||||
HingeInfo(
|
||||
bounds = it.bounds.toComposeRect(),
|
||||
isFlat = it.state == FoldingFeature.State.FLAT,
|
||||
isVertical = it.orientation == FoldingFeature.Orientation.VERTICAL,
|
||||
isSeparating = it.isSeparating,
|
||||
isOccluding = it.occlusionType == FoldingFeature.OcclusionType.FULL,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Posture(isTableTop, hingeList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Posture info that can help make layout adaptation decisions. For example when
|
||||
* [Posture.separatingVerticalHingeBounds] is not empty, the layout may want to avoid putting any
|
||||
* content over those hinge area. We suggest to use [calculatePosture] to retrieve instances of this
|
||||
* class in applications, unless you have a strong need of customization that cannot be fulfilled by
|
||||
* the default implementation.
|
||||
*
|
||||
* Note that the hinge bounds will be represent as [Rect] with window coordinates, instead of layout
|
||||
* coordinate.
|
||||
*
|
||||
* @constructor create an instance of [Posture]
|
||||
* @property isTabletop `true` if the current window is considered as in the table top mode, i.e.
|
||||
* there is one half-opened horizontal hinge in the middle of the current window. When
|
||||
* this is `true` it usually means it's hard for users to interact with the window area
|
||||
* around the hinge and developers may consider separating the layout along the hinge and
|
||||
* show software keyboard or other controls in the bottom half of the window.
|
||||
* @property hingeList a list of all hinges that are relevant to the posture.
|
||||
*/
|
||||
@Immutable
|
||||
class Posture(
|
||||
val isTabletop: Boolean = false,
|
||||
val hingeList: List<HingeInfo> = emptyList(),
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Posture) return false
|
||||
if (isTabletop != other.isTabletop) return false
|
||||
if (hingeList != other.hingeList) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = isTabletop.hashCode()
|
||||
result = 31 * result + hingeList.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
@Suppress("ListIterator")
|
||||
return "Posture(isTabletop=$isTabletop, " +
|
||||
"hinges=[${hingeList.joinToString(", ")}])"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of vertical hinge bounds that are separating.
|
||||
*/
|
||||
val Posture.separatingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isSeparating }
|
||||
|
||||
/**
|
||||
* Returns the list of vertical hinge bounds that are occluding.
|
||||
*/
|
||||
val Posture.occludingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isOccluding }
|
||||
|
||||
/**
|
||||
* Returns the list of all vertical hinge bounds.
|
||||
*/
|
||||
val Posture.allVerticalHingeBounds get() = hingeList.getBounds { isVertical }
|
||||
|
||||
/**
|
||||
* Returns the list of horizontal hinge bounds that are separating.
|
||||
*/
|
||||
val Posture.separatingHorizontalHingeBounds
|
||||
get() = hingeList.getBounds { !isVertical && isSeparating }
|
||||
|
||||
/**
|
||||
* Returns the list of horizontal hinge bounds that are occluding.
|
||||
*/
|
||||
val Posture.occludingHorizontalHingeBounds
|
||||
get() = hingeList.getBounds { !isVertical && isOccluding }
|
||||
|
||||
/**
|
||||
* Returns the list of all horizontal hinge bounds.
|
||||
*/
|
||||
val Posture.allHorizontalHingeBounds
|
||||
get() = hingeList.getBounds { !isVertical }
|
||||
|
||||
/**
|
||||
* A class that contains the info of a hinge relevant to a [Posture].
|
||||
*
|
||||
* @param bounds the bounds of the hinge in the relevant viewport.
|
||||
* @param isFlat `true` if the hinge is fully open and the relevant window space presented to the
|
||||
* user is flat.
|
||||
* @param isVertical `true` if the hinge is a vertical one, i.e., it separates the viewport into
|
||||
* left and right; `false` if the hinge is horizontal, i.e., it separates the viewport
|
||||
* into top and bottom.
|
||||
* @param isSeparating `true` if the hinge creates two logical display areas.
|
||||
* @param isOccluding `true` if the hinge conceals part of the display.
|
||||
*/
|
||||
@Immutable
|
||||
class HingeInfo(
|
||||
val bounds: Rect,
|
||||
val isFlat: Boolean,
|
||||
val isVertical: Boolean,
|
||||
val isSeparating: Boolean,
|
||||
val isOccluding: Boolean,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is HingeInfo) return false
|
||||
if (bounds != other.bounds) return false
|
||||
if (isFlat != other.isFlat) return false
|
||||
if (isVertical != other.isVertical) return false
|
||||
if (isSeparating != other.isSeparating) return false
|
||||
if (isOccluding != other.isOccluding) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = bounds.hashCode()
|
||||
result = 31 * result + isFlat.hashCode()
|
||||
result = 31 * result + isVertical.hashCode()
|
||||
result = 31 * result + isSeparating.hashCode()
|
||||
result = 31 * result + isOccluding.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String =
|
||||
"HingeInfo(bounds=$bounds, " +
|
||||
"isFlat=$isFlat, " +
|
||||
"isVertical=$isVertical, " +
|
||||
"isSeparating=$isSeparating, " +
|
||||
"isOccluding=$isOccluding)"
|
||||
}
|
||||
|
||||
private inline fun List<HingeInfo>.getBounds(predicate: HingeInfo.() -> Boolean): List<Rect> =
|
||||
@Suppress("ListIterator")
|
||||
mapNotNull { if (it.predicate()) it.bounds else null }
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 androidx.compose.material3.adaptive
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import androidx.window.layout.WindowInfoTracker
|
||||
import androidx.window.layout.WindowMetricsCalculator
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@Composable
|
||||
fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo {
|
||||
val windowSize =
|
||||
with(LocalDensity.current) {
|
||||
currentWindowSize().toSize().toDpSize()
|
||||
}
|
||||
return WindowAdaptiveInfo(
|
||||
WindowSizeClass.compute(windowSize.width.value, windowSize.height.value),
|
||||
calculatePosture(collectFoldingFeaturesAsState().value),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns and automatically update the current window size from [WindowMetricsCalculator].
|
||||
*
|
||||
* @return an [IntSize] that represents the current window size.
|
||||
*/
|
||||
@Composable
|
||||
fun currentWindowSize(): IntSize {
|
||||
// Observe view configuration changes and recalculate the size class on each change. We can't
|
||||
// use Activity#onConfigurationChanged as this will sometimes fail to be called on different
|
||||
// API levels, hence why this function needs to be @Composable so we can observe the
|
||||
// ComposeView's configuration changes.
|
||||
LocalConfiguration.current
|
||||
val windowBounds =
|
||||
WindowMetricsCalculator
|
||||
.getOrCreate()
|
||||
.computeCurrentWindowMetrics(LocalContext.current)
|
||||
.bounds
|
||||
return IntSize(windowBounds.width(), windowBounds.height())
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the current window folding features from [WindowInfoTracker] in to a [State].
|
||||
*
|
||||
* @return a [State] of a [FoldingFeature] list.
|
||||
*/
|
||||
@Composable
|
||||
fun collectFoldingFeaturesAsState(): State<List<FoldingFeature>> {
|
||||
val context = LocalContext.current
|
||||
return remember(context) {
|
||||
if (context is Activity) {
|
||||
// TODO(b/284347941) remove the instance check after the test bug is fixed.
|
||||
WindowInfoTracker
|
||||
.getOrCreate(context)
|
||||
.windowLayoutInfo(context)
|
||||
} else {
|
||||
WindowInfoTracker
|
||||
.getOrCreate(context)
|
||||
.windowLayoutInfo(context)
|
||||
}.map { it.displayFeatures.filterIsInstance<FoldingFeature>() }
|
||||
}.collectAsState(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* This class collects window info that affects adaptation decisions. An adaptive layout is supposed
|
||||
* to use the info from this class to decide how the layout is supposed to be adapted.
|
||||
*
|
||||
* @constructor create an instance of [WindowAdaptiveInfo]
|
||||
* @param windowSizeClass [WindowSizeClass] of the current window.
|
||||
* @param windowPosture [Posture] of the current window.
|
||||
*/
|
||||
@Immutable
|
||||
class WindowAdaptiveInfo(
|
||||
val windowSizeClass: WindowSizeClass,
|
||||
val windowPosture: Posture,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is WindowAdaptiveInfo) return false
|
||||
if (windowSizeClass != other.windowSizeClass) return false
|
||||
if (windowPosture != other.windowPosture) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = windowSizeClass.hashCode()
|
||||
result = 31 * result + windowPosture.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String = "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass, windowPosture=$windowPosture)"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 androidx.compose.material3.pullrefresh
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.platform.inspectable
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
|
||||
/**
|
||||
* A nested scroll modifier that provides scroll events to [state].
|
||||
*
|
||||
* Note that this modifier must be added above a scrolling container, such as a lazy column, in
|
||||
* order to receive scroll events. For example:
|
||||
*
|
||||
* @param state The [PullRefreshState] associated with this pull-to-refresh component. The state
|
||||
* will be updated by this modifier.
|
||||
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored.
|
||||
* @sample androidx.compose.material.samples.PullRefreshSample
|
||||
*/
|
||||
fun Modifier.pullRefresh(
|
||||
state: PullRefreshState,
|
||||
enabled: Boolean = true,
|
||||
) = inspectable(
|
||||
inspectorInfo =
|
||||
debugInspectorInfo {
|
||||
name = "pullRefresh"
|
||||
properties["state"] = state
|
||||
properties["enabled"] = enabled
|
||||
},
|
||||
) {
|
||||
Modifier.pullRefresh(state::onPull, state::onRelease, enabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom
|
||||
* pull refresh components.
|
||||
*
|
||||
* Note that this modifier must be added above a scrolling container, such as a lazy column, in
|
||||
* order to receive scroll events. For example:
|
||||
*
|
||||
* @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument.
|
||||
* Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling
|
||||
* down despite being at the top of a scrollable component), whereas negative delta (swiping up)
|
||||
* is dispatched first (in case it is needed to push the indicator back up), and then the
|
||||
* unconsumed delta is passed on to the child. The callback returns how much delta was consumed.
|
||||
* @param onRelease Callback for when drag is released, takes float flingVelocity as argument. The
|
||||
* callback returns how much velocity was consumed - in most cases this should only consume
|
||||
* velocity if pull refresh has been dragged already and the velocity is positive (the fling is
|
||||
* downwards), as an upwards fling should typically still scroll a scrollable component beneath
|
||||
* the pullRefresh. This is invoked before any remaining velocity is passed to the child.
|
||||
* @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither
|
||||
* [onPull] nor [onRelease] will be invoked.
|
||||
* @sample androidx.compose.material.samples.CustomPullRefreshSample
|
||||
*/
|
||||
fun Modifier.pullRefresh(
|
||||
onPull: (pullDelta: Float) -> Float,
|
||||
onRelease: suspend (flingVelocity: Float) -> Float,
|
||||
enabled: Boolean = true,
|
||||
) = inspectable(
|
||||
inspectorInfo =
|
||||
debugInspectorInfo {
|
||||
name = "pullRefresh"
|
||||
properties["onPull"] = onPull
|
||||
properties["onRelease"] = onRelease
|
||||
properties["enabled"] = enabled
|
||||
},
|
||||
) {
|
||||
Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled))
|
||||
}
|
||||
|
||||
private class PullRefreshNestedScrollConnection(
|
||||
private val onPull: (pullDelta: Float) -> Float,
|
||||
private val onRelease: suspend (flingVelocity: Float) -> Float,
|
||||
private val enabled: Boolean,
|
||||
) : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset =
|
||||
when {
|
||||
!enabled -> Offset.Zero
|
||||
source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset =
|
||||
when {
|
||||
!enabled -> Offset.Zero
|
||||
source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity = Velocity(0f, onRelease(available.y))
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 androidx.compose.material3.pullrefresh
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.center
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout.
|
||||
*
|
||||
* @param refreshing A boolean representing whether a refresh is occurring.
|
||||
* @param state The [PullRefreshState] which controls where and how the indicator will be drawn.
|
||||
* @param modifier Modifiers for the indicator.
|
||||
* @param backgroundColor The color of the indicator's background.
|
||||
* @param contentColor The color of the indicator's arc and arrow.
|
||||
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
|
||||
* @sample androidx.compose.material.samples.PullRefreshSample
|
||||
*/
|
||||
@Composable
|
||||
fun PullRefreshIndicator(
|
||||
refreshing: Boolean,
|
||||
state: PullRefreshState,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color = MaterialTheme.colorScheme.surface,
|
||||
contentColor: Color = contentColorFor(backgroundColor),
|
||||
scale: Boolean = false,
|
||||
) {
|
||||
val showElevation by
|
||||
remember(refreshing, state) { derivedStateOf { refreshing || state.position > 0.5f } }
|
||||
|
||||
Surface(
|
||||
modifier = modifier.size(IndicatorSize).pullRefreshIndicatorTransform(state, scale),
|
||||
shape = SpinnerShape,
|
||||
color = backgroundColor,
|
||||
shadowElevation = if (showElevation) Elevation else 0.dp,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = refreshing,
|
||||
animationSpec = tween(durationMillis = CROSSFADE_DURATION_MS),
|
||||
) { refreshing ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val spinnerSize = (ArcRadius + StrokeWidth).times(2)
|
||||
|
||||
if (refreshing) {
|
||||
CircularProgressIndicator(
|
||||
color = contentColor,
|
||||
strokeWidth = StrokeWidth,
|
||||
modifier = Modifier.size(spinnerSize),
|
||||
)
|
||||
} else {
|
||||
CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Modifier.size MUST be specified. */
|
||||
@Composable
|
||||
private fun CircularArrowIndicator(
|
||||
state: PullRefreshState,
|
||||
color: Color,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val path = remember { Path().apply { fillType = PathFillType.EvenOdd } }
|
||||
|
||||
val targetAlpha by
|
||||
remember(state) { derivedStateOf { if (state.progress >= 1f) MAX_ALPHA else MIN_ALPHA } }
|
||||
|
||||
val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween)
|
||||
|
||||
// Empty semantics for tests
|
||||
Canvas(modifier.semantics {}) {
|
||||
val values = ArrowValues(state.progress)
|
||||
val alpha = alphaState.value
|
||||
|
||||
rotate(degrees = values.rotation) {
|
||||
val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f
|
||||
val arcBounds =
|
||||
Rect(
|
||||
size.center.x - arcRadius,
|
||||
size.center.y - arcRadius,
|
||||
size.center.x + arcRadius,
|
||||
size.center.y + arcRadius,
|
||||
)
|
||||
drawArc(
|
||||
color = color,
|
||||
alpha = alpha,
|
||||
startAngle = values.startAngle,
|
||||
sweepAngle = values.endAngle - values.startAngle,
|
||||
useCenter = false,
|
||||
topLeft = arcBounds.topLeft,
|
||||
size = arcBounds.size,
|
||||
style =
|
||||
Stroke(
|
||||
width = StrokeWidth.toPx(),
|
||||
cap = StrokeCap.Square,
|
||||
),
|
||||
)
|
||||
drawArrow(path, arcBounds, color, alpha, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
private class ArrowValues(
|
||||
val rotation: Float,
|
||||
val startAngle: Float,
|
||||
val endAngle: Float,
|
||||
val scale: Float,
|
||||
)
|
||||
|
||||
private fun ArrowValues(progress: Float): ArrowValues {
|
||||
// Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%.
|
||||
val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3
|
||||
// How far beyond the threshold pull has gone, as a percentage of the threshold.
|
||||
val overshootPercent = abs(progress) - 1.0f
|
||||
// Limit the overshoot to 200%. Linear between 0 and 200.
|
||||
val linearTension = overshootPercent.coerceIn(0f, 2f)
|
||||
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
|
||||
val tensionPercent = linearTension - linearTension.pow(2) / 4
|
||||
|
||||
// Calculations based on SwipeRefreshLayout specification.
|
||||
val endTrim = adjustedPercent * MAX_PROGRESS_ARC
|
||||
val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f
|
||||
val startAngle = rotation * 360
|
||||
val endAngle = (rotation + endTrim) * 360
|
||||
val scale = min(1f, adjustedPercent)
|
||||
|
||||
return ArrowValues(rotation, startAngle, endAngle, scale)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawArrow(
|
||||
arrow: Path,
|
||||
bounds: Rect,
|
||||
color: Color,
|
||||
alpha: Float,
|
||||
values: ArrowValues,
|
||||
) {
|
||||
arrow.reset()
|
||||
arrow.moveTo(0f, 0f) // Move to left corner
|
||||
arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner
|
||||
|
||||
// Line to tip of arrow
|
||||
arrow.lineTo(
|
||||
x = ArrowWidth.toPx() * values.scale / 2,
|
||||
y = ArrowHeight.toPx() * values.scale,
|
||||
)
|
||||
|
||||
val radius = min(bounds.width, bounds.height) / 2f
|
||||
val inset = ArrowWidth.toPx() * values.scale / 2f
|
||||
arrow.translate(
|
||||
Offset(
|
||||
x = radius + bounds.center.x - inset,
|
||||
y = bounds.center.y + StrokeWidth.toPx() / 2f,
|
||||
),
|
||||
)
|
||||
arrow.close()
|
||||
rotate(degrees = values.endAngle) { drawPath(path = arrow, color = color, alpha = alpha) }
|
||||
}
|
||||
|
||||
private const val CROSSFADE_DURATION_MS = 100
|
||||
private const val MAX_PROGRESS_ARC = 0.8f
|
||||
|
||||
private val IndicatorSize = 40.dp
|
||||
private val SpinnerShape = CircleShape
|
||||
private val ArcRadius = 7.5.dp
|
||||
private val StrokeWidth = 2.5.dp
|
||||
private val ArrowWidth = 10.dp
|
||||
private val ArrowHeight = 5.dp
|
||||
private val Elevation = 6.dp
|
||||
|
||||
// Values taken from SwipeRefreshLayout
|
||||
private const val MIN_ALPHA = 0.3f
|
||||
private const val MAX_ALPHA = 1f
|
||||
private val AlphaTween = tween<Float>(300, easing = LinearEasing)
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 androidx.compose.material3.pullrefresh
|
||||
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.platform.inspectable
|
||||
|
||||
/**
|
||||
* A modifier for translating the position and scaling the size of a pull-to-refresh indicator based
|
||||
* on the given [PullRefreshState].
|
||||
*
|
||||
* @param state The [PullRefreshState] which determines the position of the indicator.
|
||||
* @param scale A boolean controlling whether the indicator's size scales with pull progress or not.
|
||||
* @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample
|
||||
*/
|
||||
fun Modifier.pullRefreshIndicatorTransform(
|
||||
state: PullRefreshState,
|
||||
scale: Boolean = false,
|
||||
) = inspectable(
|
||||
inspectorInfo =
|
||||
debugInspectorInfo {
|
||||
name = "pullRefreshIndicatorTransform"
|
||||
properties["state"] = state
|
||||
properties["scale"] = scale
|
||||
},
|
||||
) {
|
||||
Modifier
|
||||
// Essentially we only want to clip the at the top, so the indicator will not appear when
|
||||
// the position is 0. It is preferable to clip the indicator as opposed to the layout that
|
||||
// contains the indicator, as this would also end up clipping shadows drawn by items in a
|
||||
// list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE
|
||||
// for the other dimensions to allow for more room for elevation / arbitrary indicators - we
|
||||
// only ever really want to clip at the top edge.
|
||||
.drawWithContent {
|
||||
clipRect(
|
||||
top = 0f,
|
||||
left = -Float.MAX_VALUE,
|
||||
right = Float.MAX_VALUE,
|
||||
bottom = Float.MAX_VALUE,
|
||||
) {
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
}.graphicsLayer {
|
||||
translationY = state.position - size.height
|
||||
|
||||
if (scale && !state.refreshing) {
|
||||
val scaleFraction =
|
||||
LinearOutSlowInEasing.transform(state.position / state.threshold).coerceIn(0f, 1f)
|
||||
scaleX = scaleFraction
|
||||
scaleY = scaleFraction
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 androidx.compose.material3.pullrefresh
|
||||
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Creates a [PullRefreshState] that is remembered across compositions.
|
||||
*
|
||||
* Changes to [refreshing] will result in [PullRefreshState] being updated.
|
||||
*
|
||||
* @param refreshing A boolean representing whether a refresh is currently occurring.
|
||||
* @param onRefresh The function to be called to trigger a refresh.
|
||||
* @param refreshThreshold The threshold below which, if a release occurs, [onRefresh] will be
|
||||
* called.
|
||||
* @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This
|
||||
* offset corresponds to the position of the bottom of the indicator.
|
||||
* @sample androidx.compose.material.samples.PullRefreshSample
|
||||
*/
|
||||
@Composable
|
||||
fun rememberPullRefreshState(
|
||||
refreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold,
|
||||
refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset,
|
||||
): PullRefreshState {
|
||||
require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val onRefreshState = rememberUpdatedState(onRefresh)
|
||||
val thresholdPx: Float
|
||||
val refreshingOffsetPx: Float
|
||||
|
||||
with(LocalDensity.current) {
|
||||
thresholdPx = refreshThreshold.toPx()
|
||||
refreshingOffsetPx = refreshingOffset.toPx()
|
||||
}
|
||||
|
||||
val state =
|
||||
remember(scope) { PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) }
|
||||
|
||||
SideEffect {
|
||||
state.setRefreshing(refreshing)
|
||||
state.setThreshold(thresholdPx)
|
||||
state.setRefreshingOffset(refreshingOffsetPx)
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh
|
||||
* behaviour to a scroll component. Based on Android's SwipeRefreshLayout.
|
||||
*
|
||||
* Provides [progress], a float representing how far the user has pulled as a percentage of the
|
||||
* refreshThreshold. Values of one or less indicate that the user has not yet pulled past the
|
||||
* threshold. Values greater than one indicate how far past the threshold the user has pulled.
|
||||
*
|
||||
* Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like
|
||||
* pull-to-refresh behaviour with a custom indicator.
|
||||
*
|
||||
* Should be created using [rememberPullRefreshState].
|
||||
*/
|
||||
class PullRefreshState
|
||||
internal constructor(
|
||||
private val animationScope: CoroutineScope,
|
||||
private val onRefreshState: State<() -> Unit>,
|
||||
refreshingOffset: Float,
|
||||
threshold: Float,
|
||||
) {
|
||||
/**
|
||||
* A float representing how far the user has pulled as a percentage of the refreshThreshold.
|
||||
*
|
||||
* If the component has not been pulled at all, progress is zero. If the pull has reached halfway
|
||||
* to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has gone beyond
|
||||
* the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to two times the
|
||||
* refreshThreshold.
|
||||
*/
|
||||
val progress
|
||||
get() = adjustedDistancePulled / threshold
|
||||
|
||||
val refreshing
|
||||
get() = _refreshing
|
||||
|
||||
val position
|
||||
get() = _position
|
||||
|
||||
val threshold
|
||||
get() = _threshold
|
||||
|
||||
private val adjustedDistancePulled by derivedStateOf { distancePulled * DRAG_MULTIPLIER }
|
||||
|
||||
private var _refreshing by mutableStateOf(false)
|
||||
private var _position by mutableStateOf(0f)
|
||||
private var distancePulled by mutableStateOf(0f)
|
||||
private var _threshold by mutableStateOf(threshold)
|
||||
private var refreshingOffsetState by mutableStateOf(refreshingOffset)
|
||||
|
||||
internal fun onPull(pullDelta: Float): Float {
|
||||
if (_refreshing) return 0f // Already refreshing, do nothing.
|
||||
|
||||
val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f)
|
||||
val dragConsumed = newOffset - distancePulled
|
||||
distancePulled = newOffset
|
||||
_position = calculateIndicatorPosition()
|
||||
return dragConsumed
|
||||
}
|
||||
|
||||
internal fun onRelease(velocity: Float): Float {
|
||||
if (refreshing) return 0f // Already refreshing, do nothing
|
||||
|
||||
if (adjustedDistancePulled > threshold) {
|
||||
onRefreshState.value()
|
||||
}
|
||||
animateIndicatorTo(0f)
|
||||
val consumed =
|
||||
when {
|
||||
// We are flinging without having dragged the pull refresh (for example a fling inside
|
||||
// a list) - don't consume
|
||||
distancePulled == 0f -> 0f
|
||||
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
|
||||
// the list from scrolling
|
||||
velocity < 0f -> 0f
|
||||
// We are showing the indicator, and the fling is downwards - consume everything
|
||||
else -> velocity
|
||||
}
|
||||
distancePulled = 0f
|
||||
return consumed
|
||||
}
|
||||
|
||||
internal fun setRefreshing(refreshing: Boolean) {
|
||||
if (_refreshing != refreshing) {
|
||||
_refreshing = refreshing
|
||||
distancePulled = 0f
|
||||
animateIndicatorTo(if (refreshing) refreshingOffsetState else 0f)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun setThreshold(threshold: Float) {
|
||||
_threshold = threshold
|
||||
}
|
||||
|
||||
internal fun setRefreshingOffset(refreshingOffset: Float) {
|
||||
if (refreshingOffsetState != refreshingOffset) {
|
||||
refreshingOffsetState = refreshingOffset
|
||||
if (refreshing) animateIndicatorTo(refreshingOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure to cancel any existing animations when we launch a new one. We use this instead of
|
||||
// Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra
|
||||
// overhead of running through the animation pipeline instead of directly mutating the state.
|
||||
private val mutatorMutex = MutatorMutex()
|
||||
|
||||
private fun animateIndicatorTo(offset: Float) =
|
||||
animationScope.launch {
|
||||
mutatorMutex.mutate {
|
||||
animate(initialValue = _position, targetValue = offset) { value, _ -> _position = value }
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateIndicatorPosition(): Float =
|
||||
when {
|
||||
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
|
||||
adjustedDistancePulled <= threshold -> adjustedDistancePulled
|
||||
else -> {
|
||||
// How far beyond the threshold pull has gone, as a percentage of the threshold.
|
||||
val overshootPercent = abs(progress) - 1.0f
|
||||
// Limit the overshoot to 200%. Linear between 0 and 200.
|
||||
val linearTension = overshootPercent.coerceIn(0f, 2f)
|
||||
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
|
||||
val tensionPercent = linearTension - linearTension.pow(2) / 4
|
||||
// The additional offset beyond the threshold.
|
||||
val extraOffset = threshold * tensionPercent
|
||||
threshold + extraOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Default parameter values for [rememberPullRefreshState]. */
|
||||
object PullRefreshDefaults {
|
||||
/**
|
||||
* If the indicator is below this threshold offset when it is released, a refresh will be
|
||||
* triggered.
|
||||
*/
|
||||
val RefreshThreshold = 80.dp
|
||||
|
||||
/** The offset at which the indicator should be rendered whilst a refresh is occurring. */
|
||||
val RefreshingOffset = 56.dp
|
||||
}
|
||||
|
||||
/**
|
||||
* The distance pulled is multiplied by this value to give us the adjusted distance pulled, which is
|
||||
* used in calculating the indicator position (when the adjusted distance pulled is less than the
|
||||
* refresh threshold, it is the indicator position, otherwise the indicator position is derived from
|
||||
* the progress).
|
||||
*/
|
||||
private const val DRAG_MULTIPLIER = 0.5f
|
||||
156
amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
Normal file
156
amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.content.Context
|
||||
import android.os.Looper
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.service.playback.VideoCache
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class Amethyst : Application() {
|
||||
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Service Manager is only active when the activity is active.
|
||||
val serviceManager = ServiceManager(applicationIOScope)
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
applicationIOScope.cancel()
|
||||
}
|
||||
|
||||
fun nip95cache() = safeCacheDir.resolve("NIP95")
|
||||
|
||||
val videoCache: VideoCache by lazy {
|
||||
val newCache = VideoCache()
|
||||
runBlocking {
|
||||
newCache.initFileCache(
|
||||
this@Amethyst,
|
||||
safeCacheDir.resolve("exoplayer"),
|
||||
)
|
||||
}
|
||||
newCache
|
||||
}
|
||||
|
||||
val coilCache: DiskCache by lazy {
|
||||
DiskCache
|
||||
.Builder()
|
||||
.directory(safeCacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.2)
|
||||
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
|
||||
.build()
|
||||
}
|
||||
|
||||
val coilMemCache: MemoryCache by lazy {
|
||||
MemoryCache
|
||||
.Builder(this)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun contentResolverFn(): ContentResolver = contentResolver
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
instance = this
|
||||
|
||||
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
|
||||
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
|
||||
StrictMode.setThreadPolicy(
|
||||
ThreadPolicy
|
||||
.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
VmPolicy
|
||||
.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
Looper.getMainLooper().setMessageLogging(LogMonitor())
|
||||
ChoreographerHelper.start()
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val (value, elapsed) =
|
||||
measureTimedValue {
|
||||
// initializes the video cache in a thread
|
||||
videoCache
|
||||
}
|
||||
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
fun imageLoaderBuilder(): ImageLoader.Builder =
|
||||
ImageLoader
|
||||
.Builder(this)
|
||||
.diskCache { coilCache }
|
||||
.memoryCache { coilMemCache }
|
||||
.crossfade(true)
|
||||
|
||||
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.
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
GlobalScope.launch(Dispatchers.Default) { serviceManager.trimMemory() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: Amethyst
|
||||
private set
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.safeCacheDir: File
|
||||
get() {
|
||||
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
|
||||
return cacheDir.apply { mkdirs() }
|
||||
}
|
||||
180
amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt
Normal file
180
amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 coil.Coil
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.ammolite.relays.Client
|
||||
import com.vitorpamplona.ammolite.relays.RelayPool
|
||||
|
||||
fun debugState(context: Context) {
|
||||
Client
|
||||
.allSubscriptions()
|
||||
.forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toDebugJson() }}") }
|
||||
|
||||
NostrAccountDataSource.printCounter()
|
||||
NostrChannelDataSource.printCounter()
|
||||
NostrChatroomDataSource.printCounter()
|
||||
NostrChatroomListDataSource.printCounter()
|
||||
NostrCommunityDataSource.printCounter()
|
||||
NostrDiscoveryDataSource.printCounter()
|
||||
NostrHashtagDataSource.printCounter()
|
||||
NostrGeohashDataSource.printCounter()
|
||||
NostrHomeDataSource.printCounter()
|
||||
NostrSearchEventOrUserDataSource.printCounter()
|
||||
NostrSingleChannelDataSource.printCounter()
|
||||
NostrSingleEventDataSource.printCounter()
|
||||
NostrSingleUserDataSource.printCounter()
|
||||
NostrThreadDataSource.printCounter()
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
NostrVideoDataSource.printCounter()
|
||||
|
||||
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: " + RelayPool.connectedRelays())
|
||||
|
||||
val imageLoader = Coil.imageLoader(context)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB",
|
||||
)
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB",
|
||||
)
|
||||
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Notes: " +
|
||||
LocalCache.notes.filter { _, it -> it.liveSet != null }.size +
|
||||
" / " +
|
||||
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.liveSet != null }.size +
|
||||
" / " +
|
||||
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.liveSet != null }.size +
|
||||
" / " +
|
||||
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.recentMessages.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.get(it.first) }.forEach { (kind, qtt) ->
|
||||
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesNotes.get(kind)?.div((1024 * 1024))}MB ")
|
||||
}
|
||||
qttAddressables.toList().sortedByDescending { bytesNotes.get(it.first) }.forEach { (kind, qtt) ->
|
||||
Log.d("STATE DUMP", "Kind ${kind.toString().padStart(5,' ')}:\t${qtt.toString().padStart(6,' ')} elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
class EncryptedStorage {
|
||||
companion object {
|
||||
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 = 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)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
applicationContext,
|
||||
prefsFileName(npub),
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
) as EncryptedSharedPreferences
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.DefaultReactions
|
||||
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
// 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 RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
const val LOCAL_RELAY_SERVERS = "localRelayServers"
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val REACTION_CHOICES = "reactionChoices"
|
||||
const val DEFAULT_ZAPTYPE = "defaultZapType"
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_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_MUTE_LIST = "latestMuteList"
|
||||
const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList"
|
||||
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 SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
|
||||
const val WARN_ABOUT_REPORTS = "warn_about_reports"
|
||||
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
|
||||
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
|
||||
const val 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 var savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
|
||||
private var 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().apply()
|
||||
}
|
||||
} else if (currentAccount != info.npub) {
|
||||
currentAccount = info.npub
|
||||
if (!info.isTransient) {
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, info.npub) }.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun savedAccounts(): List<AccountInfo> {
|
||||
if (savedAccounts.value == null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
with(encryptedPreferences()) {
|
||||
val newSystemOfAccounts =
|
||||
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
|
||||
Event.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().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts.value)) }.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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()
|
||||
.apply {
|
||||
putString(
|
||||
PrefKeys.ALL_ACCOUNT_INFO,
|
||||
Event.mapper.writeValueAsString(accounts.filter { !it.isTransient }),
|
||||
)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return 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 updatePrefsForLogout(accountInfo: AccountInfo) {
|
||||
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}")
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences(accountInfo.npub).edit().clear().commit()
|
||||
removeAccount(accountInfo)
|
||||
deleteUserPreferenceFile(accountInfo.npub)
|
||||
|
||||
if (savedAccounts().isEmpty()) {
|
||||
updateCurrentAccount(null)
|
||||
} else if (currentAccount() == accountInfo.npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePrefsForLogin(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()
|
||||
.apply {
|
||||
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.RELAYS, Event.mapper.writeValueAsString(settings.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, settings.dontTranslateFrom)
|
||||
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
|
||||
putString(
|
||||
PrefKeys.LANGUAGE_PREFS,
|
||||
Event.mapper.writeValueAsString(settings.languagePreferences),
|
||||
)
|
||||
putString(PrefKeys.TRANSLATE_TO, settings.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value))
|
||||
putString(
|
||||
PrefKeys.REACTION_CHOICES,
|
||||
Event.mapper.writeValueAsString(settings.reactionChoices.value),
|
||||
)
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_FILE_SERVER,
|
||||
Event.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,
|
||||
Event.mapper.writeValueAsString(settings.zapPaymentRequest),
|
||||
)
|
||||
if (settings.backupContactList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_CONTACT_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupContactList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_CONTACT_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupUserMetadata != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_USER_METADATA,
|
||||
Event.mapper.writeValueAsString(settings.backupUserMetadata),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_USER_METADATA)
|
||||
}
|
||||
|
||||
if (settings.backupDMRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_DM_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupDMRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_DM_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupNIP65RelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_NIP65_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupNIP65RelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupSearchRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_SEARCH_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupSearchRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupMuteList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_MUTE_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupMuteList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_MUTE_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupPrivateHomeRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_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, Event.mapper.writeValueAsString(settings.torSettings.toSettings()))
|
||||
|
||||
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports)
|
||||
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers)
|
||||
|
||||
val regularMap =
|
||||
settings.lastReadPerRoute.value.mapValues {
|
||||
it.value.value
|
||||
}
|
||||
|
||||
putString(
|
||||
PrefKeys.LAST_READ_PER_ROUTE,
|
||||
Event.mapper.writeValueAsString(regularMap),
|
||||
)
|
||||
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
|
||||
|
||||
if (settings.showSensitiveContent.value == null) {
|
||||
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
|
||||
} else {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!)
|
||||
}
|
||||
|
||||
putString(
|
||||
PrefKeys.PENDING_ATTESTATIONS,
|
||||
Event.mapper.writeValueAsString(settings.pendingAttestations.value),
|
||||
)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
|
||||
|
||||
suspend fun saveSharedSettings(
|
||||
sharedSettings: Settings,
|
||||
prefs: SharedPreferences = encryptedPreferences(),
|
||||
) {
|
||||
Log.d("LocalPreferences", "Saving to shared settings")
|
||||
with(prefs.edit()) {
|
||||
putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings))
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
|
||||
Log.d("LocalPreferences", "Load shared settings")
|
||||
with(prefs) {
|
||||
return try {
|
||||
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { Event.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,
|
||||
)
|
||||
e.printStackTrace()
|
||||
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")
|
||||
|
||||
return 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 dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
val defaultHomeFollowList =
|
||||
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
val defaultStoriesFollowList =
|
||||
getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultNotificationFollowList =
|
||||
getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultDiscoveryFollowList =
|
||||
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val defaultZapType =
|
||||
getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
|
||||
LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName }
|
||||
} ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
|
||||
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
|
||||
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
|
||||
|
||||
val defaultFileServer = parseOrNull<Nip96MediaServers.ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0]
|
||||
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
|
||||
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
|
||||
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
|
||||
|
||||
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 latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
|
||||
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_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 useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
|
||||
val torSettings =
|
||||
if (useProxy) {
|
||||
// old settings, means Orbot
|
||||
TorSettings(
|
||||
TorType.EXTERNAL,
|
||||
getInt(PrefKeys.PROXY_PORT, 9050),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
|
||||
}
|
||||
|
||||
val showSensitiveContent =
|
||||
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
|
||||
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
|
||||
|
||||
val lastReadPerRoute =
|
||||
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,
|
||||
localRelays = localRelays,
|
||||
localRelayServers = localRelayServers,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
translateTo = translateTo,
|
||||
zapAmountChoices = MutableStateFlow(zapAmountChoices.toImmutableList()),
|
||||
reactionChoices = MutableStateFlow(reactionChoices.toImmutableList()),
|
||||
defaultZapType = MutableStateFlow(defaultZapType),
|
||||
defaultFileServer = defaultFileServer,
|
||||
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
|
||||
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
backupUserMetadata = latestUserMetadata,
|
||||
backupContactList = latestContactList,
|
||||
backupNIP65RelayList = latestNip65RelayList,
|
||||
backupDMRelayList = latestDmRelayList,
|
||||
backupSearchRelayList = latestSearchRelayList,
|
||||
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
|
||||
backupMuteList = latestMuteList,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
showSensitiveContent = MutableStateFlow(showSensitiveContent),
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
Event.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)
|
||||
e.printStackTrace()
|
||||
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)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
198
amethyst/src/main/java/com/vitorpamplona/amethyst/LogMonitor.kt
Normal file
198
amethyst/src/main/java/com/vitorpamplona/amethyst/LogMonitor.kt
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.util.Printer
|
||||
import android.view.Choreographer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class LogMonitor : Printer {
|
||||
private val mStackSampler: StackSampler
|
||||
private var mPrintingStarted = false
|
||||
private var mStartTimestamp: Long = 0
|
||||
|
||||
// threshold
|
||||
private val mBlockThresholdMillis: Long = 16
|
||||
|
||||
// Sampling frequency
|
||||
private val mSampleInterval: Long = 1000
|
||||
|
||||
private val mLogHandler: Handler
|
||||
|
||||
init {
|
||||
mStackSampler = StackSampler(mSampleInterval)
|
||||
val handlerThread = HandlerThread("block-canary-io")
|
||||
handlerThread.start()
|
||||
mLogHandler = Handler(handlerThread.getLooper())
|
||||
}
|
||||
|
||||
override fun println(x: String) {
|
||||
// From if to Else, execute DispatchMessage. If the execution takes more than the threshold, the output stuck information
|
||||
if (!mPrintingStarted) {
|
||||
// Record start time
|
||||
mStartTimestamp = System.currentTimeMillis()
|
||||
mPrintingStarted = true
|
||||
mStackSampler.startDump()
|
||||
} else {
|
||||
val endTime = System.currentTimeMillis()
|
||||
|
||||
if (x.indexOf("com.vitorpamplona.amethyst") > 0) {
|
||||
Log.d("block-canary", "Looper ${endTime - mStartTimestamp}ms for $x")
|
||||
}
|
||||
|
||||
mPrintingStarted = false
|
||||
//
|
||||
if (isBlock(endTime)) {
|
||||
notifyBlockEvent(endTime)
|
||||
}
|
||||
mStackSampler.stopDump()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyBlockEvent(endTime: Long) {
|
||||
mLogHandler.post {
|
||||
// Obtain the stack of the main thread stack
|
||||
val stacks: List<String> = mStackSampler.getStacks(mStartTimestamp, endTime)
|
||||
for (stack in stacks) {
|
||||
Log.e("block-canary", stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBlock(endTime: Long): Boolean = endTime - mStartTimestamp > mBlockThresholdMillis
|
||||
}
|
||||
|
||||
class StackSampler(
|
||||
private val mSampleInterval: Long,
|
||||
) {
|
||||
private val mHandler: Handler
|
||||
private val mStackMap: MutableMap<Long, String> = LinkedHashMap()
|
||||
private val mMaxCount = 100
|
||||
|
||||
// Whether to sample
|
||||
var mShouldSample: AtomicBoolean = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Start sampling and execute stack
|
||||
*/
|
||||
fun startDump() {
|
||||
// Avoid repeating start
|
||||
if (mShouldSample.get()) {
|
||||
return
|
||||
}
|
||||
mShouldSample.set(true)
|
||||
mHandler.removeCallbacks(mRunnable)
|
||||
mHandler.postDelayed(mRunnable, mSampleInterval)
|
||||
}
|
||||
|
||||
fun stopDump() {
|
||||
if (!mShouldSample.get()) {
|
||||
return
|
||||
}
|
||||
mShouldSample.set(false)
|
||||
mHandler.removeCallbacks(mRunnable)
|
||||
}
|
||||
|
||||
fun getStacks(
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
synchronized(mStackMap) {
|
||||
for (entryTime in mStackMap.keys) {
|
||||
if (startTime < entryTime && entryTime < endTime) {
|
||||
result.add(
|
||||
TIME_FORMATTER.format(entryTime) +
|
||||
SEPARATOR +
|
||||
SEPARATOR +
|
||||
mStackMap[entryTime],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
init {
|
||||
val handlerThread = HandlerThread("block-canary-sampler")
|
||||
handlerThread.start()
|
||||
mHandler = Handler(handlerThread.looper)
|
||||
}
|
||||
|
||||
private val mRunnable: Runnable =
|
||||
object : Runnable {
|
||||
override fun run() {
|
||||
val sb = StringBuilder()
|
||||
val stackTrace = Looper.getMainLooper().thread.stackTrace
|
||||
for (s in stackTrace) {
|
||||
sb.append(s.toString()).append("\n")
|
||||
}
|
||||
synchronized(mStackMap) {
|
||||
// Save up to 100 stack information
|
||||
if (mStackMap.size == mMaxCount) {
|
||||
mStackMap.remove(mStackMap.keys.iterator().next())
|
||||
}
|
||||
mStackMap.put(System.currentTimeMillis(), sb.toString())
|
||||
}
|
||||
if (mShouldSample.get()) {
|
||||
mHandler.postDelayed(this, mSampleInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SEPARATOR: String = "\r\n"
|
||||
val TIME_FORMATTER: SimpleDateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
|
||||
}
|
||||
}
|
||||
|
||||
object ChoreographerHelper {
|
||||
var lastFrameTimeNanos: Long = 0
|
||||
|
||||
fun start() {
|
||||
Choreographer.getInstance().postFrameCallback(
|
||||
object : Choreographer.FrameCallback {
|
||||
override fun doFrame(frameTimeNanos: Long) {
|
||||
// Last callback time
|
||||
if (lastFrameTimeNanos == 0L) {
|
||||
lastFrameTimeNanos = frameTimeNanos
|
||||
Choreographer.getInstance().postFrameCallback(this)
|
||||
return
|
||||
}
|
||||
val diff = (frameTimeNanos - lastFrameTimeNanos) / 1000000
|
||||
// only report after 30ms because videos play at 30fps
|
||||
if (diff > 35) {
|
||||
// Follow the frame number
|
||||
val droppedCount = (diff / 16.6).toInt()
|
||||
Log.w("block-canary", "Dropped $droppedCount frames. Skipped $diff ms")
|
||||
}
|
||||
lastFrameTimeNanos = frameTimeNanos
|
||||
Choreographer.getInstance().postFrameCallback(this)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import coil.Coil
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.size.Precision
|
||||
import coil.util.DebugLogger
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.Base64Fetcher
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.ammolite.relays.Client
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.ots.OpenTimestamps
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@Stable
|
||||
class ServiceManager(
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
private var isStarted: Boolean =
|
||||
false // to not open amber in a loop trying to use auth relays and registering for notifications
|
||||
private var account: Account? = null
|
||||
|
||||
private var collectorJob: Job? = null
|
||||
|
||||
private fun start(account: Account) {
|
||||
this.account = account
|
||||
start()
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account")
|
||||
if (isStarted && account != null) {
|
||||
return
|
||||
}
|
||||
Log.d("ServiceManager", "Starting Relay Services")
|
||||
|
||||
val myAccount = account
|
||||
|
||||
// Resets Proxy Use
|
||||
if (myAccount != null) {
|
||||
when (myAccount.settings.torSettings.torType.value) {
|
||||
TorType.INTERNAL -> {
|
||||
Log.d("TorManager", "Relays Service Connected ${TorManager.socksPort()}")
|
||||
HttpClientManager.setDefaultProxyOnPort(TorManager.socksPort())
|
||||
}
|
||||
TorType.EXTERNAL -> HttpClientManager.setDefaultProxyOnPort(myAccount.settings.torSettings.externalSocksPort.value)
|
||||
else -> HttpClientManager.setDefaultProxy(null)
|
||||
}
|
||||
|
||||
OtsEvent.otsInstance =
|
||||
OpenTimestamps(
|
||||
OkHttpBlockstreamExplorer(myAccount::shouldUseTorForMoneyOperations),
|
||||
OkHttpCalendarBuilder(myAccount::shouldUseTorForMoneyOperations),
|
||||
)
|
||||
} else {
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer { false }, OkHttpCalendarBuilder { false })
|
||||
|
||||
HttpClientManager.setDefaultProxy(null)
|
||||
}
|
||||
|
||||
LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true
|
||||
Coil.setImageLoader {
|
||||
Amethyst.instance
|
||||
.imageLoaderBuilder()
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
add(Base64Fetcher.Factory)
|
||||
}.apply {
|
||||
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
|
||||
this.logger(DebugLogger())
|
||||
}
|
||||
}.okHttpClient {
|
||||
myAccount?.shouldUseTorForImageDownload()?.let { HttpClientManager.getHttpClient(it) }
|
||||
?: HttpClientManager.getHttpClient(false)
|
||||
}.precision(Precision.INEXACT)
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
if (myAccount != null) {
|
||||
val relaySet = myAccount.connectToRelaysWithProxy.value
|
||||
Client.reconnect(relaySet)
|
||||
|
||||
collectorJob?.cancel()
|
||||
collectorJob = null
|
||||
collectorJob =
|
||||
scope.launch {
|
||||
myAccount.connectToRelaysWithProxy.collectLatest {
|
||||
delay(500)
|
||||
if (isStarted) {
|
||||
Client.reconnect(it, onlyIfChanged = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
NostrDiscoveryDataSource.account = myAccount
|
||||
|
||||
NostrAccountDataSource.otherAccounts =
|
||||
runBlocking {
|
||||
LocalPreferences.allSavedAccounts().mapNotNull {
|
||||
try {
|
||||
it.npub.bechToBytes().toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification Elements
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(3000)
|
||||
NostrChatroomListDataSource.start()
|
||||
NostrDiscoveryDataSource.start()
|
||||
NostrVideoDataSource.start()
|
||||
}
|
||||
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleChannelDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
isStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun pause() {
|
||||
Log.d("ServiceManager", "Pausing Relay Services")
|
||||
|
||||
collectorJob?.cancel()
|
||||
collectorJob = null
|
||||
|
||||
NostrAccountDataSource.stopSync()
|
||||
NostrHomeDataSource.stopSync()
|
||||
NostrChannelDataSource.stopSync()
|
||||
NostrChatroomDataSource.stopSync()
|
||||
NostrChatroomListDataSource.stopSync()
|
||||
NostrDiscoveryDataSource.stopSync()
|
||||
|
||||
NostrCommunityDataSource.stopSync()
|
||||
NostrHashtagDataSource.stopSync()
|
||||
NostrGeohashDataSource.stopSync()
|
||||
NostrSearchEventOrUserDataSource.stopSync()
|
||||
NostrSingleChannelDataSource.stopSync()
|
||||
NostrSingleEventDataSource.stopSync()
|
||||
NostrSingleUserDataSource.stopSync()
|
||||
NostrThreadDataSource.stopSync()
|
||||
NostrUserProfileDataSource.stopSync()
|
||||
NostrVideoDataSource.stopSync()
|
||||
|
||||
Client.reconnect(null)
|
||||
isStarted = false
|
||||
}
|
||||
|
||||
fun cleanObservers() {
|
||||
LocalCache.cleanObservers()
|
||||
}
|
||||
|
||||
suspend fun trimMemory() {
|
||||
LocalCache.cleanObservers()
|
||||
|
||||
val accounts =
|
||||
LocalPreferences.allSavedAccounts().mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
|
||||
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
NostrChatroomDataSource.clearEOSEs(it)
|
||||
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(accounts)
|
||||
LocalCache.pruneRepliesAndReactions(accounts)
|
||||
LocalCache.prunePastVersionsOfReplaceables()
|
||||
LocalCache.pruneExpiredEvents()
|
||||
}
|
||||
}
|
||||
|
||||
// This method keeps the pause/start in a Syncronized block to
|
||||
// avoid concurrent pauses and starts.
|
||||
@Synchronized
|
||||
fun forceRestart(
|
||||
account: Account? = null,
|
||||
start: Boolean = true,
|
||||
pause: Boolean = true,
|
||||
) {
|
||||
if (pause) {
|
||||
pause()
|
||||
}
|
||||
|
||||
if (start) {
|
||||
if (account != null) {
|
||||
start(account)
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restartIfDifferentAccount(account: Account) {
|
||||
if (this.account != account) {
|
||||
forceRestart(account, true, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun forceRestart() {
|
||||
forceRestart(null, true, true)
|
||||
}
|
||||
|
||||
fun justStart() {
|
||||
forceRestart(null, true, false)
|
||||
}
|
||||
|
||||
fun pauseForGood() {
|
||||
forceRestart(null, false, true)
|
||||
}
|
||||
|
||||
fun pauseForGoodAndClearAccount() {
|
||||
account = null
|
||||
forceRestart(null, false, true)
|
||||
}
|
||||
}
|
||||
3614
amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
Normal file
3614
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,526 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.compose.runtime.Stable
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.ammolite.relays.Constants
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
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 =
|
||||
setOf(
|
||||
// Anigma's Nostr
|
||||
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
|
||||
// Amethyst's Group
|
||||
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5",
|
||||
)
|
||||
|
||||
val DefaultReactions =
|
||||
persistentListOf(
|
||||
"\uD83D\uDE80",
|
||||
"\uD83E\uDEC2",
|
||||
"\uD83D\uDC40",
|
||||
"\uD83D\uDE02",
|
||||
"\uD83C\uDF89",
|
||||
"\uD83E\uDD14",
|
||||
"\uD83D\uDE31",
|
||||
)
|
||||
|
||||
val DefaultNIP65List =
|
||||
listOf(
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nos.lol/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
|
||||
)
|
||||
|
||||
val DefaultDMRelayList =
|
||||
listOf(
|
||||
RelayUrlFormatter.normalize("wss://auth.nostr1.com/"),
|
||||
RelayUrlFormatter.normalize("wss://nostr.mom/"),
|
||||
RelayUrlFormatter.normalize("wss://nos.lol/"),
|
||||
)
|
||||
|
||||
val DefaultSearchRelayList =
|
||||
listOf(
|
||||
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
|
||||
RelayUrlFormatter.normalize("wss://nostr.wine"),
|
||||
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
|
||||
)
|
||||
|
||||
val DefaultZapAmounts = persistentListOf(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
|
||||
}
|
||||
|
||||
// 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 KIND3_FOLLOWS = " All Follows "
|
||||
|
||||
@Stable
|
||||
class AccountSettings(
|
||||
val keyPair: KeyPair,
|
||||
val transientAccount: Boolean = false,
|
||||
var externalSignerPackageName: String? = null,
|
||||
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
|
||||
var localRelayServers: Set<String> = setOf(),
|
||||
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>> = MutableStateFlow(DefaultZapAmounts),
|
||||
var reactionChoices: MutableStateFlow<ImmutableList<String>> = MutableStateFlow(DefaultReactions),
|
||||
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType> = MutableStateFlow(LnZapEvent.ZapType.PUBLIC),
|
||||
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
|
||||
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
|
||||
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = 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 backupMuteList: MuteListEvent? = null,
|
||||
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
|
||||
val torSettings: TorSettingsFlow = TorSettingsFlow(),
|
||||
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
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(this))
|
||||
|
||||
class AccountSettingsUpdater(
|
||||
val accountSettings: AccountSettings,
|
||||
)
|
||||
|
||||
fun saveAccountSettings() {
|
||||
saveable.update { AccountSettingsUpdater(this) }
|
||||
}
|
||||
|
||||
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
|
||||
|
||||
fun createSigner() =
|
||||
if (keyPair.privKey != null) {
|
||||
NostrSignerInternal(keyPair)
|
||||
} else {
|
||||
when (val packageName = externalSignerPackageName) {
|
||||
null -> NostrSignerInternal(keyPair)
|
||||
else -> NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toHexKey(), packageName))
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// Zaps and Reactions
|
||||
// ---
|
||||
|
||||
fun changeDefaultZapType(zapType: LnZapEvent.ZapType) {
|
||||
if (defaultZapType.value != zapType) {
|
||||
defaultZapType.tryEmit(zapType)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
if (zapAmountChoices.value != newAmounts) {
|
||||
zapAmountChoices.tryEmit(newAmounts.toImmutableList())
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) {
|
||||
if (zapPaymentRequest != newServer) {
|
||||
zapPaymentRequest = newServer
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeReactionTypes(newTypes: List<String>) {
|
||||
if (reactionChoices.value != newTypes) {
|
||||
reactionChoices.tryEmit(newTypes.toImmutableList())
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// file servers
|
||||
// ---
|
||||
|
||||
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
|
||||
if (defaultFileServer != server) {
|
||||
defaultFileServer = server
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// list names
|
||||
// ---
|
||||
|
||||
fun changeDefaultHomeFollowList(name: String) {
|
||||
if (defaultHomeFollowList.value != name) {
|
||||
defaultHomeFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultStoriesFollowList(name: String) {
|
||||
if (defaultStoriesFollowList.value != name) {
|
||||
defaultStoriesFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultNotificationFollowList(name: String) {
|
||||
if (defaultNotificationFollowList.value != name) {
|
||||
defaultNotificationFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultDiscoveryFollowList(name: String) {
|
||||
if (defaultDiscoveryFollowList.value != name) {
|
||||
defaultDiscoveryFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// proxy settings
|
||||
// ---
|
||||
fun setTorSettings(newTorSettings: TorSettings): Boolean {
|
||||
if (torSettings.update(newTorSettings)) {
|
||||
saveAccountSettings()
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// language services
|
||||
// ---
|
||||
fun toggleDontTranslateFrom(languageCode: String) {
|
||||
if (!dontTranslateFrom.contains(languageCode)) {
|
||||
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
|
||||
saveAccountSettings()
|
||||
} else {
|
||||
dontTranslateFrom = dontTranslateFrom.minus(languageCode)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale) {
|
||||
if (translateTo != languageCode.language) {
|
||||
translateTo = languageCode.language
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun prefer(
|
||||
source: String,
|
||||
target: String,
|
||||
preference: String,
|
||||
) {
|
||||
val key = "$source,$target"
|
||||
if (key !in languagePreferences) {
|
||||
languagePreferences = languagePreferences + Pair(key, preference)
|
||||
saveAccountSettings()
|
||||
} 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"]
|
||||
|
||||
// ----
|
||||
// Backup Lists
|
||||
// ----
|
||||
|
||||
fun updateLocalRelayServers(servers: Set<String>) {
|
||||
if (localRelayServers != servers) {
|
||||
localRelayServers = 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 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 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()
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
// Warning dialogs
|
||||
// ----
|
||||
|
||||
fun setHideDeleteRequestDialog() {
|
||||
if (!hideDeleteRequestDialog) {
|
||||
hideDeleteRequestDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideNIP17WarningDialog() {
|
||||
if (!hideNIP17WarningDialog) {
|
||||
hideNIP17WarningDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideBlockAlertDialog() {
|
||||
if (!hideBlockAlertDialog) {
|
||||
hideBlockAlertDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (showSensitiveContent.value != show) {
|
||||
showSensitiveContent.update { show }
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
// local relays
|
||||
// ----
|
||||
|
||||
fun updateLocalRelays(newLocalRelays: Set<RelaySetupInfo>) {
|
||||
if (!localRelays.equals(newLocalRelays)) {
|
||||
localRelays = newLocalRelays
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// 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 updateOptOutOptions(
|
||||
warnReports: Boolean,
|
||||
filterSpam: Boolean,
|
||||
): Boolean =
|
||||
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) {
|
||||
warnAboutPostsWithReports = warnReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
|
||||
saveAccountSettings()
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.note.njumpLink
|
||||
import com.vitorpamplona.ammolite.relays.Relay
|
||||
import com.vitorpamplona.ammolite.relays.RelayStats
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
data class Spammer(
|
||||
val pubkeyHex: HexKey,
|
||||
var duplicatedMessages: Set<HexKey>,
|
||||
)
|
||||
|
||||
class AntiSpamFilter {
|
||||
val recentMessages = LruCache<Int, String>(1000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
var active: Boolean = true
|
||||
|
||||
fun isSpam(
|
||||
event: Event,
|
||||
relay: Relay?,
|
||||
): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (!active) return false
|
||||
|
||||
val idHex = event.id
|
||||
|
||||
// if short message, ok
|
||||
// The idea here is to avoid considering repeated "GM" messages spam.
|
||||
if (event.content.length < 50) 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 && Nip19Bech32.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 = (event.content + event.tags.flatten().joinToString(",")).hashCode()
|
||||
|
||||
if (
|
||||
(recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null
|
||||
) {
|
||||
Log.w(
|
||||
"Potential SPAM Message",
|
||||
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}",
|
||||
)
|
||||
|
||||
// Log down offenders
|
||||
logOffender(hash, event)
|
||||
|
||||
if (relay != null) {
|
||||
RelayStats.newSpam(relay.url, njumpLink(Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)))
|
||||
}
|
||||
|
||||
flowSpam.tryEmit(AntiSpamState(this))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
recentMessages.put(hash, idHex)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun logOffender(
|
||||
hashCode: Int,
|
||||
event: Event,
|
||||
) {
|
||||
if (spamMessages.get(hashCode) == null) {
|
||||
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
|
||||
} else {
|
||||
val spammer = spamMessages.get(hashCode)
|
||||
spammer.duplicatedMessages += event.id
|
||||
}
|
||||
}
|
||||
|
||||
val flowSpam = MutableStateFlow<AntiSpamState>(AntiSpamState(this))
|
||||
}
|
||||
|
||||
class AntiSpamState(
|
||||
val cache: AntiSpamFilter,
|
||||
)
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.commons.data.LargeCache
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.ammolite.relays.BundledUpdate
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
@Stable
|
||||
class PublicChatChannel(
|
||||
idHex: String,
|
||||
) : Channel(idHex) {
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
|
||||
fun updateChannelInfo(
|
||||
creator: User,
|
||||
channelInfo: ChannelCreateEvent.ChannelData,
|
||||
updatedAt: Long,
|
||||
) {
|
||||
this.info = channelInfo
|
||||
super.updateChannelInfo(creator, updatedAt)
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String = info.name ?: super.toBestDisplayName()
|
||||
|
||||
override fun summary(): String? = info.about
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) return super.profilePicture()
|
||||
return info.picture ?: super.profilePicture()
|
||||
}
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean = listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LiveActivitiesChannel(
|
||||
val address: ATag,
|
||||
) : Channel(address.toTag()) {
|
||||
var info: LiveActivitiesEvent? = null
|
||||
|
||||
override fun idNote() = address.toNAddr()
|
||||
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun address() = address
|
||||
|
||||
fun updateChannelInfo(
|
||||
creator: User,
|
||||
channelInfo: LiveActivitiesEvent,
|
||||
updatedAt: Long,
|
||||
) {
|
||||
this.info = channelInfo
|
||||
super.updateChannelInfo(creator, updatedAt)
|
||||
}
|
||||
|
||||
override fun toBestDisplayName(): String = info?.title() ?: super.toBestDisplayName()
|
||||
|
||||
override fun summary(): String? = info?.summary()
|
||||
|
||||
override fun profilePicture(): String? = info?.image()?.ifBlank { null }
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean =
|
||||
listOfNotNull(info?.title(), info?.summary())
|
||||
.filter { it.contains(prefix, true) }
|
||||
.isNotEmpty()
|
||||
}
|
||||
|
||||
@Stable
|
||||
abstract class Channel(
|
||||
val idHex: String,
|
||||
) {
|
||||
var creator: User? = null
|
||||
var updatedMetadataAt: Long = 0
|
||||
val notes = LargeCache<HexKey, Note>()
|
||||
var lastNoteCreatedAt: Long = 0
|
||||
|
||||
open fun id() = Hex.decode(idHex)
|
||||
|
||||
open fun idNote() = id().toNote()
|
||||
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
open fun toBestDisplayName(): String = idDisplayNote()
|
||||
|
||||
open fun summary(): String? = null
|
||||
|
||||
open fun creatorName(): String? = creator?.toBestDisplayName()
|
||||
|
||||
open fun profilePicture(): String? = creator?.info?.banner
|
||||
|
||||
open fun updateChannelInfo(
|
||||
creator: User,
|
||||
updatedAt: Long,
|
||||
) {
|
||||
this.creator = creator
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun addNote(note: Note) {
|
||||
notes.put(note.idHex, note)
|
||||
|
||||
if ((note.createdAt() ?: 0) > lastNoteCreatedAt) {
|
||||
lastNoteCreatedAt = note.createdAt() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
fun removeNote(note: Note) {
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
|
||||
fun removeNote(noteHex: String) {
|
||||
notes.remove(noteHex)
|
||||
}
|
||||
|
||||
abstract fun anyNameStartsWith(prefix: String): Boolean
|
||||
|
||||
// Observers line up here.
|
||||
val live: ChannelLiveData = ChannelLiveData(this)
|
||||
|
||||
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
|
||||
val important =
|
||||
notes
|
||||
.filter { key, it ->
|
||||
it.author?.let { author -> account.isHidden(author) } == false
|
||||
}.sortedWith(DefaultFeedOrder)
|
||||
.take(500)
|
||||
.toSet()
|
||||
|
||||
val toBeRemoved = notes.filter { key, it -> it !in important }
|
||||
|
||||
toBeRemoved.forEach { notes.remove(it.idHex) }
|
||||
|
||||
return toBeRemoved.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelLiveData(
|
||||
val channel: Channel,
|
||||
) : LiveData<ChannelState>(ChannelState(channel)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate {
|
||||
checkNotInMainThread()
|
||||
if (hasActiveObservers()) {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleChannelDataSource.add(channel)
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleChannelDataSource.remove(channel)
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelState(
|
||||
val channel: Channel,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Stable
|
||||
class Chatroom {
|
||||
var authors: Set<User> = setOf()
|
||||
var roomMessages: Set<Note> = setOf()
|
||||
var subject: String? = null
|
||||
var subjectCreatedAt: Long? = null
|
||||
|
||||
@Synchronized
|
||||
fun addMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
msg.author?.let { author ->
|
||||
if (author !in authors) {
|
||||
authors += author
|
||||
}
|
||||
}
|
||||
|
||||
val newSubject = msg.event?.subject()
|
||||
|
||||
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
|
||||
subject = newSubject
|
||||
subjectCreatedAt = msg.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg in roomMessages) {
|
||||
roomMessages = roomMessages - msg
|
||||
|
||||
roomMessages
|
||||
.filter { it.event?.subject() != null }
|
||||
.sortedBy { it.createdAt() }
|
||||
.lastOrNull()
|
||||
?.let {
|
||||
subject = it.event?.subject()
|
||||
subjectCreatedAt = it.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun senderIntersects(keySet: Set<HexKey>): Boolean = authors.any { it.pubkeyHex in keySet }
|
||||
|
||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
||||
val sorted = roomMessages.sortedWith(DefaultFeedOrder)
|
||||
|
||||
val toKeep =
|
||||
if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
|
||||
// Recent messages, keep last 100
|
||||
sorted.take(100).toSet()
|
||||
} else {
|
||||
// Old messages, keep the last one.
|
||||
sorted.take(1).toSet()
|
||||
} + sorted.filter { it.liveSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent }
|
||||
|
||||
val toRemove = roomMessages.minus(toKeep)
|
||||
roomMessages = toKeep
|
||||
return toRemove
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
|
||||
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.Footstr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Mate
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Nostr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Plebs
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Skull
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Weed
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Zap
|
||||
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.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderHashTagIconsPreview() {
|
||||
ThemeComparisonColumn {
|
||||
RenderRegular(
|
||||
"Testing rendering of hashtags: #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate",
|
||||
EmptyTagList,
|
||||
) { word, state ->
|
||||
when (word) {
|
||||
is HashTagSegment -> HashTag(word, EmptyNav)
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? =
|
||||
when (tag.lowercase()) {
|
||||
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
|
||||
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
|
||||
"lightning", "lightningnetwork" -> lightning
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> zap
|
||||
"amethyst" -> amethyst
|
||||
"cashu", "ecash", "nut", "nuts", "deeznuts" -> cashu
|
||||
"plebs", "pleb", "plebchain" -> plebs
|
||||
"coffee", "coffeechain", "cafe" -> coffee
|
||||
"skullofsatoshi" -> skull
|
||||
"grownostr", "gardening", "garden" -> growstr
|
||||
"footstr" -> footstr
|
||||
"tunestr", "music", "nowplaying" -> tunestr
|
||||
"mate", "matechain", "matestr" -> matestr
|
||||
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
|
||||
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))
|
||||
val lightning = HashtagIcon(CustomHashTagIcons.Lightning, "Lightning", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val zap = HashtagIcon(CustomHashTagIcons.Zap, "Zap", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val amethyst = HashtagIcon(CustomHashTagIcons.Amethyst, "Amethyst", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
|
||||
val cashu = HashtagIcon(CustomHashTagIcons.Cashu, "Cashu", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
|
||||
val plebs = HashtagIcon(CustomHashTagIcons.Plebs, "Pleb", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
|
||||
val coffee = HashtagIcon(CustomHashTagIcons.Coffee, "Coffee", Modifier.padding(start = 3.dp, bottom = 1.dp, top = 1.dp))
|
||||
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 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))
|
||||
|
||||
@Immutable
|
||||
class HashtagIcon(
|
||||
val icon: ImageVector,
|
||||
val description: String,
|
||||
val modifier: Modifier = Modifier,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 androidx.compose.runtime.Stable
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
|
||||
@JsonTypeInfo(
|
||||
use = JsonTypeInfo.Id.NAME,
|
||||
include = JsonTypeInfo.As.PROPERTY,
|
||||
property = "resourceType",
|
||||
)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(value = Practitioner::class, name = "Practitioner"),
|
||||
JsonSubTypes.Type(value = Patient::class, name = "Patient"),
|
||||
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 = "",
|
||||
var active: Boolean? = null,
|
||||
var name: ArrayList<HumanName> = arrayListOf(),
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class Patient(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
var active: Boolean? = null,
|
||||
var name: ArrayList<HumanName> = arrayListOf(),
|
||||
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 = given.joinToString(" ") + " " + family
|
||||
}
|
||||
|
||||
@Stable
|
||||
class Bundle(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
var type: String? = null,
|
||||
var created: String? = null,
|
||||
var entry: List<Resource> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class VisionPrescription(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
var status: String? = null,
|
||||
var created: String? = null,
|
||||
var patient: Reference? = Reference(),
|
||||
var encounter: Reference? = Reference(),
|
||||
var dateWritten: String? = null,
|
||||
var prescriber: Reference? = Reference(),
|
||||
var lensSpecification: List<LensSpecification> = arrayListOf(),
|
||||
) : 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: Prism? = null,
|
||||
// contact lenses
|
||||
var power: Double? = null,
|
||||
var backCurve: Double? = null,
|
||||
var diameter: Double? = null,
|
||||
var color: String? = null,
|
||||
var brand: String? = null,
|
||||
var note: String? = null,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class Prism(
|
||||
var amount: Double? = null,
|
||||
var base: String? = null,
|
||||
)
|
||||
|
||||
class Reference(
|
||||
var reference: String? = null,
|
||||
)
|
||||
|
||||
data class FhirElementDatabase(
|
||||
var baseResource: Resource? = null,
|
||||
var localDb: ImmutableMap<String, Resource> = persistentMapOf(),
|
||||
)
|
||||
|
||||
fun findReferenceInDb(
|
||||
it: String,
|
||||
db: Map<String, Resource>,
|
||||
): Resource? {
|
||||
val parts = it.split("/")
|
||||
return if (parts.size == 2) {
|
||||
db.get(parts[1].removePrefix("#"))
|
||||
} else {
|
||||
db.get(it.removePrefix("#"))
|
||||
}
|
||||
}
|
||||
|
||||
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
return try {
|
||||
val resource = mapper.readValue(json, Resource::class.java)
|
||||
|
||||
val db =
|
||||
when (resource) {
|
||||
is Bundle -> {
|
||||
resource.entry.associateBy { it.id }.toImmutableMap()
|
||||
}
|
||||
else -> {
|
||||
persistentMapOf(resource.id to resource)
|
||||
}
|
||||
}
|
||||
|
||||
FhirElementDatabase(
|
||||
localDb = db,
|
||||
baseResource = resource,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RenderEyeGlassesPrescription", "Parser error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
1046
amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
Normal file
1046
amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.encoders.HexKey
|
||||
|
||||
class ParticipantListBuilder {
|
||||
private fun addFollowsThatDirectlyParticipateOnToSet(
|
||||
baseNote: Note,
|
||||
followingSet: Set<HexKey>?,
|
||||
set: MutableSet<User>,
|
||||
) {
|
||||
baseNote.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
|
||||
set.add(author)
|
||||
}
|
||||
}
|
||||
|
||||
// Breaks these searchers down to avoid the memory use of creating multiple lists
|
||||
baseNote.replies.forEach { reply ->
|
||||
reply.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
|
||||
set.add(author)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.boosts.forEach { boost ->
|
||||
boost.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
|
||||
set.add(author)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.zaps.forEach { zapPair ->
|
||||
zapPair.key.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
|
||||
set.add(author)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseNote.reactions.forEach { reactionSet ->
|
||||
reactionSet.value.forEach { reaction ->
|
||||
reaction.author?.let { author ->
|
||||
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) {
|
||||
set.add(author)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun followsThatParticipateOnDirect(
|
||||
baseNote: Note?,
|
||||
followingSet: Set<HexKey>?,
|
||||
): Set<User> {
|
||||
if (baseNote == null) return mutableSetOf()
|
||||
|
||||
val set = mutableSetOf<User>()
|
||||
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set)
|
||||
return set
|
||||
}
|
||||
|
||||
fun followsThatParticipateOn(
|
||||
baseNote: Note?,
|
||||
followingSet: Set<HexKey>?,
|
||||
): Set<User> {
|
||||
if (baseNote == null) return mutableSetOf()
|
||||
|
||||
val mySet = mutableSetOf<User>()
|
||||
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet)
|
||||
|
||||
baseNote.replies.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
|
||||
|
||||
baseNote.boosts.forEach {
|
||||
it.replyTo?.forEach { addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet) }
|
||||
}
|
||||
|
||||
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.forEach { key, it ->
|
||||
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
|
||||
}
|
||||
|
||||
return mySet
|
||||
}
|
||||
|
||||
fun countFollowsThatParticipateOn(
|
||||
baseNote: Note?,
|
||||
followingSet: Set<HexKey>?,
|
||||
): Int {
|
||||
if (baseNote == null) return 0
|
||||
|
||||
val list = followsThatParticipateOn(baseNote, followingSet)
|
||||
|
||||
return list.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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,
|
||||
)
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class ThreadAssembler {
|
||||
private fun searchRoot(
|
||||
note: Note,
|
||||
testedNotes: MutableSet<Note> = mutableSetOf(),
|
||||
): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
|
||||
if (note.event is RepostEvent || note.event is GenericRepostEvent) return note
|
||||
|
||||
testedNotes.add(note)
|
||||
|
||||
val markedAsRoot =
|
||||
note.event
|
||||
?.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?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
|
||||
// recursive
|
||||
val roots =
|
||||
note.replyTo
|
||||
?.map {
|
||||
if (it !in testedNotes) {
|
||||
searchRoot(it, testedNotes)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}?.filterNotNull()
|
||||
|
||||
if (roots != null && roots.isNotEmpty()) {
|
||||
return roots[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun findThreadFor(noteId: String): Set<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val (result, elapsed) =
|
||||
measureTimedValue {
|
||||
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
|
||||
|
||||
if (note.event != null) {
|
||||
val thread = OnlyLatestVersionSet()
|
||||
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
|
||||
loadDown(threadRoot, thread)
|
||||
// adds the replies of the note in case the search for Root
|
||||
// did not added them.
|
||||
note.replies.forEach { loadDown(it, thread) }
|
||||
|
||||
thread
|
||||
} else {
|
||||
setOf(note)
|
||||
}
|
||||
}
|
||||
|
||||
println("Model Refresh: Thread loaded in $elapsed")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
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<ATag, 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: ATag,
|
||||
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,128 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
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
|
||||
if (
|
||||
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
) {
|
||||
return LevelSignature(
|
||||
signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";",
|
||||
createdAt = note.createdAt(),
|
||||
author = note.author,
|
||||
)
|
||||
}
|
||||
|
||||
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 (parent?.author == note.author && note.createdAt() != null) {
|
||||
// author of the thread first, in **ascending** order
|
||||
"9" +
|
||||
formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) +
|
||||
note.idHex.substring(0, 8)
|
||||
} else if (note.author?.pubkeyHex == account.pubkeyHex) {
|
||||
"8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies
|
||||
} else if (note.author?.pubkeyHex in accountFollowingSet) {
|
||||
"7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies.
|
||||
} else {
|
||||
"0" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // everyone else.
|
||||
}
|
||||
|
||||
val mySignature =
|
||||
LevelSignature(
|
||||
signature = parentSignature + "/" + threadOrder + ";",
|
||||
createdAt = note.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()
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return replyTo.maxOf {
|
||||
cachedLevels[it] ?: replyLevel(it, cachedLevels).apply { cachedLevels.put(it, this) }
|
||||
} + 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
@Stable
|
||||
object UrlCachedPreviewer {
|
||||
var cache = LruCache<String, UrlPreviewState>(100)
|
||||
private set
|
||||
|
||||
suspend fun previewInfo(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
onReady: suspend (UrlPreviewState) -> Unit,
|
||||
) {
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
|
||||
UrlPreview().fetch(
|
||||
url,
|
||||
forceProxy,
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
593
amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt
Normal file
593
amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.ammolite.relays.BundledUpdate
|
||||
import com.vitorpamplona.ammolite.relays.Relay
|
||||
import com.vitorpamplona.ammolite.relays.filters.EOSETime
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.UserMetadata
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Stable
|
||||
class User(
|
||||
val pubkeyHex: String,
|
||||
) {
|
||||
var info: UserMetadata? = null
|
||||
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
var latestMetadataRelay: String? = null
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestBookmarkList: BookmarkListEvent? = null
|
||||
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
|
||||
var latestEOSEs: Map<String, EOSETime> = emptyMap()
|
||||
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relaysBeingUsed = mapOf<String, RelayInfo>()
|
||||
private set
|
||||
|
||||
var privateChatrooms = mapOf<ChatroomKey, Chatroom>()
|
||||
private set
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
fun toNProfile(): String {
|
||||
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent)?.writeRelays()
|
||||
|
||||
return Nip19Bech32.createNProfile(
|
||||
pubkeyHex,
|
||||
relayList?.take(3) ?: listOfNotNull(latestMetadataRelay),
|
||||
)
|
||||
}
|
||||
|
||||
fun toNostrUri() = "nostr:${toNProfile()}"
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
|
||||
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 updateBookmark(event: BookmarkListEvent) {
|
||||
if (event.id == latestBookmarkList?.id) return
|
||||
|
||||
latestBookmarkList = event
|
||||
liveSet?.innerBookmarks?.invalidateData()
|
||||
}
|
||||
|
||||
fun clearEOSE() {
|
||||
latestEOSEs = emptyMap()
|
||||
}
|
||||
|
||||
fun updateContactList(event: ContactListEvent) {
|
||||
if (event.id == latestContactList?.id) return
|
||||
|
||||
val oldContactListEvent = latestContactList
|
||||
latestContactList = event
|
||||
|
||||
// Update following of the current user
|
||||
liveSet?.innerFollows?.invalidateData()
|
||||
flowSet?.follows?.invalidateData()
|
||||
|
||||
// Update Followers of the past user list
|
||||
// Update Followers of the new contact list
|
||||
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache
|
||||
.getUserIfExists(it)
|
||||
?.liveSet
|
||||
?.innerFollowers
|
||||
?.invalidateData()
|
||||
}
|
||||
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
|
||||
LocalCache
|
||||
.getUserIfExists(it)
|
||||
?.liveSet
|
||||
?.innerFollowers
|
||||
?.invalidateData()
|
||||
}
|
||||
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
flowSet?.relays?.invalidateData()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
val author = note.author ?: return
|
||||
|
||||
val reportsBy = reports[author]
|
||||
if (reportsBy == null) {
|
||||
reports = reports + Pair(author, setOf(note))
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
} else if (!reportsBy.contains(note)) {
|
||||
reports = reports + Pair(author, reportsBy + note)
|
||||
liveSet?.innerReports?.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))
|
||||
liveSet?.innerReports?.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addZap(
|
||||
zapRequest: Note,
|
||||
zap: Note?,
|
||||
) {
|
||||
if (zaps[zapRequest] == null) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZap(zapRequestOrZapEvent: Note) {
|
||||
if (zaps.containsKey(zapRequestOrZapEvent)) {
|
||||
zaps = zaps.minus(zapRequestOrZapEvent)
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zaps.containsValue(zapRequestOrZapEvent)) {
|
||||
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
|
||||
liveSet?.innerZaps?.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()
|
||||
|
||||
@Synchronized
|
||||
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
|
||||
checkNotInMainThread()
|
||||
|
||||
return privateChatrooms[key]
|
||||
?: run {
|
||||
val privateChatroom = Chatroom()
|
||||
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
|
||||
privateChatroom
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
|
||||
return getOrCreatePrivateChatroom(key)
|
||||
}
|
||||
|
||||
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom = privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
|
||||
|
||||
fun addMessage(
|
||||
room: ChatroomKey,
|
||||
msg: Note,
|
||||
) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(room)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addMessage(
|
||||
user: User,
|
||||
msg: Note,
|
||||
) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun createChatroom(withKey: ChatroomKey) {
|
||||
getOrCreatePrivateChatroom(withKey)
|
||||
}
|
||||
|
||||
fun removeMessage(
|
||||
user: User,
|
||||
msg: Note,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.removeMessageSync(msg)
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMessage(
|
||||
room: ChatroomKey,
|
||||
msg: Note,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
val privateChatroom = getOrCreatePrivateChatroom(room)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.removeMessageSync(msg)
|
||||
liveSet?.innerMessages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelayBeingUsed(
|
||||
relay: Relay,
|
||||
eventTime: Long,
|
||||
) {
|
||||
val here = relaysBeingUsed[relay.url]
|
||||
if (here == null) {
|
||||
relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1))
|
||||
} else {
|
||||
if (eventTime > here.lastEvent) {
|
||||
here.lastEvent = eventTime
|
||||
}
|
||||
here.counter++
|
||||
}
|
||||
|
||||
liveSet?.innerRelayInfo?.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()
|
||||
liveSet?.innerMetadata?.invalidateData()
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
|
||||
|
||||
fun isFollowingHashtag(tag: String): Boolean {
|
||||
return latestContactList?.unverifiedFollowTagSet()?.map { it.lowercase() }?.toSet()?.let {
|
||||
return tag.lowercase() in it
|
||||
}
|
||||
?: false
|
||||
}
|
||||
|
||||
fun isFollowingGeohash(geoTag: String): Boolean {
|
||||
return latestContactList?.unverifiedFollowAddressSet()?.toSet()?.let {
|
||||
return geoTag.lowercase() in it
|
||||
}
|
||||
?: false
|
||||
}
|
||||
|
||||
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
|
||||
|
||||
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
|
||||
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
|
||||
val messagesToUser = privateChatrooms[key] ?: return false
|
||||
|
||||
return messagesToUser.authors.any { this == it }
|
||||
}
|
||||
|
||||
fun hasReport(
|
||||
loggedIn: User,
|
||||
type: ReportEvent.ReportType,
|
||||
): Boolean =
|
||||
reports[loggedIn]?.firstOrNull {
|
||||
it.event is ReportEvent &&
|
||||
(it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
|
||||
} != null
|
||||
|
||||
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
|
||||
|
||||
var liveSet: UserLiveSet? = null
|
||||
var flowSet: UserFlowSet? = null
|
||||
|
||||
fun live(): UserLiveSet {
|
||||
if (liveSet == null) {
|
||||
createOrDestroyLiveSync(true)
|
||||
}
|
||||
return liveSet!!
|
||||
}
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
createOrDestroyLiveSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun createOrDestroyLiveSync(create: Boolean) {
|
||||
if (create) {
|
||||
if (liveSet == null) {
|
||||
liveSet = UserLiveSet(this)
|
||||
}
|
||||
} else {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun createOrDestroyFlowSync(create: Boolean) {
|
||||
if (create) {
|
||||
if (flowSet == null) {
|
||||
flowSet = UserFlowSet(this)
|
||||
}
|
||||
} else {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
flowSet?.destroy()
|
||||
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 relays = UserBundledRefresherFlow(u)
|
||||
|
||||
fun isInUse(): Boolean =
|
||||
metadata.stateFlow.subscriptionCount.value > 0 ||
|
||||
relays.stateFlow.subscriptionCount.value > 0 ||
|
||||
follows.stateFlow.subscriptionCount.value > 0
|
||||
|
||||
fun destroy() {
|
||||
metadata.destroy()
|
||||
relays.destroy()
|
||||
follows.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserLiveSet(
|
||||
u: User,
|
||||
) {
|
||||
val innerMetadata = UserBundledRefresherLiveData(u)
|
||||
|
||||
// UI Observers line up here.
|
||||
val innerFollows = UserBundledRefresherLiveData(u)
|
||||
val innerFollowers = UserBundledRefresherLiveData(u)
|
||||
val innerReports = UserBundledRefresherLiveData(u)
|
||||
val innerMessages = UserBundledRefresherLiveData(u)
|
||||
val innerRelays = UserBundledRefresherLiveData(u)
|
||||
val innerRelayInfo = UserBundledRefresherLiveData(u)
|
||||
val innerZaps = UserBundledRefresherLiveData(u)
|
||||
val innerBookmarks = UserBundledRefresherLiveData(u)
|
||||
val innerStatuses = UserBundledRefresherLiveData(u)
|
||||
|
||||
// UI Observers line up here.
|
||||
val metadata = innerMetadata.map { it }
|
||||
val follows = innerFollows.map { it }
|
||||
val followers = innerFollowers.map { it }
|
||||
val reports = innerReports.map { it }
|
||||
val messages = innerMessages.map { it }
|
||||
val relays = innerRelays.map { it }
|
||||
val relayInfo = innerRelayInfo.map { it }
|
||||
val zaps = innerZaps.map { it }
|
||||
val bookmarks = innerBookmarks.map { it }
|
||||
val statuses = innerStatuses.map { it }
|
||||
|
||||
val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged()
|
||||
|
||||
val nip05Changes = innerMetadata.map { it.user.nip05() }.distinctUntilChanged()
|
||||
|
||||
val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged()
|
||||
|
||||
fun isInUse(): Boolean =
|
||||
metadata.hasObservers() ||
|
||||
follows.hasObservers() ||
|
||||
followers.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
messages.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
relayInfo.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
bookmarks.hasObservers() ||
|
||||
statuses.hasObservers() ||
|
||||
profilePictureChanges.hasObservers() ||
|
||||
nip05Changes.hasObservers() ||
|
||||
userMetadataInfo.hasObservers()
|
||||
|
||||
fun destroy() {
|
||||
innerMetadata.destroy()
|
||||
innerFollows.destroy()
|
||||
innerFollowers.destroy()
|
||||
innerReports.destroy()
|
||||
innerMessages.destroy()
|
||||
innerRelays.destroy()
|
||||
innerRelayInfo.destroy()
|
||||
innerZaps.destroy()
|
||||
innerBookmarks.destroy()
|
||||
innerStatuses.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class RelayInfo(
|
||||
val url: String,
|
||||
var lastEvent: Long,
|
||||
var counter: Long,
|
||||
)
|
||||
|
||||
class UserBundledRefresherLiveData(
|
||||
val user: User,
|
||||
) : LiveData<UserState>(UserState(user)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate {
|
||||
checkNotInMainThread()
|
||||
|
||||
postValue(UserState(user))
|
||||
}
|
||||
}
|
||||
|
||||
fun <Y> map(transform: (UserState) -> Y): UserLoadingLiveData<Y> {
|
||||
val initialValue = this.value?.let { transform(it) }
|
||||
val result = UserLoadingLiveData(user, initialValue)
|
||||
result.addSource(this) { x -> result.value = transform(x) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserBundledRefresherFlow(
|
||||
val user: User,
|
||||
) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
val stateFlow = MutableStateFlow(UserState(user))
|
||||
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate {
|
||||
checkNotInMainThread()
|
||||
|
||||
stateFlow.emit(UserState(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UserLoadingLiveData<Y>(
|
||||
val user: User,
|
||||
initialValue: Y?,
|
||||
) : MediatorLiveData<Y>(initialValue) {
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleUserDataSource.add(user)
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleUserDataSource.remove(user)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable class UserState(
|
||||
val user: User,
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
object CreatedAtComparator : Comparator<Note> {
|
||||
override fun compare(
|
||||
first: Note?,
|
||||
second: Note?,
|
||||
): Int {
|
||||
val firstEvent = first?.event
|
||||
val secondEvent = second?.event
|
||||
|
||||
return if (firstEvent == null && secondEvent == null) {
|
||||
0
|
||||
} else if (firstEvent == null) {
|
||||
1
|
||||
} else if (secondEvent == null) {
|
||||
-1
|
||||
} else {
|
||||
firstEvent.createdAt().compareTo(secondEvent.createdAt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object CreatedAtComparatorAddresses : Comparator<AddressableNote> {
|
||||
override fun compare(
|
||||
first: AddressableNote?,
|
||||
second: AddressableNote?,
|
||||
): Int {
|
||||
val firstEvent = first?.event
|
||||
val secondEvent = second?.event
|
||||
|
||||
return if (firstEvent == null && secondEvent == null) {
|
||||
0
|
||||
} else if (firstEvent == null) {
|
||||
1
|
||||
} else if (secondEvent == null) {
|
||||
-1
|
||||
} else {
|
||||
firstEvent.createdAt().compareTo(secondEvent.createdAt())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class LatestByKindAndAuthor<T : Event>(
|
||||
private val kind: Int,
|
||||
private val pubkey: String,
|
||||
) {
|
||||
private val _latest = MutableStateFlow<T?>(null)
|
||||
val latest = _latest.asStateFlow()
|
||||
|
||||
fun matches(event: T) = event.kind == kind && event.pubKey == pubkey
|
||||
|
||||
fun updateIfMatches(event: T) {
|
||||
if (matches(event)) {
|
||||
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
|
||||
_latest.tryEmit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0
|
||||
|
||||
fun restart() {
|
||||
val latestNote =
|
||||
if ((kind in 10000..19999) || (kind in 30000..39999)) {
|
||||
LocalCache.addressables
|
||||
.maxOrNullOf(
|
||||
filter = { idHex: String, note: AddressableNote ->
|
||||
note.event?.let {
|
||||
it.kind() == kind && it.pubKey() == pubkey
|
||||
} == true
|
||||
},
|
||||
comparator = CreatedAtComparatorAddresses,
|
||||
)?.event as? T
|
||||
} else {
|
||||
LocalCache.notes
|
||||
.maxOrNullOf(
|
||||
filter = { idHex: String, note: Note ->
|
||||
note.event?.let {
|
||||
it.kind() == kind && it.pubKey() == pubkey
|
||||
} == true
|
||||
},
|
||||
comparator = CreatedAtComparator,
|
||||
)?.event as? T
|
||||
}
|
||||
|
||||
if (_latest.value != latestNote) {
|
||||
_latest.tryEmit(latestNote)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun init() {
|
||||
restart()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class LatestByKindWithETag<T : Event>(
|
||||
private val kind: Int,
|
||||
private val eTag: String,
|
||||
) {
|
||||
private val _latest = MutableStateFlow<T?>(null)
|
||||
val latest = _latest.asStateFlow()
|
||||
|
||||
fun matches(event: T) = event.kind == kind && event.isTaggedEvent(eTag)
|
||||
|
||||
fun updateIfMatches(event: T) {
|
||||
if (matches(event)) {
|
||||
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
|
||||
_latest.tryEmit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0
|
||||
|
||||
fun restart() {
|
||||
val latestNote =
|
||||
LocalCache.notes
|
||||
.maxOrNullOf(
|
||||
filter = { idHex: String, note: Note ->
|
||||
note.event?.let {
|
||||
it.kind() == kind && it.isTaggedEvent(eTag)
|
||||
} == true
|
||||
},
|
||||
comparator = CreatedAtComparator,
|
||||
)?.event as? T
|
||||
|
||||
if (_latest.value != latestNote) {
|
||||
_latest.tryEmit(latestNote)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun init() {
|
||||
restart()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.ammolite.relays.NostrDataSource
|
||||
import com.vitorpamplona.ammolite.relays.Relay
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
|
||||
abstract class AmethystNostrDataSource(
|
||||
debugName: String,
|
||||
) : NostrDataSource(debugName) {
|
||||
override fun consume(
|
||||
event: Event,
|
||||
relay: Relay,
|
||||
) {
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
}
|
||||
|
||||
override fun markAsSeenOnRelay(
|
||||
eventId: String,
|
||||
relay: Relay,
|
||||
) {
|
||||
val note = LocalCache.getNoteIfExists(eventId)
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent is AddressableEvent) {
|
||||
LocalCache.getAddressableNoteIfExists(noteEvent.address().toTag())?.addRelay(relay)
|
||||
} else {
|
||||
note?.addRelay(relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
|
||||
import java.util.Base64
|
||||
|
||||
@Stable
|
||||
class Base64Fetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val matcher = base64contentPattern.matcher(data.toString())
|
||||
|
||||
if (matcher.find()) {
|
||||
val base64String = matcher.group(2)
|
||||
|
||||
val byteArray = Base64.getDecoder().decode(base64String)
|
||||
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) ?: throw Exception("Unable to load base64 $base64String")
|
||||
|
||||
return DrawableResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
} else {
|
||||
throw Exception("Unable to load base64 $data")
|
||||
}
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(
|
||||
data: Uri,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher? {
|
||||
return if (base64contentPattern.matcher(data.toString()).find()) {
|
||||
return Base64Fetcher(options, data)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Base64Requester {
|
||||
fun imageRequest(
|
||||
context: Context,
|
||||
message: String,
|
||||
): ImageRequest =
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(message)
|
||||
.fetcherFactory(Base64Fetcher.Factory)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
|
||||
@Stable
|
||||
class BlurHashFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8")
|
||||
|
||||
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data")
|
||||
|
||||
return DrawableResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(
|
||||
data: Uri,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher = BlurHashFetcher(options, data)
|
||||
}
|
||||
}
|
||||
|
||||
object BlurHashRequester {
|
||||
fun imageRequest(
|
||||
context: Context,
|
||||
message: String,
|
||||
): ImageRequest {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("bluehash:$encodedMessage")
|
||||
.fetcherFactory(BlurHashFetcher.Factory)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
object CachedRichTextParser {
|
||||
private val richTextCache = LruCache<Int, RichTextViewerState>(50)
|
||||
|
||||
private fun hashCodeCache(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String?,
|
||||
): Int {
|
||||
var result = content.hashCode()
|
||||
result = 31 * result + tags.lists.hashCode()
|
||||
if (callbackUri != null) {
|
||||
result = 31 * result + callbackUri.hashCode()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String? = null,
|
||||
): RichTextViewerState {
|
||||
val key = hashCodeCache(content, tags, callbackUri)
|
||||
val cached = richTextCache[key]
|
||||
return if (cached != null) {
|
||||
cached
|
||||
} else {
|
||||
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
|
||||
richTextCache.put(key, newUrls)
|
||||
newUrls
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.content.Context
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.cbor.ByteString
|
||||
import kotlinx.serialization.cbor.Cbor
|
||||
import kotlinx.serialization.decodeFromByteArray
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.Base64
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Immutable
|
||||
data class CashuToken(
|
||||
val token: String,
|
||||
val mint: String,
|
||||
val totalAmount: Long,
|
||||
val proofs: List<Proof>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Immutable
|
||||
class Proof(
|
||||
val amount: Int,
|
||||
val id: String,
|
||||
val secret: String,
|
||||
val C: String,
|
||||
)
|
||||
|
||||
object CachedCashuProcessor {
|
||||
val cashuCache = LruCache<String, GenericLoadable<ImmutableList<CashuToken>>>(20)
|
||||
|
||||
fun cached(token: String): GenericLoadable<ImmutableList<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
|
||||
|
||||
fun parse(token: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
if (cashuCache[token] !is GenericLoadable.Loaded) {
|
||||
checkNotInMainThread()
|
||||
val newCachuData = CashuProcessor().parse(token)
|
||||
|
||||
cashuCache.put(token, newCachuData)
|
||||
}
|
||||
|
||||
return cashuCache[token]
|
||||
}
|
||||
}
|
||||
|
||||
class CashuProcessor {
|
||||
@Serializable
|
||||
class V3Token(
|
||||
val unit: String?, // unit
|
||||
val memo: String?, // memo
|
||||
val token: List<V3T>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class V3T(
|
||||
val mint: String,
|
||||
val proofs: List<Proof>,
|
||||
)
|
||||
|
||||
fun parse(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (cashuToken.startsWith("cashuA")) {
|
||||
return parseCashuA(cashuToken)
|
||||
}
|
||||
|
||||
if (cashuToken.startsWith("cashuB")) {
|
||||
return parseCashuB(cashuToken)
|
||||
}
|
||||
|
||||
return GenericLoadable.Error("Could not parse this cashu token")
|
||||
}
|
||||
|
||||
fun parseCashuA(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val base64token = cashuToken.replace("cashuA", "")
|
||||
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))
|
||||
|
||||
if (cashu.token == null) {
|
||||
return GenericLoadable.Error("No token found")
|
||||
}
|
||||
|
||||
val converted =
|
||||
cashu.token.map { token ->
|
||||
val proofs = token.proofs
|
||||
val mint = token.mint
|
||||
|
||||
var totalAmount = 0L
|
||||
for (proof in proofs) {
|
||||
totalAmount += proof.amount
|
||||
}
|
||||
|
||||
CashuToken(cashuToken, mint, totalAmount, proofs)
|
||||
}
|
||||
|
||||
return GenericLoadable.Loaded(converted.toImmutableList())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
return GenericLoadable.Error("Could not parse this cashu token")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class V4Token(
|
||||
val m: String, // mint
|
||||
val u: String, // unit
|
||||
val d: String? = null, // memo
|
||||
val t: Array<V4T>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class V4T(
|
||||
@ByteString
|
||||
val i: ByteArray, // identifier
|
||||
val p: Array<V4Proof>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class V4Proof(
|
||||
val a: Int, // amount
|
||||
val s: String, // secret
|
||||
@ByteString
|
||||
val c: ByteArray, // signature
|
||||
val d: V4DleqProof? = null, // no idea what this is
|
||||
val w: String? = null, // witness
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class V4DleqProof(
|
||||
@ByteString
|
||||
val e: ByteArray,
|
||||
@ByteString
|
||||
val s: ByteArray,
|
||||
@ByteString
|
||||
val r: ByteArray,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun parseCashuB(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val base64token = cashuToken.replace("cashuB", "")
|
||||
|
||||
val parser = Cbor { ignoreUnknownKeys = true }
|
||||
|
||||
val v4Token = parser.decodeFromByteArray<V4Token>(Base64.getUrlDecoder().decode(base64token))
|
||||
|
||||
val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found")
|
||||
|
||||
val converted =
|
||||
v4proofs.map { id ->
|
||||
val proofs =
|
||||
id.p.map {
|
||||
Proof(
|
||||
it.a,
|
||||
id.i.toHexKey(),
|
||||
it.s,
|
||||
it.c.toHexKey(),
|
||||
)
|
||||
}
|
||||
val mint = v4Token.m
|
||||
|
||||
var totalAmount = 0L
|
||||
for (proof in proofs) {
|
||||
totalAmount += proof.amount
|
||||
}
|
||||
|
||||
CashuToken(cashuToken, mint, totalAmount, proofs)
|
||||
}
|
||||
|
||||
return GenericLoadable.Loaded(converted.toImmutableList())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
if (e is CancellationException) throw e
|
||||
return GenericLoadable.Error("Could not parse this cashu token")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun melt(
|
||||
token: CashuToken,
|
||||
lud16: String,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String, String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
runCatching {
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
// Make invoice and leave room for fees
|
||||
milliSats = token.totalAmount * 1000,
|
||||
message = "Calculate Fees for Cashu",
|
||||
forceProxy = forceProxy,
|
||||
onSuccess = { baseInvoice ->
|
||||
feeCalculator(
|
||||
token.mint,
|
||||
baseInvoice,
|
||||
forceProxy = forceProxy,
|
||||
onSuccess = { fees ->
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
// Make invoice and leave room for fees
|
||||
milliSats = (token.totalAmount - fees) * 1000,
|
||||
message = "Redeem Cashu",
|
||||
forceProxy = forceProxy,
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, fees, forceProxy, onSuccess, onError, context)
|
||||
},
|
||||
onProgress = {},
|
||||
onError = onError,
|
||||
context = context,
|
||||
)
|
||||
},
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
},
|
||||
onProgress = {},
|
||||
onError = onError,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun feeCalculator(
|
||||
mintAddress: String,
|
||||
invoice: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: (Int) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
val jsonObject = factory.objectNode()
|
||||
jsonObject.put("pr", invoice)
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
val body = it.body.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
|
||||
val feeCost = tree?.get("fee")?.asInt()
|
||||
|
||||
if (feeCost != null) {
|
||||
onSuccess(
|
||||
feeCost,
|
||||
)
|
||||
} else {
|
||||
val msg =
|
||||
tree
|
||||
?.get("detail")
|
||||
?.asText()
|
||||
?.split('.')
|
||||
?.getOrNull(0)
|
||||
?.ifBlank { null }
|
||||
onError(
|
||||
stringRes(context, R.string.cashu_failed_redemption),
|
||||
if (msg != null) {
|
||||
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
|
||||
} else {
|
||||
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(
|
||||
stringRes(context, R.string.cashu_successful_redemption),
|
||||
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(
|
||||
token: CashuToken,
|
||||
invoice: String,
|
||||
fees: Int,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: (String, String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
try {
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
val jsonObject = factory.objectNode()
|
||||
|
||||
jsonObject.replace(
|
||||
"proofs",
|
||||
factory.arrayNode(token.proofs.size).apply {
|
||||
token.proofs.forEach {
|
||||
addObject().apply {
|
||||
put("amount", it.amount)
|
||||
put("id", it.id)
|
||||
put("secret", it.secret)
|
||||
put("C", it.C)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
jsonObject.put("pr", invoice)
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
val body = it.body.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
|
||||
val successful = tree?.get("paid")?.asText() == "true"
|
||||
|
||||
if (successful) {
|
||||
onSuccess(
|
||||
stringRes(context, R.string.cashu_successful_redemption),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.cashu_successful_redemption_explainer,
|
||||
token.totalAmount.toString(),
|
||||
fees.toString(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val msg =
|
||||
tree
|
||||
?.get("detail")
|
||||
?.asText()
|
||||
?.split('.')
|
||||
?.getOrNull(0)
|
||||
?.ifBlank { null }
|
||||
onError(
|
||||
stringRes(context, R.string.cashu_failed_redemption),
|
||||
if (msg != null) {
|
||||
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, msg)
|
||||
} else {
|
||||
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(
|
||||
stringRes(context, R.string.cashu_successful_redemption),
|
||||
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
fun String.isUTF16Char(pos: Int): Boolean {
|
||||
return Character.charCount(this.codePointAt(pos)) == 2
|
||||
}
|
||||
fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2
|
||||
|
||||
fun String.firstFullCharOld(): String {
|
||||
return when (this.length) {
|
||||
0, 1 -> return this
|
||||
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
|
||||
0,
|
||||
1,
|
||||
-> return this
|
||||
2,
|
||||
3,
|
||||
-> return if (isUTF16Char(0)) this.take(2) else this.take(1)
|
||||
else -> {
|
||||
val first = isUTF16Char(0)
|
||||
val second = isUTF16Char(2)
|
||||
@@ -96,7 +118,7 @@ fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
|
||||
val emojiParts = this.split(":", limit = 3)
|
||||
if (emojiParts.size >= 2) {
|
||||
val emojiName = emojiParts[1]
|
||||
val emojiUrl = tags.lists.firstOrNull() { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
|
||||
val emojiUrl = tags.lists.firstOrNull { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
|
||||
if (emojiUrl != null) {
|
||||
return ":$emojiName:$emojiUrl"
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageDecoder
|
||||
import android.media.MediaDataSource
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMetadataRetriever.BitmapParams
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.IOException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FileHeader(
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
) {
|
||||
companion object {
|
||||
suspend fun prepare(
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
forceProxy: Boolean,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl, forceProxy)
|
||||
|
||||
if (imageData != null) {
|
||||
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
|
||||
} else {
|
||||
onError("Unable to download image from $fileUrl")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val hash = CryptoUtils.sha256(data).toHexKey()
|
||||
val size = data.size
|
||||
|
||||
val (blurHash, dim) =
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
val opt = BitmapFactory.Options()
|
||||
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
|
||||
|
||||
val intArray = IntArray(mBitmap.width * mBitmap.height)
|
||||
mBitmap.getPixels(
|
||||
intArray,
|
||||
0,
|
||||
mBitmap.width,
|
||||
0,
|
||||
0,
|
||||
mBitmap.width,
|
||||
mBitmap.height,
|
||||
)
|
||||
|
||||
val dim = "${mBitmap.width}x${mBitmap.height}"
|
||||
|
||||
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
Pair(
|
||||
BlurHash.encode(
|
||||
intArray,
|
||||
mBitmap.width,
|
||||
mBitmap.height,
|
||||
9,
|
||||
(9 * (1 / aspectRatio)).roundToInt(),
|
||||
),
|
||||
dim,
|
||||
)
|
||||
} else if (aspectRatio < 1) {
|
||||
Pair(
|
||||
BlurHash.encode(
|
||||
intArray,
|
||||
mBitmap.width,
|
||||
mBitmap.height,
|
||||
(9 * aspectRatio).roundToInt(),
|
||||
9,
|
||||
),
|
||||
dim,
|
||||
)
|
||||
} else {
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
|
||||
}
|
||||
} else if (mimeType?.startsWith("video/") == true) {
|
||||
val mediaMetadataRetriever = MediaMetadataRetriever()
|
||||
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
|
||||
|
||||
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
|
||||
|
||||
val blurhash =
|
||||
mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
|
||||
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
|
||||
|
||||
val intArray = IntArray(thumbnail.width * thumbnail.height)
|
||||
thumbnail.getPixels(
|
||||
intArray,
|
||||
0,
|
||||
thumbnail.width,
|
||||
0,
|
||||
0,
|
||||
thumbnail.width,
|
||||
thumbnail.height,
|
||||
)
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
BlurHash.encode(
|
||||
intArray,
|
||||
thumbnail.width,
|
||||
thumbnail.height,
|
||||
9,
|
||||
(9 * (1 / aspectRatio)).roundToInt(),
|
||||
)
|
||||
} else if (aspectRatio < 1) {
|
||||
BlurHash.encode(
|
||||
intArray,
|
||||
thumbnail.width,
|
||||
thumbnail.height,
|
||||
(9 * aspectRatio).roundToInt(),
|
||||
9,
|
||||
)
|
||||
} else {
|
||||
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if (newDim != "0x0") {
|
||||
Pair(blurhash, newDim)
|
||||
} else {
|
||||
Pair(blurhash, null)
|
||||
}
|
||||
} else {
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(mimeType, hash, size, dim, blurHash))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
|
||||
val raw: ByteArray? = getEmbeddedPicture()
|
||||
if (raw != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw))
|
||||
}
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val params = BitmapParams()
|
||||
params.preferredConfig = Bitmap.Config.ARGB_8888
|
||||
|
||||
// Fall back to middle of video
|
||||
// Note: METADATA_KEY_DURATION unit is in ms, not us.
|
||||
val thumbnailTimeUs: Long =
|
||||
(extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
|
||||
val width = prepareVideoWidth() ?: return null
|
||||
val height = prepareVideoHeight() ?: return null
|
||||
|
||||
return "${width}x$height"
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareVideoWidth(): Int? {
|
||||
val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
return if (widthData.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
widthData.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaMetadataRetriever.prepareVideoHeight(): Int? {
|
||||
val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
return if (heightData.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
heightData.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
class ByteArrayMediaDataSource(
|
||||
var imageData: ByteArray,
|
||||
) : MediaDataSource() {
|
||||
override fun getSize(): Long = imageData.size.toLong()
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readAt(
|
||||
position: Long,
|
||||
buffer: ByteArray,
|
||||
offset: Int,
|
||||
size: Int,
|
||||
): Int {
|
||||
if (position >= imageData.size) {
|
||||
return -1
|
||||
}
|
||||
val newSize =
|
||||
if (position + size > imageData.size) {
|
||||
size - ((position.toInt() + size) - imageData.size)
|
||||
} else {
|
||||
size
|
||||
}
|
||||
|
||||
imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize)
|
||||
|
||||
return newSize
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
class HttpStatusMessages {
|
||||
companion object {
|
||||
fun resourceIdFor(statusCode: Int = 0): Int? =
|
||||
when (statusCode) {
|
||||
400 -> R.string.http_status_400
|
||||
401 -> R.string.http_status_401
|
||||
402 -> R.string.http_status_402
|
||||
403 -> R.string.http_status_403
|
||||
404 -> R.string.http_status_404
|
||||
405 -> R.string.http_status_405
|
||||
406 -> R.string.http_status_406
|
||||
407 -> R.string.http_status_407
|
||||
408 -> R.string.http_status_408
|
||||
409 -> R.string.http_status_409
|
||||
410 -> R.string.http_status_410
|
||||
411 -> R.string.http_status_411
|
||||
412 -> R.string.http_status_412
|
||||
413 -> R.string.http_status_413
|
||||
414 -> R.string.http_status_414
|
||||
415 -> R.string.http_status_415
|
||||
416 -> R.string.http_status_416
|
||||
417 -> R.string.http_status_417
|
||||
426 -> R.string.http_status_426
|
||||
|
||||
500 -> R.string.http_status_500
|
||||
501 -> R.string.http_status_501
|
||||
502 -> R.string.http_status_502
|
||||
503 -> R.string.http_status_503
|
||||
504 -> R.string.http_status_504
|
||||
505 -> R.string.http_status_505
|
||||
506 -> R.string.http_status_506
|
||||
507 -> R.string.http_status_507
|
||||
508 -> R.string.http_status_508
|
||||
511 -> R.string.http_status_511
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.HandlerThread
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class LocationUtil(
|
||||
context: Context,
|
||||
) {
|
||||
companion object {
|
||||
const val MIN_TIME: Long = 1000L
|
||||
const val MIN_DISTANCE: Float = 0.0f
|
||||
}
|
||||
|
||||
private val locationManager =
|
||||
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private var locationListener: LocationListener? = null
|
||||
|
||||
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
|
||||
val providerState = mutableStateOf(false)
|
||||
val isStart: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
private val locHandlerThread = HandlerThread("LocationUtil Thread")
|
||||
|
||||
init {
|
||||
locHandlerThread.start()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(
|
||||
minTimeMs: Long = MIN_TIME,
|
||||
minDistanceM: Float = MIN_DISTANCE,
|
||||
) {
|
||||
locationListener().let {
|
||||
locationListener = it
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
minTimeMs,
|
||||
minDistanceM,
|
||||
it,
|
||||
locHandlerThread.looper,
|
||||
)
|
||||
}
|
||||
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
isStart.value = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
locationListener?.let { locationManager.removeUpdates(it) }
|
||||
isStart.value = false
|
||||
}
|
||||
|
||||
private fun locationListener() =
|
||||
object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
locationStateFlow.value = location
|
||||
}
|
||||
|
||||
override fun onProviderEnabled(provider: String) {
|
||||
providerState.value = true
|
||||
}
|
||||
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
providerState.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object CachedGeoLocations {
|
||||
val locationNames = LruCache<String, String>(20)
|
||||
|
||||
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
|
||||
|
||||
suspend fun geoLocate(
|
||||
geoHashStr: String,
|
||||
location: Location,
|
||||
context: Context,
|
||||
): String? {
|
||||
locationNames[geoHashStr]?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
|
||||
|
||||
if (name != null) {
|
||||
locationNames.put(geoHashStr, name)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
private class ReverseGeoLocationUtil {
|
||||
suspend fun execute(
|
||||
location: Location,
|
||||
context: Context,
|
||||
): String? {
|
||||
return try {
|
||||
Geocoder(context)
|
||||
.getFromLocation(location.latitude, location.longitude, 1)
|
||||
?.firstOrNull()
|
||||
?.let { address ->
|
||||
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
|
||||
.joinToString(", ")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.os.Looper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
|
||||
fun checkNotInMainThread() {
|
||||
if (BuildConfig.DEBUG && isMainThread()) {
|
||||
throw OnMainThreadException("It should not be in the MainThread")
|
||||
}
|
||||
}
|
||||
|
||||
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
|
||||
|
||||
class OnMainThreadException(
|
||||
str: String,
|
||||
) : RuntimeException(str)
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
|
||||
class Nip05NostrAddressVerifier {
|
||||
fun assembleUrl(nip05address: String): String? {
|
||||
val parts = nip05address.trim().split("@")
|
||||
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
|
||||
}
|
||||
if (parts.size == 1) {
|
||||
return "https://${parts[0]}/.well-known/nostr.json?name=_"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun fetchNip05Json(
|
||||
nip05: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
try {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
|
||||
HttpClientManager
|
||||
.getHttpClient(forceProxy(url))
|
||||
.newBuilder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError(
|
||||
"Could not resolve $nip05. Error: ${it.code}. Check if the server is up and if the address $nip05 is correct",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Could not resolve NIP-05 $nip05 as URL $url: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyNip05(
|
||||
nip05: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
// check fails on tests
|
||||
checkNotInMainThread()
|
||||
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchNip05Json(
|
||||
nip05,
|
||||
forceProxy,
|
||||
onSuccess = {
|
||||
checkNotInMainThread()
|
||||
|
||||
// NIP05 usernames are case insensitive, but JSON properties are not
|
||||
// converts the json to lowercase and then tries to access the username via a
|
||||
// lowercase version of the username.
|
||||
val nip05url =
|
||||
try {
|
||||
mapper.readTree(it.lowercase())
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
val parts = nip05.split("@")
|
||||
val user =
|
||||
if (parts.size == 2) {
|
||||
parts[0].lowercase()
|
||||
} else {
|
||||
"_"
|
||||
}
|
||||
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
|
||||
if (hexKey == null) {
|
||||
onError("Username not found in the NIP05 JSON [$nip05]")
|
||||
} else {
|
||||
onSuccess(hexKey)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
object Nip11CachedRetriever {
|
||||
open class RetrieveResult(
|
||||
val time: Long,
|
||||
)
|
||||
|
||||
class RetrieveResultError(
|
||||
val error: Nip11Retriever.ErrorCode,
|
||||
val msg: String? = null,
|
||||
) : RetrieveResult(TimeUtils.now())
|
||||
|
||||
class RetrieveResultSuccess(
|
||||
val data: Nip11RelayInformation,
|
||||
) : RetrieveResult(TimeUtils.now())
|
||||
|
||||
class RetrieveResultLoading : RetrieveResult(TimeUtils.now())
|
||||
|
||||
private val relayInformationDocumentCache = LruCache<String, RetrieveResult?>(100)
|
||||
private val retriever = Nip11Retriever()
|
||||
|
||||
fun getFromCache(dirtyUrl: String): Nip11RelayInformation? {
|
||||
val result = relayInformationDocumentCache.get(RelayUrlFormatter.getHttpsUrl(dirtyUrl)) ?: return null
|
||||
if (result is RetrieveResultSuccess) return result.data
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
dirtyUrl: String,
|
||||
forceProxy: Boolean,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
val url = RelayUrlFormatter.getHttpsUrl(dirtyUrl)
|
||||
val doc = relayInformationDocumentCache.get(url)
|
||||
|
||||
if (doc != null) {
|
||||
if (doc is RetrieveResultSuccess) {
|
||||
onInfo(doc.data)
|
||||
} else if (doc is RetrieveResultLoading) {
|
||||
if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) {
|
||||
// just wait.
|
||||
} else {
|
||||
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
|
||||
}
|
||||
} else if (doc is RetrieveResultError) {
|
||||
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
|
||||
onError(dirtyUrl, doc.error, null)
|
||||
} else {
|
||||
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun retrieve(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
forceProxy: Boolean,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
relayInformationDocumentCache.put(url, RetrieveResultLoading())
|
||||
retriever.loadRelayInfo(
|
||||
url = url,
|
||||
dirtyUrl = dirtyUrl,
|
||||
forceProxy = forceProxy,
|
||||
onInfo = {
|
||||
checkNotInMainThread()
|
||||
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
|
||||
onInfo(it)
|
||||
},
|
||||
onError = { dirtyUrl, code, errorMsg ->
|
||||
checkNotInMainThread()
|
||||
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
|
||||
onError(url, code, errorMsg)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Nip11Retriever {
|
||||
enum class ErrorCode {
|
||||
FAIL_TO_ASSEMBLE_URL,
|
||||
FAIL_TO_REACH_SERVER,
|
||||
FAIL_TO_PARSE_RESULT,
|
||||
FAIL_WITH_HTTP_STATUS,
|
||||
}
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
forceProxy: Boolean,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
try {
|
||||
val request: Request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClientManager
|
||||
.getHttpClient(forceProxy)
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(
|
||||
call: Call,
|
||||
response: Response,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
onInfo(Nip11RelayInformation.fromJson(body))
|
||||
} else {
|
||||
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(
|
||||
"RelayInfoFail",
|
||||
"Resulting Message from Relay $dirtyUrl in not parseable: $body",
|
||||
e,
|
||||
)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
call: Call,
|
||||
e: IOException,
|
||||
) {
|
||||
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
|
||||
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Request
|
||||
import java.net.URI
|
||||
import java.net.URL
|
||||
|
||||
object Nip96MediaServers {
|
||||
val DEFAULT =
|
||||
listOf(
|
||||
ServerName("Nostr.Build", "https://nostr.build"),
|
||||
ServerName("NostrCheck.me", "https://nostrcheck.me"),
|
||||
ServerName("NostPic", "https://nostpic.com"),
|
||||
ServerName("Sovbit", "https://files.sovbit.host"),
|
||||
ServerName("Void.cat", "https://void.cat"),
|
||||
)
|
||||
|
||||
data class ServerName(
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
)
|
||||
|
||||
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
|
||||
|
||||
suspend fun load(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
): Nip96Retriever.ServerInfo {
|
||||
val cached = cache[url]
|
||||
if (cached != null) return cached
|
||||
|
||||
val fetched = Nip96Retriever().loadInfo(url, forceProxy)
|
||||
cache[url] = fetched
|
||||
return fetched
|
||||
}
|
||||
}
|
||||
|
||||
class Nip96Retriever {
|
||||
data class ServerInfo(
|
||||
@JsonProperty("api_url") val apiUrl: String,
|
||||
@JsonProperty("download_url") val downloadUrl: String? = null,
|
||||
@JsonProperty("delegated_to_url") val delegatedToUrl: String? = null,
|
||||
@JsonProperty("supported_nips") val supportedNips: ArrayList<Int> = arrayListOf(),
|
||||
@JsonProperty("tos_url") val tosUrl: String? = null,
|
||||
@JsonProperty("content_types") val contentTypes: ArrayList<MimeType> = arrayListOf(),
|
||||
@JsonProperty("plans") val plans: Map<PlanName, Plan> = mapOf(),
|
||||
)
|
||||
|
||||
data class Plan(
|
||||
@JsonProperty("name") val name: String? = null,
|
||||
@JsonProperty("is_nip98_required") val isNip98Required: Boolean? = null,
|
||||
@JsonProperty("url") val url: String? = null,
|
||||
@JsonProperty("max_byte_size") val maxByteSize: Long? = null,
|
||||
@JsonProperty("file_expiration") val fileExpiration: ArrayList<Int> = arrayListOf(),
|
||||
@JsonProperty("media_transformations")
|
||||
val mediaTransformations: Map<MimeType, Array<String>> = emptyMap(),
|
||||
)
|
||||
|
||||
fun parse(
|
||||
baseUrl: String,
|
||||
body: String,
|
||||
): ServerInfo {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
val serverInfo = mapper.readValue(body, ServerInfo::class.java)
|
||||
|
||||
return serverInfo.copy(
|
||||
apiUrl = makeAbsoluteIfRelativeUrl(baseUrl, serverInfo.apiUrl),
|
||||
downloadUrl = serverInfo.downloadUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
|
||||
delegatedToUrl = serverInfo.delegatedToUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
|
||||
tosUrl = serverInfo.tosUrl?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
|
||||
plans =
|
||||
serverInfo.plans.mapValues { u ->
|
||||
u.value.copy(
|
||||
url = u.value.url?.let { makeAbsoluteIfRelativeUrl(baseUrl, it) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadInfo(
|
||||
baseUrl: String,
|
||||
forceProxy: Boolean,
|
||||
): ServerInfo {
|
||||
checkNotInMainThread()
|
||||
|
||||
val request: Request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
|
||||
.build()
|
||||
|
||||
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use { response ->
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
return parse(baseUrl, body)
|
||||
} else {
|
||||
throw RuntimeException(
|
||||
"Resulting Message from $baseUrl is an error: ${response.code} ${response.message}",
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("RelayInfoFail", "Resulting Message from $baseUrl in not parseable: $body", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias PlanName = String
|
||||
|
||||
typealias MimeType = String
|
||||
|
||||
fun makeAbsoluteIfRelativeUrl(
|
||||
baseUrl: String,
|
||||
potentialyRelativeUrl: String,
|
||||
): String =
|
||||
try {
|
||||
val apiUrl = URI(potentialyRelativeUrl)
|
||||
if (apiUrl.isAbsolute) {
|
||||
potentialyRelativeUrl
|
||||
} else {
|
||||
URL(URL(baseUrl), potentialyRelativeUrl).toString()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
potentialyRelativeUrl
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.net.toFile
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
|
||||
class Nip96Uploader(
|
||||
val account: Account?,
|
||||
) {
|
||||
suspend fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96MediaServers.ServerName,
|
||||
contentResolver: ContentResolver,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
val serverInfo =
|
||||
Nip96Retriever()
|
||||
.loadInfo(
|
||||
server.baseUrl,
|
||||
forceProxy(server.baseUrl),
|
||||
)
|
||||
|
||||
return uploadImage(
|
||||
uri,
|
||||
contentType,
|
||||
size,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
serverInfo,
|
||||
contentResolver,
|
||||
forceProxy,
|
||||
onProgress,
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
size: Long?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
contentResolver: ContentResolver,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
checkNotInMainThread()
|
||||
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
val length =
|
||||
size
|
||||
?: contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
}
|
||||
?: kotlin.runCatching { uri.toFile().length() }.getOrNull() ?: 0
|
||||
|
||||
checkNotNull(imageInputStream) { "Can't open the image input stream" }
|
||||
|
||||
return uploadImage(
|
||||
imageInputStream,
|
||||
length,
|
||||
myContentType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
server,
|
||||
forceProxy,
|
||||
onProgress,
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun uploadImage(
|
||||
inputStream: InputStream,
|
||||
length: Long,
|
||||
contentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
checkNotInMainThread()
|
||||
|
||||
val fileName = randomChars()
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl))
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
val requestBody: RequestBody =
|
||||
MultipartBody
|
||||
.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("expiration", "")
|
||||
.addFormDataPart("size", length.toString())
|
||||
.also { body ->
|
||||
alt?.ifBlank { null }?.let { body.addFormDataPart("alt", it) }
|
||||
sensitiveContent?.let { body.addFormDataPart("content-warning", it) }
|
||||
contentType?.let { body.addFormDataPart("content_type", it) }
|
||||
}.addFormDataPart(
|
||||
"file",
|
||||
"$fileName.$extension",
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType?.toMediaType()
|
||||
|
||||
override fun contentLength() = length
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
},
|
||||
).build()
|
||||
|
||||
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
requestBuilder
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.apiUrl)
|
||||
.post(requestBody)
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
response.body.use { body ->
|
||||
val str = body.string()
|
||||
val result = parseResults(str)
|
||||
|
||||
if (!result.processingUrl.isNullOrBlank()) {
|
||||
return waitProcessing(result, server, forceProxy, onProgress)
|
||||
} else if (result.status == "success" && result.nip94Event != null) {
|
||||
return result.nip94Event
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, result.message))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val msg = response.body.string()
|
||||
|
||||
val errorMessage =
|
||||
try {
|
||||
val tree = jacksonObjectMapper().readTree(msg)
|
||||
val status = tree.get("status")?.asText()
|
||||
val message = tree.get("message")?.asText()
|
||||
if (status == "error" && message != null) {
|
||||
message
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||
if (errorMessage != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, errorMessage))
|
||||
} else if (explanation != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, stringRes(context, explanation)))
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_with_message, response.code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(
|
||||
hash: String,
|
||||
contentType: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
context: Context,
|
||||
): Boolean {
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl))
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
|
||||
|
||||
val request =
|
||||
requestBuilder
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
|
||||
.delete()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
response.body.use { body ->
|
||||
val str = body.string()
|
||||
val result = parseDeleteResults(str)
|
||||
return result.status == "success"
|
||||
}
|
||||
} else {
|
||||
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||
if (explanation != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation)))
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitProcessing(
|
||||
result: Nip96Result,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
): PartialEvent {
|
||||
var currentResult = result
|
||||
|
||||
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
|
||||
onProgress((currentResult.percentage ?: 100) / 100f)
|
||||
|
||||
val request: Request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(result.processingUrl)
|
||||
.build()
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(result.processingUrl))
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
it.body.use { currentResult = parseResults(it.string()) }
|
||||
}
|
||||
}
|
||||
|
||||
delay(500)
|
||||
}
|
||||
onProgress((currentResult.percentage ?: 100) / 100f)
|
||||
|
||||
val nip94 = currentResult.nip94Event
|
||||
|
||||
if (nip94 != null) {
|
||||
return nip94
|
||||
} else {
|
||||
throw RuntimeException("Error waiting for processing. Final result is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nip98Header(url: String): String? =
|
||||
withTimeoutOrNull(5000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
nip98Header(url, "POST") { authorizationToken -> continuation.resume(authorizationToken) }
|
||||
}
|
||||
}
|
||||
|
||||
fun nip98Header(
|
||||
url: String,
|
||||
method: String,
|
||||
file: ByteArray? = null,
|
||||
onReady: (String?) -> Unit,
|
||||
) {
|
||||
val myAccount = account
|
||||
|
||||
if (myAccount == null) {
|
||||
onReady(null)
|
||||
return
|
||||
}
|
||||
|
||||
myAccount.createHTTPAuthorization(url, method, file) {
|
||||
val encodedNIP98Event = Base64.getEncoder().encodeToString(it.toJson().toByteArray())
|
||||
onReady("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
|
||||
data class DeleteResult(
|
||||
val status: String?,
|
||||
val message: String?,
|
||||
)
|
||||
|
||||
private fun parseDeleteResults(body: String): DeleteResult {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, DeleteResult::class.java)
|
||||
}
|
||||
|
||||
data class Nip96Result(
|
||||
val status: String? = null,
|
||||
val message: String? = null,
|
||||
@JsonProperty("processing_url") val processingUrl: String? = null,
|
||||
val percentage: Int? = null,
|
||||
@JsonProperty("nip94_event") val nip94Event: PartialEvent? = null,
|
||||
)
|
||||
|
||||
class PartialEvent(
|
||||
val tags: Array<Array<String>>? = null,
|
||||
val content: String? = null,
|
||||
)
|
||||
|
||||
private fun parseResults(body: String): Nip96Result {
|
||||
val mapper =
|
||||
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
return mapper.readValue(body, Nip96Result::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.Client
|
||||
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.Relay
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.EOSETime
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.events.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.events.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
// TODO: Migrate this to a property of AccountVi
|
||||
object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
var otherAccounts = listOf<HexKey>()
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val hasLoadedTheBasics = mutableMapOf<User, Boolean>()
|
||||
|
||||
fun createAccountContactListFilter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
|
||||
fun createAccountMetadataFilter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
|
||||
fun createAccountRelayListFilter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
StatusEvent.KIND,
|
||||
AdvertisedRelayListEvent.KIND,
|
||||
ChatMessageRelayListEvent.KIND,
|
||||
SearchRelayListEvent.KIND,
|
||||
FileServersEvent.KIND,
|
||||
PrivateOutboxRelayListEvent.KIND,
|
||||
),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 10,
|
||||
),
|
||||
)
|
||||
|
||||
fun createOtherAccountsBaseFilter(): TypedFilter? {
|
||||
val otherAuthors = otherAccounts.filter { it != account.userProfile().pubkeyHex }
|
||||
if (otherAuthors.isEmpty()) return null
|
||||
return TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
MetadataEvent.KIND,
|
||||
ContactListEvent.KIND,
|
||||
AdvertisedRelayListEvent.KIND,
|
||||
ChatMessageRelayListEvent.KIND,
|
||||
SearchRelayListEvent.KIND,
|
||||
FileServersEvent.KIND,
|
||||
MuteListEvent.KIND,
|
||||
PeopleListEvent.KIND,
|
||||
),
|
||||
authors = otherAuthors,
|
||||
limit = 100,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountSettingsFilter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 100,
|
||||
),
|
||||
)
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(DraftEvent.KIND, ReportEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultNotificationFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
|
||||
fun createAccountLastPostsListFilter(): TypedFilter =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 400,
|
||||
),
|
||||
)
|
||||
|
||||
fun createNotificationFilter(): TypedFilter {
|
||||
var since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultNotificationFollowList.value)
|
||||
?.relayList
|
||||
?.toMutableMap()
|
||||
|
||||
if (since == null) {
|
||||
since =
|
||||
account.connectToRelays.value
|
||||
.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
.toMutableMap()
|
||||
} else {
|
||||
account.connectToRelays.value.forEach {
|
||||
val eose = since.get(it.url)
|
||||
if (eose == null) {
|
||||
since.put(it.url, EOSETime(TimeUtils.oneWeekAgo()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
LnZapPaymentResponseEvent.KIND,
|
||||
ChannelMessageEvent.KIND,
|
||||
BadgeAwardEvent.KIND,
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 4000,
|
||||
since = since,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createNotificationFilter2(): TypedFilter {
|
||||
val since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultNotificationFollowList.value)
|
||||
?.relayList
|
||||
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
GitReplyEvent.KIND,
|
||||
GitIssueEvent.KIND,
|
||||
GitPatchEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
CalendarDateSlotEvent.KIND,
|
||||
CalendarTimeSlotEvent.KIND,
|
||||
CalendarRSVPEvent.KIND,
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 400,
|
||||
since = since,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createGiftWrapsToMeFilter() =
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(GiftWrapEvent.KIND),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get("&&((GIFTWRAPS_EOSE))&&")
|
||||
?.relayList
|
||||
?.mapValues {
|
||||
EOSETime(it.value.time - TimeUtils.twoDays())
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
val accountChannel =
|
||||
requestNewChannel { time, relayUrl ->
|
||||
if (hasLoadedTheBasics[account.userProfile()] != null) {
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.settings.defaultNotificationFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
"&&((GIFTWRAPS_EOSE))&&",
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
} else {
|
||||
hasLoadedTheBasics[account.userProfile()] = true
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
override fun consume(
|
||||
event: Event,
|
||||
relay: Relay,
|
||||
) {
|
||||
if (LocalCache.justVerify(event)) {
|
||||
consumeAlreadyVerified(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeAlreadyVerified(
|
||||
event: Event,
|
||||
relay: Relay,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
when (event) {
|
||||
is PrivateOutboxRelayListEvent -> {
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent == null || event.createdAt > noteEvent.createdAt()) {
|
||||
event.privateTags(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is DraftEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
|
||||
if (!event.isDeleted()) {
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent != null) {
|
||||
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
|
||||
LocalCache.consume(event, relay)
|
||||
}
|
||||
} else {
|
||||
// decrypts
|
||||
event.cachedDraft(account.signer) {}
|
||||
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is GiftWrapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? GiftWrapEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
LocalCache.justConsume(noteEvent, relay)
|
||||
|
||||
noteEvent.innerEventId?.let {
|
||||
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
|
||||
this.consumeAlreadyVerified(it, relay)
|
||||
}
|
||||
} ?: run {
|
||||
event.unwrap(account.signer) {
|
||||
this.consume(it, relay)
|
||||
noteEvent.innerEventId = it.id
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.unwrap(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SealedGossipEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? SealedGossipEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
LocalCache.justConsume(noteEvent, relay)
|
||||
|
||||
noteEvent.innerEventId?.let {
|
||||
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
} ?: run {
|
||||
event.unseal(account.signer) {
|
||||
LocalCache.justConsume(it, relay)
|
||||
noteEvent.innerEventId = it.id
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.unseal(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note?.event == null) {
|
||||
event.zapRequest?.let {
|
||||
if (it.isPrivateZap()) {
|
||||
it.decryptPrivateZap(account.signer) {}
|
||||
}
|
||||
}
|
||||
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun markAsSeenOnRelay(
|
||||
eventId: String,
|
||||
relay: Relay,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
super.markAsSeenOnRelay(eventId, relay)
|
||||
|
||||
val note = LocalCache.getNoteIfExists(eventId) ?: return
|
||||
val noteEvent = note.event ?: return
|
||||
markInnerAsSeenOnRelay(noteEvent, relay)
|
||||
}
|
||||
|
||||
private fun markInnerAsSeenOnRelay(
|
||||
newNoteEvent: EventInterface,
|
||||
relay: Relay,
|
||||
) {
|
||||
markInnerAsSeenOnRelay(newNoteEvent.id(), relay)
|
||||
}
|
||||
|
||||
private fun markInnerAsSeenOnRelay(
|
||||
eventId: HexKey,
|
||||
relay: Relay,
|
||||
) {
|
||||
val note = LocalCache.getNoteIfExists(eventId)
|
||||
|
||||
if (note != null) {
|
||||
note.addRelay(relay)
|
||||
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is GiftWrapEvent) {
|
||||
noteEvent.innerEventId?.let {
|
||||
markInnerAsSeenOnRelay(it, relay)
|
||||
}
|
||||
} else if (noteEvent is SealedGossipEvent) {
|
||||
noteEvent.innerEventId?.let {
|
||||
markInnerAsSeenOnRelay(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() =
|
||||
if (hasLoadedTheBasics[account.userProfile()] != null) {
|
||||
// gets everything about the user logged in
|
||||
accountChannel.typedFilters =
|
||||
listOfNotNull(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createAccountRelayListFilter(),
|
||||
createNotificationFilter(),
|
||||
createNotificationFilter2(),
|
||||
createGiftWrapsToMeFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountSettingsFilter(),
|
||||
createAccountLastPostsListFilter(),
|
||||
createOtherAccountsBaseFilter(),
|
||||
).ifEmpty { null }
|
||||
} else {
|
||||
// just the basics.
|
||||
accountChannel.typedFilters =
|
||||
listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createAccountRelayListFilter(),
|
||||
createAccountSettingsFilter(),
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
override fun auth(
|
||||
relay: Relay,
|
||||
challenge: String,
|
||||
) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
account.createAuthEvent(relay, challenge) {
|
||||
Client.sendIfExists(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun notify(
|
||||
relay: Relay,
|
||||
description: String,
|
||||
) {
|
||||
super.notify(relay, description)
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
|
||||
object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
|
||||
var account: Account? = null
|
||||
var channel: Channel? = null
|
||||
|
||||
fun loadMessagesBetween(
|
||||
account: Account,
|
||||
channel: Channel,
|
||||
) {
|
||||
this.account = account
|
||||
this.channel = channel
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
account = null
|
||||
channel = null
|
||||
}
|
||||
|
||||
fun createMessagesByMeToChannelFilter(): TypedFilter? {
|
||||
val myAccount = account ?: return null
|
||||
|
||||
if (channel is PublicChatChannel) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ChannelMessageEvent.KIND),
|
||||
authors = listOf(myAccount.userProfile().pubkeyHex),
|
||||
limit = 50,
|
||||
),
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
|
||||
authors = listOf(myAccount.userProfile().pubkeyHex),
|
||||
limit = 50,
|
||||
),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun createMessagesToChannelFilter(): TypedFilter? {
|
||||
if (channel is PublicChatChannel) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ChannelMessageEvent.KIND),
|
||||
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
|
||||
limit = 200,
|
||||
),
|
||||
)
|
||||
} else if (channel is LiveActivitiesChannel) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
|
||||
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
|
||||
limit = 200,
|
||||
),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val messagesChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
messagesChannel.typedFilters =
|
||||
listOfNotNull(
|
||||
createMessagesToChannelFilter(),
|
||||
createMessagesByMeToChannelFilter(),
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
|
||||
object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
private var withRoom: ChatroomKey? = null
|
||||
|
||||
private val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun loadMessagesBetween(
|
||||
accountIn: Account,
|
||||
withRoom: ChatroomKey,
|
||||
) {
|
||||
this.account = accountIn
|
||||
this.withRoom = withRoom
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createMessagesToMeFilter(): TypedFilter? {
|
||||
val myPeer = withRoom
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(PrivateDmEvent.KIND),
|
||||
authors = myPeer.users.toList(),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(withRoom.hashCode().toString())
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun createMessagesFromMeFilter(): TypedFilter? {
|
||||
val myPeer = withRoom
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(PrivateDmEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
tags = mapOf("p" to myPeer.users.map { it }),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(withRoom.hashCode().toString())
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun clearEOSEs(account: Account) {
|
||||
latestEOSEs.removeDataFor(account.userProfile())
|
||||
}
|
||||
|
||||
val inandoutChannel =
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
inandoutChannel.typedFilters =
|
||||
listOfNotNull(
|
||||
createMessagesToMeFilter(),
|
||||
createMessagesFromMeFilter(),
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
|
||||
object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val chatRoomList = "ChatroomList"
|
||||
|
||||
fun createMessagesToMeFilter() =
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(PrivateDmEvent.KIND),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(chatRoomList)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
|
||||
fun createMessagesFromMeFilter() =
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(PrivateDmEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(chatRoomList)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
|
||||
fun createChannelsCreatedbyMeFilter() =
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(chatRoomList)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter(): TypedFilter? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
if (followingEvents.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
// Metadata comes from any relay
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ChannelCreateEvent.KIND),
|
||||
ids = followingEvents.toList(),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(chatRoomList)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
if (followingEvents.isEmpty()) return null
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
// Metadata comes from any relay
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.KIND),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter>? {
|
||||
val followingEvents = account.selectedChatsFollowList()
|
||||
|
||||
if (followingEvents.isEmpty()) return null
|
||||
|
||||
return followingEvents.map {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ChannelMessageEvent.KIND),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(chatRoomList)
|
||||
?.relayList,
|
||||
// Remember to consider spam that is being removed from the UI
|
||||
limit = 50,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val chatroomListChannel =
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val list =
|
||||
listOfNotNull(
|
||||
createMessagesToMeFilter(),
|
||||
createMessagesFromMeFilter(),
|
||||
createMyChannelsFilter(),
|
||||
)
|
||||
|
||||
chatroomListChannel.typedFilters =
|
||||
listOfNotNull(
|
||||
list,
|
||||
createLastChannelInfoFilter(),
|
||||
createLastMessageOfEachChannelFilter(),
|
||||
).flatten()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
|
||||
object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed") {
|
||||
private var communityToWatch: AddressableNote? = null
|
||||
|
||||
private fun createLoadCommunityFilter(): TypedFilter? {
|
||||
val myCommunityToWatch = communityToWatch ?: return null
|
||||
|
||||
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
|
||||
|
||||
val authors =
|
||||
community
|
||||
.moderators()
|
||||
.map { it.key }
|
||||
.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex))
|
||||
|
||||
if (authors.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
authors = authors,
|
||||
tags =
|
||||
mapOf(
|
||||
"a" to listOf(myCommunityToWatch.address.toTag()),
|
||||
),
|
||||
kinds = listOf(CommunityPostApprovalEvent.KIND),
|
||||
limit = 500,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val loadCommunityChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadCommunity(note: AddressableNote?) {
|
||||
communityToWatch = note
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val scope = Amethyst.instance.applicationIOScope
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
var job: Job? = null
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.liveDiscoveryFollowLists.collect {
|
||||
if (this@NostrDiscoveryDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
fun createMarketplaceFilter(): List<TypedFilter> {
|
||||
val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null }
|
||||
val hashToLoad =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.hashtags
|
||||
?.toList()
|
||||
?.ifEmpty { null }
|
||||
val geohashToLoad =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.geotags
|
||||
?.toList()
|
||||
?.ifEmpty { null }
|
||||
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(ClassifiedsEvent.KIND),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
hashToLoad?.let {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ClassifiedsEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to
|
||||
it
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
},
|
||||
geohashToLoad?.let {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ClassifiedsEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to
|
||||
it
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createNIP89Filter(kTags: List<String>): List<TypedFilter> =
|
||||
listOfNotNull(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(AppDefinitionEvent.KIND),
|
||||
limit = 300,
|
||||
tags = mapOf("k" to kTags),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun createLiveStreamFilter(): List<TypedFilter> {
|
||||
val follows =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.users
|
||||
?.toList()
|
||||
?.ifEmpty { null }
|
||||
|
||||
val followsRelays = account.liveDiscoveryListAuthorsPerRelay.value
|
||||
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
authors = followsRelays,
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
follows?.let {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags = mapOf("p" to it),
|
||||
kinds = listOf(LiveActivitiesEvent.KIND),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatFilter(): List<TypedFilter> {
|
||||
val follows = account.liveDiscoveryListAuthorsPerRelay.value?.ifEmpty { null }
|
||||
val followChats = account.selectedChatsFollowList().toList()
|
||||
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(ChannelMessageEvent.KIND),
|
||||
limit = 500,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
if (followChats.isNotEmpty()) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
ids = followChats,
|
||||
kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesFilter(): TypedFilter {
|
||||
val follows = account.liveDiscoveryListAuthorsPerRelay.value
|
||||
|
||||
return TypedFilter(
|
||||
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamTagsFilter(): TypedFilter? {
|
||||
val hashToLoad =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.hashtags
|
||||
?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.geotags
|
||||
?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatsTagsFilter(): TypedFilter? {
|
||||
val hashToLoad =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.hashtags
|
||||
?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatsGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.geotags
|
||||
?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesTagsFilter(): TypedFilter? {
|
||||
val hashToLoad =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.hashtags
|
||||
?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad =
|
||||
account.liveDiscoveryFollowLists.value
|
||||
?.geotags
|
||||
?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 300,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val discoveryFeedChannel =
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.settings.defaultDiscoveryFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters =
|
||||
createLiveStreamFilter()
|
||||
.plus(createNIP89Filter(listOf("5300")))
|
||||
.plus(createPublicChatFilter())
|
||||
.plus(createMarketplaceFilter())
|
||||
.plus(
|
||||
listOfNotNull(
|
||||
createLiveStreamTagsFilter(),
|
||||
createLiveStreamGeohashesFilter(),
|
||||
createCommunitiesFilter(),
|
||||
createCommunitiesTagsFilter(),
|
||||
createCommunitiesGeohashesFilter(),
|
||||
createPublicChatsTagsFilter(),
|
||||
createPublicChatsGeohashesFilter(),
|
||||
),
|
||||
).toList()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") {
|
||||
private var geohashToWatch: String? = null
|
||||
|
||||
fun createLoadHashtagFilter(): TypedFilter? {
|
||||
val hashToLoad = geohashToWatch ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to
|
||||
listOf(
|
||||
hashToLoad,
|
||||
),
|
||||
),
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
ChannelMessageEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
limit = 200,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val loadGeohashChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadHashtag(tag: String?) {
|
||||
geohashToWatch = tag
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") {
|
||||
private var hashtagToWatch: String? = null
|
||||
|
||||
fun createLoadHashtagFilter(): TypedFilter? {
|
||||
val hashToLoad = hashtagToWatch ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to
|
||||
listOf(
|
||||
hashToLoad,
|
||||
hashToLoad.lowercase(),
|
||||
hashToLoad.uppercase(),
|
||||
hashToLoad.capitalize(),
|
||||
),
|
||||
),
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
ChannelMessageEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
limit = 200,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val loadHashtagChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadHashtag(tag: String?) {
|
||||
hashtagToWatch = tag
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val scope = Amethyst.instance.applicationIOScope
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
var job: Job? = null
|
||||
var job2: Job? = null
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.liveHomeFollowLists.collect {
|
||||
if (this@NostrHomeDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
job2?.cancel()
|
||||
job2 =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.liveHomeListAuthorsPerRelay.collect {
|
||||
if (this@NostrHomeDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
job?.cancel()
|
||||
job2?.cancel()
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows =
|
||||
account.liveHomeListAuthorsPerRelay.value
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
authors = follows,
|
||||
limit = 400,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowMetadataAndReleaseFilter(): TypedFilter? {
|
||||
val follows = account.liveHomeListAuthorsPerRelay.value
|
||||
|
||||
return if (!follows.isNullOrEmpty()) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
MetadataEvent.KIND,
|
||||
AdvertisedRelayListEvent.KIND,
|
||||
),
|
||||
authors = follows,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowCommunitiesFilter(): TypedFilter? {
|
||||
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null
|
||||
|
||||
if (communitiesToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
CommunityPostApprovalEvent.KIND,
|
||||
),
|
||||
tags =
|
||||
mapOf(
|
||||
"a" to communitiesToLoad.toList(),
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel =
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.settings.defaultHomeFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters =
|
||||
listOfNotNull(
|
||||
createFollowAccountsFilter(),
|
||||
createFollowMetadataAndReleaseFilter(),
|
||||
createFollowCommunitiesFilter(),
|
||||
createFollowTagsFilter(),
|
||||
createFollowGeohashesFilter(),
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
|
||||
class NostrLnZapPaymentResponseDataSource(
|
||||
private val fromServiceHex: String,
|
||||
private val toUserHex: String,
|
||||
private val replyingToHex: String,
|
||||
private val authSigner: NostrSigner,
|
||||
) : AmethystNostrDataSource("LnZapPaymentResponseFeed") {
|
||||
val feedTypes = setOf(FeedType.WALLET_CONNECT)
|
||||
|
||||
private fun createWalletConnectServiceWatcher(): TypedFilter {
|
||||
// downloads all the reactions to a given event.
|
||||
return TypedFilter(
|
||||
types = feedTypes,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(LnZapPaymentResponseEvent.KIND),
|
||||
authors = listOf(fromServiceHex),
|
||||
tags =
|
||||
mapOf(
|
||||
"e" to listOf(replyingToHex),
|
||||
"p" to listOf(toUserHex),
|
||||
),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val channel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val wc = createWalletConnectServiceWatcher()
|
||||
|
||||
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexValidator
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.NNSEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFeed") {
|
||||
private var searchString: String? = null
|
||||
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
val mySearchString = searchString
|
||||
if (mySearchString.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val hexToWatch =
|
||||
try {
|
||||
val isAStraightHex =
|
||||
if (HexValidator.isHex(mySearchString)) {
|
||||
Hex.decode(mySearchString).toHexKey()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
when (val parsed = Nip19Bech32.uriToRoute(mySearchString)?.entity) {
|
||||
is Nip19Bech32.NSec -> KeyPair(privKey = parsed.hex.bechToBytes()).pubKey.toHexKey()
|
||||
is Nip19Bech32.NPub -> parsed.hex
|
||||
is Nip19Bech32.NProfile -> parsed.hex
|
||||
is Nip19Bech32.Note -> parsed.hex
|
||||
is Nip19Bech32.NEvent -> parsed.hex
|
||||
is Nip19Bech32.NEmbed -> parsed.event.id
|
||||
is Nip19Bech32.NRelay -> null
|
||||
is Nip19Bech32.NAddress -> parsed.atag
|
||||
else -> isAStraightHex
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
val directReferenceFilters =
|
||||
hexToWatch?.let {
|
||||
if (it.contains(":")) {
|
||||
// naddr
|
||||
listOfNotNull(
|
||||
ATag.parse(it, null)?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(MetadataEvent.KIND, aTag.kind),
|
||||
authors = listOfNotNull(aTag.pubKeyHex),
|
||||
// just to be sure
|
||||
limit = 5,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// event ids
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
ids = listOfNotNull(hexToWatch),
|
||||
),
|
||||
),
|
||||
// authors
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = listOfNotNull(hexToWatch),
|
||||
// just to be sure
|
||||
limit = 5,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return directReferenceFilters +
|
||||
listOfNotNull(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
search = mySearchString,
|
||||
limit = 1000,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
BadgeDefinitionEvent.KIND,
|
||||
PeopleListEvent.KIND,
|
||||
BookmarkListEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
ChannelCreateEvent.KIND,
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
ChannelMetadataEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
CommunityDefinitionEvent.KIND,
|
||||
EmojiPackEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
NNSEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val searchChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
searchChannel.typedFilters = createAnythingWithIDFilter()
|
||||
}
|
||||
|
||||
fun search(searchString: String) {
|
||||
if (this.searchString != searchString) {
|
||||
println("DataSource: ${this.javaClass.simpleName} Search for $searchString")
|
||||
this.searchString = searchString
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
if (searchString != null) {
|
||||
println("DataSource: ${this.javaClass.simpleName} Clear")
|
||||
searchString = null
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
|
||||
object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<Channel>()
|
||||
|
||||
private fun createMetadataChangeFilter(): TypedFilter? {
|
||||
val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex }
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.KIND),
|
||||
tags = mapOf("e" to reactionsToWatch),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val directEventsToLoad =
|
||||
channelsToWatch.filter { it.notes.isEmpty() && it is PublicChatChannel }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ChannelCreateEvent.KIND),
|
||||
ids = interestedEvents.toList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad =
|
||||
channelsToWatch.filterIsInstance<LiveActivitiesChannel>().filter { it.info == null }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return directEventsToLoad.map {
|
||||
it.address().let { aTag ->
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createMetadataChangeFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
|
||||
|
||||
singleChannelChannel.typedFilters =
|
||||
((listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: Channel) {
|
||||
if (eventId !in channelsToWatch) {
|
||||
channelsToWatch = channelsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(eventId: Channel) {
|
||||
if (eventId in channelsToWatch) {
|
||||
channelsToWatch = channelsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.EOSETime
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
|
||||
object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
|
||||
private var nextEventsToWatch = setOf<Note>()
|
||||
private var nextAddressesToWatch = setOf<Note>()
|
||||
|
||||
private var eventsToWatchInProd = setOf<Note>()
|
||||
private var addressesToWatchInProd = setOf<Note>()
|
||||
|
||||
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
|
||||
val myAddressesToWatch =
|
||||
(
|
||||
eventsToWatchInProd.filter { it.address() != null } +
|
||||
addressesToWatchInProd.filter { it.address() != null }
|
||||
).toSet()
|
||||
|
||||
if (myAddressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return groupByEOSEPresence(myAddressesToWatch)
|
||||
.map {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
CommunityPostApprovalEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
),
|
||||
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 1000,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
DeletionEvent.KIND,
|
||||
),
|
||||
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 10,
|
||||
),
|
||||
),
|
||||
)
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
private fun createAddressFilter(): List<TypedFilter>? {
|
||||
val myAddressesToWatch = addressesToWatchInProd.filter { it.event == null }
|
||||
|
||||
if (myAddressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return myAddressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
authors = listOf(aTag.pubKeyHex),
|
||||
limit = 5,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex),
|
||||
limit = 5,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
|
||||
if (eventsToWatchInProd.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return groupByEOSEPresence(eventsToWatchInProd)
|
||||
.map {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
ReportEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
OtsEvent.KIND,
|
||||
TextNoteModificationEvent.KIND,
|
||||
GitReplyEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 10000,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
DeletionEvent.KIND,
|
||||
NIP90ContentDiscoveryResponseEvent.KIND,
|
||||
NIP90StatusEvent.KIND,
|
||||
TorrentCommentEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
limit = 100,
|
||||
),
|
||||
),
|
||||
)
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
private fun createQuotesFilter(): List<TypedFilter>? {
|
||||
if (eventsToWatchInProd.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return groupByEOSEPresence(eventsToWatchInProd)
|
||||
.map {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(TextNoteEvent.KIND),
|
||||
tags = mapOf("q" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
limit = 1000,
|
||||
),
|
||||
),
|
||||
)
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = eventsToWatchInProd.filter { it.event == null }
|
||||
|
||||
val threadingEventsToLoad =
|
||||
eventsToWatchInProd
|
||||
.mapNotNull { it.replyTo }
|
||||
.flatten()
|
||||
.filter { it !is AddressableNote && it.event == null }
|
||||
|
||||
val interestedEvents = (directEventsToLoad + threadingEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
ids = interestedEvents.toList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val singleEventChannel =
|
||||
requestNewChannel { time, relayUrl ->
|
||||
// Ignores EOSE if it is in the middle of a filter change.
|
||||
if (changingFilters.get()) return@requestNewChannel
|
||||
|
||||
checkNotInMainThread()
|
||||
|
||||
eventsToWatchInProd.forEach {
|
||||
val eose = it.lastReactionsDownloadTime[relayUrl]
|
||||
if (eose == null) {
|
||||
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
|
||||
addressesToWatchInProd.forEach {
|
||||
val eose = it.lastReactionsDownloadTime[relayUrl]
|
||||
if (eose == null) {
|
||||
it.lastReactionsDownloadTime += Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
addressesToWatchInProd = nextAddressesToWatch
|
||||
eventsToWatchInProd = nextEventsToWatch
|
||||
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val addresses = createAddressFilter()
|
||||
val addressReactions = createReactionsToWatchInAddressFilter()
|
||||
val quotes = createQuotesFilter()
|
||||
|
||||
singleEventChannel.typedFilters =
|
||||
listOfNotNull(missing, addresses, reactions, addressReactions, quotes).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: Note) {
|
||||
if (!nextEventsToWatch.contains(eventId)) {
|
||||
nextEventsToWatch = nextEventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(eventId: Note) {
|
||||
if (nextEventsToWatch.contains(eventId)) {
|
||||
nextEventsToWatch = nextEventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun addAddress(addressableNote: Note) {
|
||||
if (!nextAddressesToWatch.contains(addressableNote)) {
|
||||
nextAddressesToWatch = nextAddressesToWatch.plus(addressableNote)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAddress(addressableNote: Note) {
|
||||
if (nextAddressesToWatch.contains(addressableNote)) {
|
||||
nextAddressesToWatch = nextAddressesToWatch.minus(addressableNote)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> =
|
||||
notes
|
||||
.groupBy {
|
||||
it.lastReactionsDownloadTime.keys
|
||||
.sorted()
|
||||
.joinToString(",")
|
||||
}.values
|
||||
.map {
|
||||
it.sortedBy { it.idHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
|
||||
}
|
||||
|
||||
fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> =
|
||||
users
|
||||
.groupBy {
|
||||
it.latestEOSEs.keys
|
||||
.sorted()
|
||||
.joinToString(",")
|
||||
}.values
|
||||
.map {
|
||||
it.sortedBy { it.pubkeyHex } // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
|
||||
}
|
||||
|
||||
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
|
||||
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
|
||||
|
||||
notes.forEach { note ->
|
||||
note.lastReactionsDownloadTime.forEach {
|
||||
val minEose = minLatestEOSEs[it.key]
|
||||
if (minEose == null) {
|
||||
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
|
||||
} else if (it.value.time < minEose.time) {
|
||||
minEose.time = it.value.time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minLatestEOSEs
|
||||
}
|
||||
|
||||
fun findMinimumEOSEsForUsers(users: List<User>): Map<String, EOSETime> {
|
||||
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
|
||||
|
||||
users.forEach {
|
||||
it.latestEOSEs.forEach {
|
||||
val minEose = minLatestEOSEs[it.key]
|
||||
if (minEose == null) {
|
||||
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
|
||||
} else if (it.value.time < minEose.time) {
|
||||
minEose.time = it.value.time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minLatestEOSEs
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.EOSETime
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
|
||||
object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
|
||||
private var usersToWatch = setOf<User>()
|
||||
|
||||
fun createUserMetadataFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
val firstTimers = usersToWatch.filter { it.latestMetadata == null }.map { it.pubkeyHex }
|
||||
|
||||
if (firstTimers.isEmpty()) return null
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND),
|
||||
authors = firstTimers,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserMetadataStatusReportFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
val secondTimers = usersToWatch.filter { it.latestMetadata != null }
|
||||
|
||||
if (secondTimers.isEmpty()) return null
|
||||
|
||||
return groupByEOSEPresence(secondTimers)
|
||||
.map { group ->
|
||||
val groupIds = group.map { it.pubkeyHex }
|
||||
val minEOSEs = findMinimumEOSEsForUsers(group)
|
||||
|
||||
if (groupIds.isNotEmpty()) {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND),
|
||||
authors = groupIds,
|
||||
since = minEOSEs,
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ReportEvent.KIND),
|
||||
tags = mapOf("p" to groupIds),
|
||||
since = minEOSEs,
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
val userChannel =
|
||||
requestNewChannel { time, relayUrl ->
|
||||
checkNotInMainThread()
|
||||
|
||||
usersToWatch.forEach {
|
||||
val eose = it.latestEOSEs[relayUrl]
|
||||
if (eose == null) {
|
||||
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
checkNotInMainThread()
|
||||
|
||||
userChannel.typedFilters =
|
||||
listOfNotNull(
|
||||
createUserMetadataFilter(),
|
||||
createUserMetadataStatusReportFilter(),
|
||||
).flatten()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(user: User) {
|
||||
if (!usersToWatch.contains(user)) {
|
||||
usersToWatch = usersToWatch.plus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(user: User) {
|
||||
if (usersToWatch.contains(user)) {
|
||||
usersToWatch = usersToWatch.minus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
|
||||
object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
|
||||
private var eventToWatch: String? = null
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val threadToLoad = eventToWatch ?: return null
|
||||
|
||||
val eventsToLoad =
|
||||
ThreadAssembler()
|
||||
.findThreadFor(threadToLoad)
|
||||
.filter { it.event == null }
|
||||
.map { it.idHex }
|
||||
.toSet()
|
||||
.ifEmpty { null }
|
||||
?: return null
|
||||
|
||||
if (eventsToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
ids = eventsToLoad.toList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val loadEventsChannel =
|
||||
requestNewChannel { _, _ ->
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadEventsChannel.typedFilters =
|
||||
listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String?) {
|
||||
if (eventToWatch != noteId) {
|
||||
eventToWatch = noteId
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
}
|
||||
|
||||
fun createUserInfoFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserPostsFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TextNoteEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserPostsFilter2() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TorrentEvent.KIND,
|
||||
TorrentCommentEvent.KIND,
|
||||
),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 20,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserReceivedZapsFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(LnZapEvent.KIND),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 200,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowersFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createAcceptedAwardsFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.KIND),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createBookmarksFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 100,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createProfileGalleryFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(ProfileGalleryEntryEvent.KIND),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1000,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createReceivedAwardsFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(BadgeAwardEvent.KIND),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 20,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val userInfoChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userInfoChannel.typedFilters =
|
||||
listOfNotNull(
|
||||
createUserInfoFilter(),
|
||||
createUserPostsFilter(),
|
||||
createUserPostsFilter2(),
|
||||
createProfileGalleryFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter(),
|
||||
createUserReceivedZapsFilter(),
|
||||
createAcceptedAwardsFilter(),
|
||||
createReceivedAwardsFilter(),
|
||||
createBookmarksFilter(),
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif")
|
||||
val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet()
|
||||
|
||||
object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val scope = Amethyst.instance.applicationIOScope
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
var job: Job? = null
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.liveStoriesFollowLists.collect {
|
||||
if (this@NostrVideoDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
fun createContextualFilter(): TypedFilter {
|
||||
val follows = account.liveStoriesListAuthorsPerRelay.value
|
||||
|
||||
return TypedFilter(
|
||||
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
|
||||
filter =
|
||||
SinceAuthorPerRelayFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
limit = 200,
|
||||
tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad =
|
||||
account.liveStoriesFollowLists.value
|
||||
?.hashtags
|
||||
?.toList() ?: return null
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"t" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad =
|
||||
account.liveStoriesFollowLists.value
|
||||
?.geotags
|
||||
?.toList() ?: return null
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
tags =
|
||||
mapOf(
|
||||
"g" to
|
||||
hashToLoad
|
||||
.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) }
|
||||
.flatten(),
|
||||
"m" to SUPPORTED_VIDEO_FEED_MIME_TYPES,
|
||||
),
|
||||
limit = 100,
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel =
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.settings.defaultStoriesFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters =
|
||||
listOfNotNull(
|
||||
createContextualFilter(),
|
||||
createFollowTagsFilter(),
|
||||
createFollowGeohashesFilter(),
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import okhttp3.EventListener
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Immutable data class OnlineCheckResult(
|
||||
val timeInMs: Long,
|
||||
val online: Boolean,
|
||||
)
|
||||
|
||||
object OnlineChecker {
|
||||
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
|
||||
val fiveMinutes = 1000 * 60 * 5
|
||||
|
||||
fun isOnlineCached(url: String?): Boolean {
|
||||
if (url.isNullOrBlank()) return false
|
||||
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
|
||||
return checkOnlineCache.get(url).online
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isOnline(
|
||||
url: String?,
|
||||
forceProxy: Boolean,
|
||||
): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (url.isNullOrBlank()) return false
|
||||
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
|
||||
return checkOnlineCache.get(url).online
|
||||
}
|
||||
|
||||
return try {
|
||||
val result =
|
||||
if (url.startsWith("wss")) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url.replace("wss+livekit://", "wss://"))
|
||||
.header("Upgrade", "websocket")
|
||||
.header("Connection", "Upgrade")
|
||||
.header("Sec-WebSocket-Key", CryptoUtils.random(16).toByteString().base64())
|
||||
.header("Sec-WebSocket-Version", "13")
|
||||
.header("Sec-WebSocket-Extensions", "permessage-deflate")
|
||||
.build()
|
||||
|
||||
val client =
|
||||
HttpClientManager
|
||||
.getHttpClient(forceProxy)
|
||||
.newBuilder()
|
||||
.eventListener(EventListener.NONE)
|
||||
.protocols(listOf(Protocol.HTTP_1_1))
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
}
|
||||
} else {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
}
|
||||
}
|
||||
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
|
||||
Log.e("LiveActivities", "Failed to check streaming url $url", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
|
||||
object PackageUtils {
|
||||
private fun isPackageInstalled(
|
||||
context: Context,
|
||||
target: String,
|
||||
): Boolean =
|
||||
context.packageManager.getInstalledApplications(0).find { info ->
|
||||
info.packageName == target
|
||||
} != null
|
||||
|
||||
fun isOrbotInstalled(context: Context): Boolean = isPackageInstalled(context, "org.torproject.android")
|
||||
|
||||
fun isExternalSignerInstalled(context: Context): Boolean {
|
||||
val intent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = Uri.parse("nostrsigner:")
|
||||
}
|
||||
val infos = context.packageManager.queryIntentActivities(intent, 0)
|
||||
return infos.size > 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.round
|
||||
|
||||
class ZapPaymentHandler(
|
||||
val account: Account,
|
||||
) {
|
||||
@Immutable
|
||||
data class Payable(
|
||||
val info: ZapSplitSetup,
|
||||
val user: User?,
|
||||
val amountMilliSats: Long,
|
||||
val invoice: String,
|
||||
)
|
||||
|
||||
suspend fun zap(
|
||||
note: Note,
|
||||
amountMilliSats: Long,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
context: Context,
|
||||
showErrorIfNoLnAddress: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val noteEvent = note.event
|
||||
val zapSplitSetup = noteEvent?.zapSplitSetup()
|
||||
|
||||
val zapsToSend =
|
||||
if (!zapSplitSetup.isNullOrEmpty()) {
|
||||
zapSplitSetup
|
||||
} else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) {
|
||||
noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) }
|
||||
} else if (noteEvent is AppDefinitionEvent) {
|
||||
val appLud16 = noteEvent.appMetaData()?.lnAddress()
|
||||
if (appLud16 != null) {
|
||||
listOf(ZapSplitSetup(appLud16, null, weight = 1.0, true))
|
||||
} else {
|
||||
val lud16 = note.author?.info?.lnAddress()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
if (showErrorIfNoLnAddress) {
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
|
||||
}
|
||||
} else {
|
||||
val lud16 = note.author?.info?.lnAddress()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
if (showErrorIfNoLnAddress) {
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
|
||||
}
|
||||
|
||||
onProgress(0.02f)
|
||||
signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs ->
|
||||
if (splitZapRequestPairs.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
return@signAllZapRequests
|
||||
} else {
|
||||
onProgress(0.05f)
|
||||
}
|
||||
|
||||
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, forceProxy, onError, onProgress = {
|
||||
onProgress(it * 0.7f + 0.05f) // keeps within range.
|
||||
}, context) {
|
||||
if (it.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
return@assembleAllInvoices
|
||||
} else {
|
||||
onProgress(0.75f)
|
||||
}
|
||||
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
payViaNWC(it.values.map { it.invoice }, note, onError, onProgress = {
|
||||
onProgress(it * 0.25f + 0.75f) // keeps within range.
|
||||
}, context) {
|
||||
// onProgress(1f)
|
||||
}
|
||||
} else {
|
||||
onPayViaIntent(
|
||||
it
|
||||
.map {
|
||||
Payable(
|
||||
info = it.key.first,
|
||||
user = it.key.second.user,
|
||||
amountMilliSats = it.value.zapValue,
|
||||
invoice = it.value.invoice,
|
||||
)
|
||||
}.toImmutableList(),
|
||||
)
|
||||
|
||||
onProgress(0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateZapValue(
|
||||
amountMilliSats: Long,
|
||||
weight: Double,
|
||||
totalWeight: Double,
|
||||
): Long {
|
||||
val shareValue = amountMilliSats * (weight / totalWeight)
|
||||
val roundedZapValue = round(shareValue / 1000f).toLong() * 1000
|
||||
return roundedZapValue
|
||||
}
|
||||
|
||||
class SignAllZapRequestsReturn(
|
||||
val zapRequestJson: String?,
|
||||
val user: User? = null,
|
||||
)
|
||||
|
||||
suspend fun signAllZapRequests(
|
||||
note: Note,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapsToSend: List<ZapSplitSetup>,
|
||||
onAllDone: suspend (MutableMap<ZapSplitSetup, SignAllZapRequestsReturn>) -> Unit,
|
||||
) {
|
||||
val authorRelayList =
|
||||
note.author
|
||||
?.pubkeyHex
|
||||
?.let {
|
||||
(
|
||||
LocalCache
|
||||
.getAddressableNoteIfExists(
|
||||
AdvertisedRelayListEvent.createAddressTag(it),
|
||||
)?.event as? AdvertisedRelayListEvent?
|
||||
)?.readRelays()
|
||||
}?.toSet()
|
||||
|
||||
collectSuccessfulSigningOperations<ZapSplitSetup, SignAllZapRequestsReturn>(
|
||||
operationsInput = zapsToSend,
|
||||
runRequestFor = { next: ZapSplitSetup, onReady ->
|
||||
if (next.isLnAddress) {
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson ->
|
||||
if (zapRequestJson != null) {
|
||||
onReady(SignAllZapRequestsReturn(zapRequestJson))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val user = LocalCache.getUserIfExists(next.lnAddressOrPubKeyHex)
|
||||
val userRelayList =
|
||||
(
|
||||
(
|
||||
LocalCache
|
||||
.getAddressableNoteIfExists(
|
||||
AdvertisedRelayListEvent.createAddressTag(next.lnAddressOrPubKeyHex),
|
||||
)?.event as? AdvertisedRelayListEvent?
|
||||
)?.readRelays()?.toSet() ?: emptySet()
|
||||
) + (authorRelayList ?: emptySet())
|
||||
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson ->
|
||||
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
|
||||
}
|
||||
}
|
||||
},
|
||||
onReady = onAllDone,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun assembleAllInvoices(
|
||||
invoices: List<Pair<ZapSplitSetup, SignAllZapRequestsReturn>>,
|
||||
totalAmountMilliSats: Long,
|
||||
message: String,
|
||||
showErrorIfNoLnAddress: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onAllDone: suspend (MutableMap<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>) -> Unit,
|
||||
) {
|
||||
var progressAllPayments = 0.00f
|
||||
val totalWeight = invoices.sumOf { it.first.weight }
|
||||
|
||||
collectSuccessfulSigningOperations<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>(
|
||||
operationsInput = invoices,
|
||||
runRequestFor = { splitZapRequestPair: Pair<ZapSplitSetup, SignAllZapRequestsReturn>, onReady ->
|
||||
assembleInvoice(
|
||||
splitSetup = splitZapRequestPair.first,
|
||||
nostrZapRequest = splitZapRequestPair.second.zapRequestJson,
|
||||
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight),
|
||||
message = message,
|
||||
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
|
||||
forceProxy = forceProxy,
|
||||
onError = onError,
|
||||
onProgressStep = { percentStepForThisPayment ->
|
||||
progressAllPayments += percentStepForThisPayment / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
},
|
||||
context = context,
|
||||
onReady = onReady,
|
||||
)
|
||||
},
|
||||
onReady = onAllDone,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun payViaNWC(
|
||||
invoices: List<String>,
|
||||
note: Note,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onAllDone: suspend (MutableMap<String, Boolean>) -> Unit,
|
||||
) {
|
||||
var progressAllPayments = 0.00f
|
||||
|
||||
collectSuccessfulSigningOperations<String, Boolean>(
|
||||
operationsInput = invoices,
|
||||
runRequestFor = { invoice: String, onReady ->
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = invoice,
|
||||
zappedNote = note,
|
||||
onSent = {
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
onReady(true)
|
||||
},
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.wallet_connect_pay_invoice_error_error,
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString() ?: "Error parsing error message",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onReady = onAllDone,
|
||||
)
|
||||
}
|
||||
|
||||
class AssembleInvoiceReturn(
|
||||
val zapValue: Long,
|
||||
val invoice: String,
|
||||
)
|
||||
|
||||
private fun assembleInvoice(
|
||||
splitSetup: ZapSplitSetup,
|
||||
nostrZapRequest: String?,
|
||||
zapValue: Long,
|
||||
message: String,
|
||||
showErrorIfNoLnAddress: Boolean = true,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgressStep: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onReady: (AssembleInvoiceReturn) -> Unit,
|
||||
) {
|
||||
var progressThisPayment = 0.00f
|
||||
|
||||
var user: User? = null
|
||||
val lud16 =
|
||||
if (splitSetup.isLnAddress) {
|
||||
splitSetup.lnAddressOrPubKeyHex
|
||||
} else {
|
||||
user = LocalCache.getUserIfExists(splitSetup.lnAddressOrPubKeyHex)
|
||||
user?.info?.lnAddress()
|
||||
}
|
||||
|
||||
if (lud16 != null) {
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = zapValue,
|
||||
message = message,
|
||||
nostrRequest = nostrZapRequest,
|
||||
forceProxy = forceProxy,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
val step = it - progressThisPayment
|
||||
progressThisPayment = it
|
||||
onProgressStep(step)
|
||||
},
|
||||
context = context,
|
||||
onSuccess = {
|
||||
onProgressStep(1 - progressThisPayment)
|
||||
onReady(AssembleInvoiceReturn(zapValue, it))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
if (showErrorIfNoLnAddress) {
|
||||
onError(
|
||||
stringRes(
|
||||
context,
|
||||
R.string.missing_lud16,
|
||||
),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
user?.toBestDisplayName() ?: splitSetup.lnAddressOrPubKeyHex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareZapRequestIfNeeded(
|
||||
note: Note,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
overrideUser: User? = null,
|
||||
additionalRelays: Set<String>? = null,
|
||||
onReady: (String?) -> Unit,
|
||||
) {
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP) {
|
||||
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser, additionalRelays) { zapRequest ->
|
||||
println("Zap Request " + zapRequest.toJson())
|
||||
onReady(zapRequest.toJson())
|
||||
}
|
||||
} else {
|
||||
onReady(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.lnurl
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.text.NumberFormat
|
||||
|
||||
@Stable
|
||||
data class InvoiceAmount(
|
||||
val invoice: String,
|
||||
val amount: String?,
|
||||
)
|
||||
|
||||
object CachedLnInvoiceParser {
|
||||
val lnInvoicesCache = LruCache<String, InvoiceAmount>(20)
|
||||
|
||||
fun cached(lnurl: String): InvoiceAmount? = lnInvoicesCache[lnurl]
|
||||
|
||||
fun parse(lnbcWord: String): InvoiceAmount? {
|
||||
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
|
||||
if (myInvoice != null) {
|
||||
val myInvoiceAmount =
|
||||
try {
|
||||
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount)
|
||||
|
||||
lnInvoicesCache.put(lnbcWord, lnInvoice)
|
||||
|
||||
return lnInvoice
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.lnurl
|
||||
|
||||
import android.content.Context
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
import okhttp3.Request
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.net.URLEncoder
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class LightningAddressResolver {
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
|
||||
}
|
||||
|
||||
if (lnaddress.lowercase().startsWith("lnurl")) {
|
||||
return Lud06().toLnUrlp(lnaddress)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fetchLightningAddressJson(
|
||||
lnaddress: String,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(lnaddress)
|
||||
|
||||
if (url == null) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
try {
|
||||
val request: Request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string
|
||||
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
|
||||
url,
|
||||
lnaddress,
|
||||
it.code.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
e.printStackTrace()
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string
|
||||
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
|
||||
url,
|
||||
lnaddress,
|
||||
e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLightningInvoice(
|
||||
lnCallback: String,
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val request: Request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(context, R.string.could_not_fetch_invoice_from, lnCallback),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(
|
||||
lnaddress: String,
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
forceProxy,
|
||||
onSuccess = { lnAddressJson ->
|
||||
onProgress(0.4f)
|
||||
|
||||
val lnurlp =
|
||||
try {
|
||||
mapper.readTree(lnAddressJson)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val callback = lnurlp?.get("callback")?.asText()
|
||||
|
||||
if (callback == null) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
|
||||
|
||||
callback?.let { cb ->
|
||||
fetchLightningInvoice(
|
||||
cb,
|
||||
milliSats,
|
||||
message,
|
||||
if (allowsNostr) nostrRequest else null,
|
||||
forceProxy,
|
||||
onSuccess = {
|
||||
onProgress(0.6f)
|
||||
|
||||
val lnInvoice =
|
||||
try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string
|
||||
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice
|
||||
?.get("pr")
|
||||
?.asText()
|
||||
?.ifBlank { null }
|
||||
?.let { pr ->
|
||||
// Forces LN Invoice amount to be the requested amount.
|
||||
val expectedAmountInSats =
|
||||
BigDecimal(milliSats).divide(BigDecimal(1000), RoundingMode.HALF_UP).toLong()
|
||||
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
|
||||
if (invoiceAmount.toLong() == expectedAmountInSats) {
|
||||
onProgress(0.7f)
|
||||
onSuccess(pr)
|
||||
} else {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
|
||||
invoiceAmount.toLong().toString(),
|
||||
lnaddress,
|
||||
expectedAmountInSats.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
?: lnInvoice
|
||||
?.get("reason")
|
||||
?.asText()
|
||||
?.ifBlank { null }
|
||||
?.let { reason ->
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
|
||||
lnaddress,
|
||||
reason,
|
||||
),
|
||||
)
|
||||
}
|
||||
?: run {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.math.BigDecimal
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class EventNotificationConsumer(
|
||||
private val applicationContext: Context,
|
||||
) {
|
||||
companion object {
|
||||
const val TAG = "EventNotificationConsumer"
|
||||
}
|
||||
|
||||
suspend fun consume(event: GiftWrapEvent) {
|
||||
Log.d(TAG, "New Notification Arrived")
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (!notificationManager().areNotificationsEnabled()) return
|
||||
|
||||
// PushNotification Wraps don't include a receiver.
|
||||
// Test with all logged in accounts
|
||||
var matchAccount = false
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) {
|
||||
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
|
||||
Log.d(TAG, "New Notification Testing if for ${it.npub}")
|
||||
try {
|
||||
consumeIfMatchesAccount(event, acc)
|
||||
matchAccount = true
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun consumeIfMatchesAccount(
|
||||
pushWrappedEvent: GiftWrapEvent,
|
||||
account: AccountSettings,
|
||||
) {
|
||||
// TODO: Modify the external launcher to launch as different users.
|
||||
// Right now it only registers if Amber has already approved this signature
|
||||
val signer = account.createSigner()
|
||||
if (signer is NostrSignerExternal) {
|
||||
signer.launcher.registerLauncher(
|
||||
launcher = { },
|
||||
contentResolver = Amethyst.instance::contentResolverFn,
|
||||
)
|
||||
}
|
||||
|
||||
pushWrappedEvent.unwrapThrowing(signer) { notificationEvent ->
|
||||
val consumed = LocalCache.hasConsumed(notificationEvent)
|
||||
val verified = LocalCache.justVerify(notificationEvent)
|
||||
Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified")
|
||||
if (!consumed && verified) {
|
||||
Log.d(TAG, "New Notification was verified")
|
||||
unwrapAndConsume(notificationEvent, signer) { innerEvent ->
|
||||
Log.d(TAG, "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
Log.d(TAG, "New Nip-04 DM to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
Log.d(TAG, "New Zap to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
Log.d(TAG, "New ChatMessage to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unwrapAndConsume(
|
||||
event: Event,
|
||||
signer: NostrSigner,
|
||||
onReady: (Event) -> Unit,
|
||||
) {
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (LocalCache.hasConsumed(event)) return
|
||||
|
||||
when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
event.unwrap(signer) {
|
||||
unwrapAndConsume(it, signer, onReady)
|
||||
LocalCache.justConsume(event, null)
|
||||
}
|
||||
}
|
||||
is SealedGossipEvent -> {
|
||||
event.unseal(signer) {
|
||||
if (!LocalCache.hasConsumed(it)) {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
onReady(it)
|
||||
}
|
||||
LocalCache.justConsume(event, null)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LocalCache.justConsume(event, null)
|
||||
onReady(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
event: ChatMessageEvent,
|
||||
signer: NostrSigner,
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
if (
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
||||
// old event being re-broadcasted
|
||||
event.pubKey != signer.pubKey
|
||||
) { // from the user
|
||||
Log.d(TAG, "Notifying")
|
||||
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
|
||||
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
val chatRoom = event.chatroomKey(signer.pubKey)
|
||||
|
||||
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
|
||||
|
||||
val isKnownRoom =
|
||||
(
|
||||
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
myUser.hasSentMessagesTo(chatRoom)
|
||||
)
|
||||
|
||||
if (isKnownRoom) {
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri = chatNote.toNEvent()
|
||||
notificationManager()
|
||||
.sendDMNotification(
|
||||
event.id,
|
||||
content,
|
||||
user,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
event: PrivateDmEvent,
|
||||
signer: NostrSigner,
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
|
||||
if (signer.pubKey == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
|
||||
|
||||
val chatRoom = event.chatroomKey(signer.pubKey)
|
||||
|
||||
val isKnownRoom =
|
||||
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
myUser.hasSentMessagesTo(chatRoom)
|
||||
|
||||
if (isKnownRoom) {
|
||||
note.author?.let {
|
||||
decryptContent(note, signer) { content ->
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager()
|
||||
.sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptZapContentAuthor(
|
||||
note: Note,
|
||||
signer: NostrSigner,
|
||||
onReady: (Event) -> Unit,
|
||||
) {
|
||||
val event = note.event
|
||||
if (event is LnZapRequestEvent) {
|
||||
if (event.isPrivateZap()) {
|
||||
event.decryptPrivateZap(signer) { onReady(it) }
|
||||
} else {
|
||||
onReady(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptContent(
|
||||
note: Note,
|
||||
signer: NostrSigner,
|
||||
onReady: (String) -> Unit,
|
||||
) {
|
||||
val event = note.event
|
||||
if (event is PrivateDmEvent) {
|
||||
event.plainContent(signer, onReady)
|
||||
} else if (event is LnZapRequestEvent) {
|
||||
decryptZapContentAuthor(note, signer) { onReady(it.content) }
|
||||
} else if (event is DraftEvent) {
|
||||
event.cachedDraft(signer) {
|
||||
onReady(it.content)
|
||||
}
|
||||
} else {
|
||||
event?.content()?.let { onReady(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
event: LnZapEvent,
|
||||
signer: NostrSigner,
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
Log.d(TAG, "Notify Start ${event.toNostrUri()}")
|
||||
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
|
||||
Log.d(TAG, "Notify Not Notified Yet")
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
|
||||
Log.d(TAG, "Notify Not an old event")
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
val noteZapped =
|
||||
event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
|
||||
Log.d(TAG, "Notify ZapRequest $noteZapRequest zapped $noteZapped")
|
||||
|
||||
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
|
||||
|
||||
Log.d(TAG, "Notify Amount Bigger than 10")
|
||||
|
||||
if (event.isTaggedUser(signer.pubKey)) {
|
||||
val amount = showAmount(event.amount)
|
||||
|
||||
Log.d(TAG, "Notify Amount $amount")
|
||||
|
||||
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
|
||||
decryptZapContentAuthor(noteZapRequest, signer) {
|
||||
Log.d(TAG, "Notify Decrypted if Private Zap ${event.id}")
|
||||
|
||||
val author = LocalCache.getOrCreateUser(it.pubKey)
|
||||
val senderInfo = Pair(author, it.content.ifBlank { null })
|
||||
|
||||
if (noteZapped.event?.content() != null) {
|
||||
decryptContent(noteZapped, signer) {
|
||||
Log.d(TAG, "Notify Decrypted if Private Note")
|
||||
|
||||
val zappedContent = it.split("\n").get(0)
|
||||
|
||||
val user = senderInfo.first.toBestDisplayName()
|
||||
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
|
||||
|
||||
var content =
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.app_notification_zaps_channel_message_from,
|
||||
user,
|
||||
)
|
||||
zappedContent.let {
|
||||
content +=
|
||||
" " +
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.app_notification_zaps_channel_message_for,
|
||||
zappedContent,
|
||||
)
|
||||
}
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
|
||||
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
event.id,
|
||||
content,
|
||||
title,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// doesn't have a base note to refer to.
|
||||
Log.d(TAG, "Notify Zapped note not available")
|
||||
|
||||
val user = senderInfo.first.toBestDisplayName()
|
||||
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
|
||||
|
||||
val content =
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.app_notification_zaps_channel_message_from,
|
||||
user,
|
||||
)
|
||||
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
|
||||
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
event.id,
|
||||
content,
|
||||
title,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notificationManager(): NotificationManager =
|
||||
ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
|
||||
as NotificationManager
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.app.NotificationCompat
|
||||
import coil.ImageLoader
|
||||
import coil.executeBlocking
|
||||
import coil.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
object NotificationUtils {
|
||||
private var dmChannel: NotificationChannel? = null
|
||||
private var zapChannel: NotificationChannel? = null
|
||||
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
||||
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
||||
|
||||
fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
if (dmChannel != null) return dmChannel!!
|
||||
|
||||
dmChannel =
|
||||
NotificationChannel(
|
||||
stringRes(applicationContext, R.string.app_notification_dms_channel_id),
|
||||
stringRes(applicationContext, R.string.app_notification_dms_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description =
|
||||
stringRes(applicationContext, R.string.app_notification_dms_channel_description)
|
||||
}
|
||||
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(dmChannel!!)
|
||||
|
||||
return dmChannel!!
|
||||
}
|
||||
|
||||
fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
|
||||
if (zapChannel != null) return zapChannel!!
|
||||
|
||||
zapChannel =
|
||||
NotificationChannel(
|
||||
stringRes(applicationContext, R.string.app_notification_zaps_channel_id),
|
||||
stringRes(applicationContext, R.string.app_notification_zaps_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description =
|
||||
stringRes(applicationContext, R.string.app_notification_zaps_channel_description)
|
||||
}
|
||||
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(zapChannel!!)
|
||||
|
||||
return zapChannel!!
|
||||
}
|
||||
|
||||
fun NotificationManager.sendZapNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
time: Long,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
val zapChannel = getOrCreateZapChannel(applicationContext)
|
||||
val channelId = stringRes(applicationContext, R.string.app_notification_zaps_channel_id)
|
||||
|
||||
sendNotification(
|
||||
id,
|
||||
messageBody,
|
||||
messageTitle,
|
||||
time,
|
||||
pictureUrl,
|
||||
uri,
|
||||
channelId,
|
||||
ZAP_GROUP_KEY,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendDMNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
time: Long,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
val dmChannel = getOrCreateDMChannel(applicationContext)
|
||||
val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id)
|
||||
|
||||
sendNotification(
|
||||
id,
|
||||
messageBody,
|
||||
messageTitle,
|
||||
time,
|
||||
pictureUrl,
|
||||
uri,
|
||||
channelId,
|
||||
DM_GROUP_KEY,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
time: Long,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
notificationGroupKey: String,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
if (pictureUrl != null) {
|
||||
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
|
||||
|
||||
val imageLoader = ImageLoader(applicationContext)
|
||||
val imageResult = imageLoader.executeBlocking(request)
|
||||
sendNotification(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
time = time,
|
||||
picture = imageResult.drawable as? BitmapDrawable,
|
||||
uri = uri,
|
||||
channelId,
|
||||
notificationGroupKey,
|
||||
applicationContext = applicationContext,
|
||||
)
|
||||
} else {
|
||||
sendNotification(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
time = time,
|
||||
picture = null,
|
||||
uri = uri,
|
||||
channelId,
|
||||
notificationGroupKey,
|
||||
applicationContext = applicationContext,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationManager.sendNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
time: Long,
|
||||
picture: BitmapDrawable?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
notificationGroupKey: String,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
val notId = id.hashCode()
|
||||
|
||||
// dont notify twice
|
||||
val notifications: Array<StatusBarNotification> = getActiveNotifications()
|
||||
for (notification in notifications) {
|
||||
if (notification.id == notId) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val contentIntent =
|
||||
Intent(applicationContext, MainActivity::class.java).apply { data = Uri.parse(uri) }
|
||||
|
||||
val contentPendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
notId,
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
)
|
||||
|
||||
// Build the notification
|
||||
val builderPublic =
|
||||
NotificationCompat
|
||||
.Builder(
|
||||
applicationContext,
|
||||
channelId,
|
||||
).setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
// .setGroup(messageTitle)
|
||||
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
|
||||
// activate this
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(time * 1000)
|
||||
|
||||
// Build the notification
|
||||
val builder =
|
||||
NotificationCompat
|
||||
.Builder(
|
||||
applicationContext,
|
||||
channelId,
|
||||
).setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(messageBody)
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
// .setGroup(messageTitle)
|
||||
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
|
||||
// activate this
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPublicVersion(builderPublic.build())
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(time * 1000)
|
||||
|
||||
notify(notId, builder.build())
|
||||
}
|
||||
|
||||
/** Cancels all notifications. */
|
||||
fun NotificationManager.cancelNotifications() {
|
||||
cancelAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class RegisterAccounts(
|
||||
private val accounts: List<AccountInfo>,
|
||||
) {
|
||||
val tag =
|
||||
if (BuildConfig.FLAVOR == "play") {
|
||||
"RegisterAccounts FirebaseMsgService"
|
||||
} else {
|
||||
"RegisterAccounts UnifiedPushService"
|
||||
}
|
||||
|
||||
private suspend fun signAllAuths(
|
||||
notificationToken: String,
|
||||
remainingTos: List<Pair<AccountSettings, List<String>>>,
|
||||
output: MutableList<RelayAuthEvent>,
|
||||
onReady: (List<RelayAuthEvent>) -> Unit,
|
||||
) {
|
||||
if (remainingTos.isEmpty()) {
|
||||
onReady(output)
|
||||
return
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
val jobs =
|
||||
remainingTos.map { accountRelayPair ->
|
||||
async {
|
||||
val result =
|
||||
withTimeoutOrNull(10000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val signer = accountRelayPair.first.createSigner()
|
||||
// TODO: Modify the external launcher to launch as different users.
|
||||
// Right now it only registers if Amber has already approved this signature
|
||||
if (signer is NostrSignerExternal) {
|
||||
signer.launcher.registerLauncher(
|
||||
launcher = { },
|
||||
contentResolver = Amethyst.instance::contentResolverFn,
|
||||
)
|
||||
}
|
||||
|
||||
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
output.add(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runs in parallel to avoid overcrowding Amber.
|
||||
withTimeoutOrNull(15000) {
|
||||
jobs.joinAll()
|
||||
}
|
||||
}
|
||||
|
||||
onReady(output)
|
||||
}
|
||||
|
||||
// creates proof that it controls all accounts
|
||||
private suspend fun signEventsToProveControlOfAccounts(
|
||||
accounts: List<AccountInfo>,
|
||||
notificationToken: String,
|
||||
onReady: (List<RelayAuthEvent>) -> Unit,
|
||||
) {
|
||||
val readyToSend =
|
||||
accounts
|
||||
.mapNotNull {
|
||||
Log.d(tag, "Register Account ${it.npub}")
|
||||
|
||||
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
|
||||
if (acc != null && acc.isWriteable()) {
|
||||
val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList()
|
||||
|
||||
Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}")
|
||||
|
||||
val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList<String>()
|
||||
|
||||
Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}")
|
||||
|
||||
val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
|
||||
|
||||
Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
|
||||
|
||||
val relays = (nip65Read + nip17Read + readKind3Relays)
|
||||
|
||||
if (relays.isNotEmpty()) {
|
||||
Pair(acc, relays)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val listOfAuthEvents = mutableListOf<RelayAuthEvent>()
|
||||
signAllAuths(
|
||||
notificationToken,
|
||||
readyToSend,
|
||||
listOfAuthEvents,
|
||||
onReady,
|
||||
)
|
||||
}
|
||||
|
||||
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
|
||||
try {
|
||||
val jsonObject =
|
||||
"""{
|
||||
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
|
||||
}
|
||||
"""
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val body = jsonObject.toRequestBody(mediaType)
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url("https://push.amethyst.social/register")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
// Always try via Tor for Amethyst.
|
||||
val client = HttpClientManager.getHttpClient(true)
|
||||
|
||||
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
|
||||
Log.i(tag, "Server registration $isSucess")
|
||||
} catch (e: java.lang.Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(tag, "Unable to register with push server", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun go(notificationToken: String) {
|
||||
if (notificationToken.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken) { postRegistrationEvent(it) }
|
||||
|
||||
PushNotificationUtils.hasInit = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.ots
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.ots.BitcoinExplorer
|
||||
import com.vitorpamplona.quartz.ots.BlockHeader
|
||||
import com.vitorpamplona.quartz.ots.exceptions.UrlException
|
||||
import okhttp3.Request
|
||||
|
||||
class OkHttpBlockstreamExplorer(
|
||||
val forceProxy: (String) -> Boolean,
|
||||
) : BitcoinExplorer {
|
||||
private val cacheHeaders = LruCache<String, BlockHeader>(100)
|
||||
private val cacheHeights = LruCache<Int, String>(100)
|
||||
|
||||
/**
|
||||
* Retrieve the block information from the block hash.
|
||||
*
|
||||
* @param hash Hash of the block.
|
||||
* @return the blockheader of the hash
|
||||
* @throws Exception desc
|
||||
*/
|
||||
override fun block(hash: String): BlockHeader {
|
||||
cacheHeaders.get(hash)?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val usingTor = forceProxy(BLOCKSTREAM_API_URL)
|
||||
val url = "${getAPI(usingTor)}/block/$hash"
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.header("Accept", "application/json")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
val jsonObject = jacksonObjectMapper().readTree(it.body.string())
|
||||
|
||||
val blockHeader = BlockHeader()
|
||||
blockHeader.merkleroot = jsonObject["merkle_root"].asText()
|
||||
blockHeader.setTime(jsonObject["timestamp"].asInt().toString())
|
||||
blockHeader.blockHash = hash
|
||||
Log.d("OkHttpBlockstreamExplorer", "$BLOCKSTREAM_API_URL/block/$hash")
|
||||
|
||||
cacheHeaders.put(hash, blockHeader)
|
||||
|
||||
return blockHeader
|
||||
} else {
|
||||
throw UrlException("Couldn't open $url: " + it.message + " " + it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the block hash from the block height.
|
||||
*
|
||||
* @param height Height of the block.
|
||||
* @return the hash of the block at height height
|
||||
* @throws Exception desc
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
override fun blockHash(height: Int): String {
|
||||
cacheHeights[height]?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val usingTor = forceProxy(BLOCKSTREAM_API_URL)
|
||||
val url = "${getAPI(usingTor)}/block-height/$height"
|
||||
val client = HttpClientManager.getHttpClient(usingTor)
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
val blockHash = it.body.string()
|
||||
|
||||
Log.d("OkHttpBlockstreamExplorer", "$url $blockHash")
|
||||
|
||||
cacheHeights.put(height, blockHash)
|
||||
return blockHash
|
||||
} else {
|
||||
throw UrlException("Couldn't open $url: " + it.message + " " + it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BLOCKSTREAM_API_URL = "https://blockstream.info/api"
|
||||
private const val MEMPOOL_API_URL = "https://mempool.space/api/"
|
||||
|
||||
fun getAPI(usingTor: Boolean) =
|
||||
if (usingTor) {
|
||||
MEMPOOL_API_URL
|
||||
} else {
|
||||
BLOCKSTREAM_API_URL
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.ots
|
||||
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.ots.ICalendar
|
||||
import com.vitorpamplona.quartz.ots.StreamDeserializationContext
|
||||
import com.vitorpamplona.quartz.ots.Timestamp
|
||||
import com.vitorpamplona.quartz.ots.exceptions.CommitmentNotFoundException
|
||||
import com.vitorpamplona.quartz.ots.exceptions.DeserializationException
|
||||
import com.vitorpamplona.quartz.ots.exceptions.ExceededSizeException
|
||||
import com.vitorpamplona.quartz.ots.exceptions.UrlException
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/**
|
||||
* Class representing remote calendar server interface.
|
||||
*/
|
||||
class OkHttpCalendar(
|
||||
val url: String,
|
||||
val forceProxy: Boolean,
|
||||
) : ICalendar {
|
||||
/**
|
||||
* Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest.
|
||||
*
|
||||
* @param digest The digest hash to send.
|
||||
* @return the Timestamp received from the calendar.
|
||||
* @throws ExceededSizeException if response is too big.
|
||||
* @throws UrlException if url is not reachable.
|
||||
* @throws DeserializationException if the data is corrupt
|
||||
*/
|
||||
@Throws(ExceededSizeException::class, UrlException::class, DeserializationException::class)
|
||||
override fun submit(digest: ByteArray): Timestamp {
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient(forceProxy)
|
||||
val url = "$url/digest"
|
||||
|
||||
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
|
||||
val requestBody = digest.toRequestBody(mediaType)
|
||||
|
||||
val request =
|
||||
okhttp3.Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.header("Accept", "application/vnd.opentimestamps.v1")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
val ctx = StreamDeserializationContext(it.body.bytes())
|
||||
return Timestamp.deserialize(ctx, digest)
|
||||
} else {
|
||||
throw UrlException("Failed to open $url")
|
||||
}
|
||||
}
|
||||
} catch (e: ExceededSizeException) {
|
||||
throw e
|
||||
} catch (e: DeserializationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw UrlException(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a timestamp for a given commitment.
|
||||
*
|
||||
* @param commitment The digest hash to send.
|
||||
* @return the Timestamp from the calendar server (with blockchain information if already written).
|
||||
* @throws ExceededSizeException if response is too big.
|
||||
* @throws UrlException if url is not reachable.
|
||||
* @throws CommitmentNotFoundException if commit is not found.
|
||||
* @throws DeserializationException if the data is corrupt
|
||||
*/
|
||||
@Throws(
|
||||
DeserializationException::class,
|
||||
ExceededSizeException::class,
|
||||
CommitmentNotFoundException::class,
|
||||
UrlException::class,
|
||||
)
|
||||
override fun getTimestamp(commitment: ByteArray): Timestamp {
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient(forceProxy)
|
||||
val url = url + "/timestamp/" + Hex.encode(commitment)
|
||||
|
||||
val request =
|
||||
okhttp3.Request
|
||||
.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.header("Accept", "application/vnd.opentimestamps.v1")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
val ctx = StreamDeserializationContext(it.body.bytes())
|
||||
return Timestamp.deserialize(ctx, commitment)
|
||||
} else {
|
||||
throw CommitmentNotFoundException("Calendar response a status code != 200: " + it.code)
|
||||
}
|
||||
}
|
||||
} catch (e: DeserializationException) {
|
||||
throw e
|
||||
} catch (e: ExceededSizeException) {
|
||||
throw e
|
||||
} catch (e: CommitmentNotFoundException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw UrlException(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user