mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
1773 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f2e24bc1f | ||
|
|
cfd6577029 | ||
|
|
976de28ece | ||
|
|
bc9b6dc912 | ||
|
|
c6fd65dbac | ||
|
|
b3f190410f | ||
|
|
6f7c550034 | ||
|
|
fb976d168a | ||
|
|
be4a6d4fc4 | ||
|
|
d90c868f32 | ||
|
|
e4f55c11f1 | ||
|
|
71242e50d2 | ||
|
|
5d0f9ac866 | ||
|
|
ad9596ab92 | ||
|
|
d847c06979 | ||
|
|
0d8b4e8baa | ||
|
|
70d034e50c | ||
|
|
f11172216d | ||
|
|
2ef8be8fdc | ||
|
|
cc850c5b7f | ||
|
|
728ff9a4cd | ||
|
|
6f9fc23a64 | ||
|
|
bcdaf8630c | ||
|
|
2d25bb2592 | ||
|
|
405d1d0519 | ||
|
|
88042f06d4 | ||
|
|
8437a819f8 | ||
|
|
cef07a2460 | ||
|
|
2a2c61973e | ||
|
|
349781d9be | ||
|
|
334aabbf02 | ||
|
|
2933454fb6 | ||
|
|
09054d6ac5 | ||
|
|
4d8be5e272 | ||
|
|
0ad3180fc5 | ||
|
|
c913c0e1ab | ||
|
|
30ff9ab09b | ||
|
|
159f1a121b | ||
|
|
9b5b50f237 | ||
|
|
898e6ac677 | ||
|
|
d6d4bdc5e3 | ||
|
|
c39eca5172 | ||
|
|
3d9efa9938 | ||
|
|
11dabd46ba | ||
|
|
94e708b5fc | ||
|
|
8d81d6d57c | ||
|
|
8c4b2fe62b | ||
|
|
514e90ff17 | ||
|
|
4c14554263 | ||
|
|
22142d17f9 | ||
|
|
e976bf9e2e | ||
|
|
4e43b14b09 | ||
|
|
6f3308e45c | ||
|
|
1dbe552821 | ||
|
|
6436668808 | ||
|
|
346a7ecac2 | ||
|
|
c67d66981c | ||
|
|
6e0e8cf00f | ||
|
|
8525f2b930 | ||
|
|
7874893938 | ||
|
|
51fa28f22a | ||
|
|
ec4f949782 | ||
|
|
4c9f2de6f7 | ||
|
|
e5aeb49a4b | ||
|
|
da84848df7 | ||
|
|
3d488165c3 | ||
|
|
f99543dc3b | ||
|
|
7ea5557828 | ||
|
|
3a93c4b075 | ||
|
|
9ebc4a1e67 | ||
|
|
9202b60dcf | ||
|
|
e31e3829a6 | ||
|
|
f553489ddf | ||
|
|
2b920fc47a | ||
|
|
ac38a73b5e | ||
|
|
4672e6b587 | ||
|
|
bddff36e6b | ||
|
|
03c00b9fba | ||
|
|
b495f3f409 | ||
|
|
cfecfe2130 | ||
|
|
f2bdb24ee5 | ||
|
|
a9c41e1d55 | ||
|
|
306d76f793 | ||
|
|
eeca86e542 | ||
|
|
951610d9ca | ||
|
|
590bd21f62 | ||
|
|
81380a32ae | ||
|
|
943f1256a7 | ||
|
|
7af1be7363 | ||
|
|
a39688dabb | ||
|
|
33ec43a5c3 | ||
|
|
1f5841d090 | ||
|
|
9d086df37a | ||
|
|
ed3390f64a | ||
|
|
e659651dd4 | ||
|
|
0cc06f424e | ||
|
|
11176a4679 | ||
|
|
d964816fd3 | ||
|
|
bbb75afed9 | ||
|
|
2eb3ebd365 | ||
|
|
c75881d928 | ||
|
|
9d8d0e249d | ||
|
|
677bc6c34a | ||
|
|
3eaf3d38a5 | ||
|
|
d4fd8088f1 | ||
|
|
3e7633ea6b | ||
|
|
11ed878111 | ||
|
|
76df43b031 | ||
|
|
41b0abc8e9 | ||
|
|
f70fd9ad2b | ||
|
|
9eba27e9b2 | ||
|
|
37f6a7b133 | ||
|
|
439b392aa0 | ||
|
|
2e1066a609 | ||
|
|
0905b05388 | ||
|
|
b2f7c3296f | ||
|
|
9c30e2d0f6 | ||
|
|
570639c1df | ||
|
|
65ae09e201 | ||
|
|
93b1392080 | ||
|
|
3e93472dce | ||
|
|
26a3dcbd85 | ||
|
|
ffa60c5fd3 | ||
|
|
cd3282b342 | ||
|
|
41539c1b7a | ||
|
|
40c45fb3a1 | ||
|
|
b6b4856fe4 | ||
|
|
adb3a0e0fc | ||
|
|
459f69ff2c | ||
|
|
90cced23d2 | ||
|
|
0dec0164ad | ||
|
|
5af0cc7eac | ||
|
|
ef5508b60b | ||
|
|
fd79da0b09 | ||
|
|
15ff3040b5 | ||
|
|
a86b9ab2e1 | ||
|
|
862aa0cb78 | ||
|
|
cfead47d71 | ||
|
|
32c0efb291 | ||
|
|
b2ad97bb6b | ||
|
|
df6dc17047 | ||
|
|
81308a2368 | ||
|
|
24d2928881 | ||
|
|
2ef634ca01 | ||
|
|
aca62ec81f | ||
|
|
b0a8cae194 | ||
|
|
f2e60986d6 | ||
|
|
b93ad35029 | ||
|
|
d54bc91c20 | ||
|
|
b1ca425c46 | ||
|
|
8fd08aa56d | ||
|
|
6bdf65da56 | ||
|
|
64316f7d73 | ||
|
|
73536dd421 | ||
|
|
817c4c7723 | ||
|
|
98cda3ba71 | ||
|
|
b6e54db81b | ||
|
|
ed624a0a36 | ||
|
|
57c046261e | ||
|
|
6cb686f5cb | ||
|
|
bbac00ccb3 | ||
|
|
0f921989b9 | ||
|
|
a21da546ab | ||
|
|
9bfb170ffa | ||
|
|
ccffeb1e49 | ||
|
|
752f460bc0 | ||
|
|
b247310ba7 | ||
|
|
fc1e3e6b83 | ||
|
|
d431b12f94 | ||
|
|
c5066d89c3 | ||
|
|
26a97aa141 | ||
|
|
c9afec47cd | ||
|
|
bd10f74b2a | ||
|
|
6007e22871 | ||
|
|
4e5d2e7d2a | ||
|
|
b3a61ba288 | ||
|
|
6632d3f515 | ||
|
|
2fc9b294ae | ||
|
|
28dbb3948b | ||
|
|
5bde93d58f | ||
|
|
2c6d18607a | ||
|
|
cbe13f6819 | ||
|
|
dce1484dcc | ||
|
|
aaed4bf697 | ||
|
|
289c882d59 | ||
|
|
29f0b78fdd | ||
|
|
19ff300f65 | ||
|
|
fdb5b58513 | ||
|
|
864581ecaa | ||
|
|
620f8dbf10 | ||
|
|
f15b7620df | ||
|
|
3a195c4648 | ||
|
|
460ccc2d02 | ||
|
|
4f80fbfae4 | ||
|
|
525b54e3c6 | ||
|
|
05eb94915b | ||
|
|
1159edad15 | ||
|
|
450a352c98 | ||
|
|
94cf31eb29 | ||
|
|
123d9eea0c | ||
|
|
c976f24a50 | ||
|
|
583b9e56ba | ||
|
|
e183d5efd2 | ||
|
|
f9369b0cf1 | ||
|
|
11b5ebe108 | ||
|
|
379cbc7e69 | ||
|
|
a3941e3b01 | ||
|
|
afb3221446 | ||
|
|
32fdb63a89 | ||
|
|
638bad3672 | ||
|
|
a1694bd84d | ||
|
|
892d6f2b7c | ||
|
|
d1e70879ee | ||
|
|
adc2e15078 | ||
|
|
4df78340b6 | ||
|
|
ae5f76e018 | ||
|
|
f8d6ac7900 | ||
|
|
cee4291ba9 | ||
|
|
fd5e8dcca0 | ||
|
|
ad53bff8e3 | ||
|
|
f75971fc6a | ||
|
|
2f60697b28 | ||
|
|
3f4e265dc0 | ||
|
|
695d3c7a23 | ||
|
|
185fd96802 | ||
|
|
0f4555cfaa | ||
|
|
5903af2229 | ||
|
|
29db4ca0e3 | ||
|
|
78ffb9fce4 | ||
|
|
30f862cf26 | ||
|
|
ab81b2a71d | ||
|
|
ec29a47dc4 | ||
|
|
98578de840 | ||
|
|
311bbe19cf | ||
|
|
3f841c94ec | ||
|
|
5b2f2ca42b | ||
|
|
f3e55f9958 | ||
|
|
35b58e489b | ||
|
|
d9848b868b | ||
|
|
9187bf448f | ||
|
|
0184ebad11 | ||
|
|
e06287acb3 | ||
|
|
598256639b | ||
|
|
24d4073b9b | ||
|
|
3e3adaff10 | ||
|
|
e57023cad9 | ||
|
|
7bfc35e04d | ||
|
|
0cf256e5b2 | ||
|
|
bfc04497d6 | ||
|
|
eb13bab738 | ||
|
|
97e6206244 | ||
|
|
a7ac30493c | ||
|
|
49c8423be5 | ||
|
|
127079ede3 | ||
|
|
c90bf8f610 | ||
|
|
cf17dea53f | ||
|
|
1d1d32da0e | ||
|
|
836e024bd7 | ||
|
|
f0c80f3c7c | ||
|
|
11b9fdad80 | ||
|
|
2a6087af27 | ||
|
|
20ab105e44 | ||
|
|
c0632e17bd | ||
|
|
9ce1ff3e63 | ||
|
|
3b1b15911f | ||
|
|
c76459859d | ||
|
|
0d514a46d7 | ||
|
|
f7a9389a66 | ||
|
|
24e8487db6 | ||
|
|
0e62904883 | ||
|
|
489e731eea | ||
|
|
b9d62edaad | ||
|
|
7219c787a6 | ||
|
|
4fc5166815 | ||
|
|
f9176fc3fb | ||
|
|
6e1315a4fa | ||
|
|
f25be5e7bd | ||
|
|
bc5b254d01 | ||
|
|
62811282a1 | ||
|
|
3e19195e4a | ||
|
|
72158e8fc2 | ||
|
|
e5e51c9c42 | ||
|
|
5029db2302 | ||
|
|
d87bf37138 | ||
|
|
e7d11fdc84 | ||
|
|
a2f29d5e2e | ||
|
|
4cf3a84190 | ||
|
|
b61e1d5779 | ||
|
|
b8351b8e29 | ||
|
|
18fdfcc9d8 | ||
|
|
b22aa1ad0f | ||
|
|
7f5baab5df | ||
|
|
76f8130d40 | ||
|
|
aaa3bcfb2a | ||
|
|
308e9fcb9e | ||
|
|
a6c153a154 | ||
|
|
76c00dea3c | ||
|
|
504a218eee | ||
|
|
97758a5163 | ||
|
|
dd9e57542f | ||
|
|
985cab73cd | ||
|
|
843058ce52 | ||
|
|
91994a0b74 | ||
|
|
acb8068606 | ||
|
|
cc98ac72c1 | ||
|
|
3764cd6d7f | ||
|
|
e66e5e28c1 | ||
|
|
c027e70c8b | ||
|
|
30b785eec9 | ||
|
|
4770f1eb90 | ||
|
|
034cc0ab6d | ||
|
|
bb32962f7d | ||
|
|
e0dc4edf7a | ||
|
|
d70b13ef33 | ||
|
|
45e0dc9bd5 | ||
|
|
fbde0052a8 | ||
|
|
9a19148da8 | ||
|
|
248f38cc47 | ||
|
|
e9249c924c | ||
|
|
bce6c12ab3 | ||
|
|
baa55ab7f9 | ||
|
|
89f39fa77e | ||
|
|
d091084064 | ||
|
|
bdd1feae3e | ||
|
|
9bf3086230 | ||
|
|
345a569115 | ||
|
|
2aa84c98ad | ||
|
|
dffb996335 | ||
|
|
f10387025d | ||
|
|
7d3d633e03 | ||
|
|
dbf5d6954c | ||
|
|
706d282681 | ||
|
|
61dc903e9a | ||
|
|
13da04e9f1 | ||
|
|
6bc927147c | ||
|
|
cd7ee69f50 | ||
|
|
adecc35721 | ||
|
|
7773844d2f | ||
|
|
e654d48cdf | ||
|
|
d5facd90df | ||
|
|
dfaca39528 | ||
|
|
4450aacdf9 | ||
|
|
ca4e51afa9 | ||
|
|
f0acbb1934 | ||
|
|
1a2dc9efac | ||
|
|
405601463e | ||
|
|
2181407647 | ||
|
|
443f7c6a09 | ||
|
|
212dda40ca | ||
|
|
883a2f995e | ||
|
|
1c4f9769b1 | ||
|
|
af12d13d67 | ||
|
|
39e15a8929 | ||
|
|
866dfca710 | ||
|
|
73d1e39587 | ||
|
|
856e85988a | ||
|
|
11950dc4f4 | ||
|
|
c3f7e491a6 | ||
|
|
7a3b1afacc | ||
|
|
61e85df8db | ||
|
|
146f3f91b5 | ||
|
|
0459633d82 | ||
|
|
4a430e6663 | ||
|
|
d0297b7e34 | ||
|
|
56cce46858 | ||
|
|
962da3280b | ||
|
|
b0c714176a | ||
|
|
6ffc902598 | ||
|
|
790fd45470 | ||
|
|
f708f6c265 | ||
|
|
7f61b4819b | ||
|
|
e7760dc7c7 | ||
|
|
12c27af813 | ||
|
|
7e2912ffd8 | ||
|
|
874d750bf0 | ||
|
|
95c7adc90b | ||
|
|
b96c1c2c3a | ||
|
|
b916817247 | ||
|
|
2d959b0108 | ||
|
|
abf2151bac | ||
|
|
56f7d83b5b | ||
|
|
dd55827486 | ||
|
|
645affd511 | ||
|
|
1a671fd0b4 | ||
|
|
0c8a462ddf | ||
|
|
9d95817b4d | ||
|
|
bf6ee6dc87 | ||
|
|
21b6f9c67e | ||
|
|
d9c95de736 | ||
|
|
7c22c4e6c5 | ||
|
|
1af1383428 | ||
|
|
ea6116cd5c | ||
|
|
81ecaa82ff | ||
|
|
588cfe4c96 | ||
|
|
844847d871 | ||
|
|
2a9c9e4510 | ||
|
|
714afdc2a8 | ||
|
|
7f6219c846 | ||
|
|
be09535cd6 | ||
|
|
2acf02945b | ||
|
|
f5d4d967eb | ||
|
|
1c663cb26b | ||
|
|
c56665d4dd | ||
|
|
e4b4606ab9 | ||
|
|
78ffd5ec6b | ||
|
|
d426dbd4ff | ||
|
|
04b4429809 | ||
|
|
d10b43c098 | ||
|
|
8b4a45cb5b | ||
|
|
bda7e48a84 | ||
|
|
e87ac7468d | ||
|
|
49f5d9518e | ||
|
|
19ca7afcb8 | ||
|
|
1cdd061b06 | ||
|
|
929b5d6175 | ||
|
|
45b947ec68 | ||
|
|
88565d02ad | ||
|
|
0e476c8988 | ||
|
|
d996911cda | ||
|
|
61089fa866 | ||
|
|
91a9dcb9a1 | ||
|
|
3624d584db | ||
|
|
2fd29ab365 | ||
|
|
cef8f5acde | ||
|
|
76c6f50fa8 | ||
|
|
6216f7a618 | ||
|
|
0c939bd447 | ||
|
|
3cfa9e1619 | ||
|
|
cd9d345a0e | ||
|
|
30b76347c5 | ||
|
|
04d04ebe69 | ||
|
|
ea48c2a5ec | ||
|
|
badc681511 | ||
|
|
d10687d6fe | ||
|
|
3bea2f795c | ||
|
|
ffac682238 | ||
|
|
22945b7faa | ||
|
|
d8de0a9b84 | ||
|
|
17c3611f50 | ||
|
|
3bd6ef5f6c | ||
|
|
2b157cc383 | ||
|
|
a793ff149b | ||
|
|
c0a81ef9a2 | ||
|
|
68f58c3b8d | ||
|
|
0380927f7e | ||
|
|
2c91fdea15 | ||
|
|
764c2241ca | ||
|
|
b1723a5dfb | ||
|
|
997a59dc23 | ||
|
|
aecbcb68a5 | ||
|
|
a21e38f428 | ||
|
|
7999010390 | ||
|
|
00fb924dbb | ||
|
|
f28fdd8404 | ||
|
|
f3aafd3884 | ||
|
|
2df98c456a | ||
|
|
21bf8a343a | ||
|
|
caee58da55 | ||
|
|
64bf1d5090 | ||
|
|
ee449f78f4 | ||
|
|
9d14a2d840 | ||
|
|
795da8cf1b | ||
|
|
1fc0ba8628 | ||
|
|
42542159d4 | ||
|
|
c290a1c779 | ||
|
|
a49a0ac9ae | ||
|
|
6aacaa7d41 | ||
|
|
0dce9542fb | ||
|
|
42574f302c | ||
|
|
d4ae3411c7 | ||
|
|
6739e66bd3 | ||
|
|
78c66b00eb | ||
|
|
b7dd460734 | ||
|
|
03e1c32757 | ||
|
|
84035f887a | ||
|
|
666c795c93 | ||
|
|
2fd623a97d | ||
|
|
120981f8c4 | ||
|
|
493fe05ac4 | ||
|
|
5905926d4a | ||
|
|
1841e47c5c | ||
|
|
43914c0f09 | ||
|
|
4fc97f21c5 | ||
|
|
dcf1e4e39b | ||
|
|
a6d5944bf3 | ||
|
|
f2391e698a | ||
|
|
f9f4e7716d | ||
|
|
ce3f9bec5c | ||
|
|
3f1e826a10 | ||
|
|
580678eed8 | ||
|
|
4b76c70c29 | ||
|
|
cf922a064b | ||
|
|
0fe721f426 | ||
|
|
83a6feeca4 | ||
|
|
4ee937ff0c | ||
|
|
9e4cb446ef | ||
|
|
50282f32f4 | ||
|
|
0bbbadd494 | ||
|
|
da38555035 | ||
|
|
bcb5be76c7 | ||
|
|
64118058f2 | ||
|
|
474a875167 | ||
|
|
9d49b2da4f | ||
|
|
ae0ab0c153 | ||
|
|
90d7b0c678 | ||
|
|
441dae8e25 | ||
|
|
1b5d7fdc13 | ||
|
|
b3adc56adf | ||
|
|
bb88228019 | ||
|
|
1468a41737 | ||
|
|
c4c73a2bb7 | ||
|
|
42fdedf23b | ||
|
|
9cd779b60c | ||
|
|
d81609165e | ||
|
|
d281c2366c | ||
|
|
4a5ffb3169 | ||
|
|
e51967923b | ||
|
|
8339413262 | ||
|
|
b3bfa8950d | ||
|
|
9b58a1be11 | ||
|
|
0ad63bbfc1 | ||
|
|
f3eb609eee | ||
|
|
00f2d46b53 | ||
|
|
b9f6017590 | ||
|
|
9ec8dc167c | ||
|
|
99eaa37d4c | ||
|
|
6f57652455 | ||
|
|
1966cc2034 | ||
|
|
e83ec94461 | ||
|
|
8bc14b52b2 | ||
|
|
34f3692d9a | ||
|
|
f4d6e3cb3e | ||
|
|
bcdc8386a0 | ||
|
|
178e47ebf5 | ||
|
|
4bf7f982ba | ||
|
|
3e0b8a5f8f | ||
|
|
c778c9f109 | ||
|
|
ae41a956ac | ||
|
|
e8a031251a | ||
|
|
9ca79acbe4 | ||
|
|
c0bd88278c | ||
|
|
c33ba239d0 | ||
|
|
3a6d199962 | ||
|
|
10f5799014 | ||
|
|
8c73dd8f14 | ||
|
|
da26ab14ab | ||
|
|
82e500eee1 | ||
|
|
48485ce933 | ||
|
|
0da3e562fb | ||
|
|
997a270a84 | ||
|
|
7746561360 | ||
|
|
83e79cf857 | ||
|
|
198aa0971c | ||
|
|
5cdf8bdd86 | ||
|
|
d9eb44df1f | ||
|
|
0d9f390cd9 | ||
|
|
e6eb421e79 | ||
|
|
21e29bbb3c | ||
|
|
deeac97082 | ||
|
|
ebbd7e1a77 | ||
|
|
fa67030b47 | ||
|
|
d4dd5a65e6 | ||
|
|
aa172dbfbc | ||
|
|
3838b6b815 | ||
|
|
42f48e11fc | ||
|
|
4b304ce8d5 | ||
|
|
bfd595ea00 | ||
|
|
c477745fbd | ||
|
|
a463a9802f | ||
|
|
750bedbabd | ||
|
|
0b928d29a6 | ||
|
|
527130a53c | ||
|
|
d957efb1b8 | ||
|
|
f31e73f182 | ||
|
|
ad17924622 | ||
|
|
3eb2fcbcb8 | ||
|
|
2b61f4a3d6 | ||
|
|
759b0c8b96 | ||
|
|
8f02ea0cf6 | ||
|
|
327bb1efc8 | ||
|
|
5a437ed5ac | ||
|
|
7180edf162 | ||
|
|
e07d42e3da | ||
|
|
117c903e7c | ||
|
|
3e16d317fc | ||
|
|
a94d5bfd07 | ||
|
|
548ce0a69d | ||
|
|
3712448c3f | ||
|
|
853cdddc99 | ||
|
|
ac69a0fa74 | ||
|
|
6aef5df6c8 | ||
|
|
3bfd3f4314 | ||
|
|
61421f1393 | ||
|
|
d7f9d584d6 | ||
|
|
a81b98a63a | ||
|
|
0c040d8816 | ||
|
|
6a2cbb3a3a | ||
|
|
1385feb066 | ||
|
|
d521e29824 | ||
|
|
1c044c9072 | ||
|
|
ffe5e14b2b | ||
|
|
a4010189cd | ||
|
|
7ce13b5ba5 | ||
|
|
9bb87b38f5 | ||
|
|
eedfc61fad | ||
|
|
2b0518a675 | ||
|
|
84da81b62f | ||
|
|
1bd96707e5 | ||
|
|
3bd2f4251b | ||
|
|
2312bea10d | ||
|
|
5437ee98b4 | ||
|
|
b6829523a7 | ||
|
|
bba11b1813 | ||
|
|
2649d05e25 | ||
|
|
60db83ecad | ||
|
|
0f934ee461 | ||
|
|
8d968c2fc0 | ||
|
|
c1705507a8 | ||
|
|
3b3482e753 | ||
|
|
dccc644a9e | ||
|
|
74e44f1256 | ||
|
|
76090077b6 | ||
|
|
76e86ee5f7 | ||
|
|
3fdde60a37 | ||
|
|
71574a5ce0 | ||
|
|
ca9074e6f5 | ||
|
|
4576204c1d | ||
|
|
190bdb0795 | ||
|
|
c67781f87d | ||
|
|
d01b5aba4e | ||
|
|
f0ffe3cbe3 | ||
|
|
3c6c368582 | ||
|
|
edf764709d | ||
|
|
36efbd1544 | ||
|
|
4d1d92db39 | ||
|
|
5475d6c2bc | ||
|
|
3a1e38a2f2 | ||
|
|
12c91aae3d | ||
|
|
bff6723ba6 | ||
|
|
d030843522 | ||
|
|
6e04482981 | ||
|
|
fd1b435020 | ||
|
|
5661c6df27 | ||
|
|
eafedda99e | ||
|
|
26ea0be4c7 | ||
|
|
2346d92f16 | ||
|
|
1821b9ff71 | ||
|
|
6c24c52104 | ||
|
|
979d07679c | ||
|
|
a702dcc0ee | ||
|
|
e8e2650fa9 | ||
|
|
9f903709e7 | ||
|
|
8cd9449ba6 | ||
|
|
8a03f0b501 | ||
|
|
2567a7c022 | ||
|
|
178fcbb4c1 | ||
|
|
cc4e81146f | ||
|
|
7a3794eabb | ||
|
|
a8d4705fa6 | ||
|
|
a1e90b8c9f | ||
|
|
26b7efafe8 | ||
|
|
ef1d7e1249 | ||
|
|
9a3b11e522 | ||
|
|
ffff1399b9 | ||
|
|
087e5878a6 | ||
|
|
c0e97c19c0 | ||
|
|
b4184d2235 | ||
|
|
b2a8a422b2 | ||
|
|
dbaa02cdb0 | ||
|
|
cdc9ff783d | ||
|
|
8f5e69e5ba | ||
|
|
558e8a2dd2 | ||
|
|
1de38ad9ec | ||
|
|
132959a5cf | ||
|
|
9b233c8679 | ||
|
|
835001bd6c | ||
|
|
ccb3ca69dc | ||
|
|
d69a9fdde1 | ||
|
|
9e105e017d | ||
|
|
98a09db9b0 | ||
|
|
7b8352afe2 | ||
|
|
cdecc2087c | ||
|
|
4ad9e435e0 | ||
|
|
a64e9bb3cf | ||
|
|
ab9ecdc939 | ||
|
|
a7cec6f5fd | ||
|
|
b8f9b18506 | ||
|
|
bc7d0fd758 | ||
|
|
f42939c3b0 | ||
|
|
1e65228488 | ||
|
|
b749fbdb88 | ||
|
|
1aa368221a | ||
|
|
ff4356ce31 | ||
|
|
3146b91c59 | ||
|
|
55477429be | ||
|
|
c4b4e1b5c8 | ||
|
|
50a578cfdc | ||
|
|
741a2e13b5 | ||
|
|
9ca5fc0496 | ||
|
|
9c8bac7b0b | ||
|
|
fec2f2dfc8 | ||
|
|
2a2bdc8026 | ||
|
|
376e34bde6 | ||
|
|
70af50b74f | ||
|
|
21d1734365 | ||
|
|
97a3fcc9df | ||
|
|
88e6825028 | ||
|
|
b3952ce9db | ||
|
|
e1abb037be | ||
|
|
55154dddb2 | ||
|
|
8265d3f70a | ||
|
|
c3a45822d5 | ||
|
|
ae76b86ab6 | ||
|
|
e56b0d2b83 | ||
|
|
e47299cd6b | ||
|
|
328fd5221c | ||
|
|
eafbd450ea | ||
|
|
68997b8033 | ||
|
|
05b3a122f5 | ||
|
|
7b3a745548 | ||
|
|
1aa61a156d | ||
|
|
bdf4add64f | ||
|
|
a34ba7cea8 | ||
|
|
2387a1bb91 | ||
|
|
76598c304e | ||
|
|
8e10bd1911 | ||
|
|
40bbd62725 | ||
|
|
961b8cf19e | ||
|
|
39393d328b | ||
|
|
af4404c86e | ||
|
|
7fa9fdff9e | ||
|
|
693931ed15 | ||
|
|
785ef8f2f3 | ||
|
|
92da66059b | ||
|
|
db27b876ff | ||
|
|
8912e55269 | ||
|
|
c1e8231ed9 | ||
|
|
f39de5a078 | ||
|
|
0b59a6233e | ||
|
|
44a2f11963 | ||
|
|
5cf661dd8b | ||
|
|
991f1dfca5 | ||
|
|
be09cc8aa3 | ||
|
|
55c45480b4 | ||
|
|
8456b38dea | ||
|
|
233d0bf089 | ||
|
|
812f465608 | ||
|
|
46d54d2826 | ||
|
|
e94985c614 | ||
|
|
9054e45310 | ||
|
|
2059f46c42 | ||
|
|
e6e697a5f8 | ||
|
|
0e3ca7ac35 | ||
|
|
966d02d89e | ||
|
|
e3f99051db | ||
|
|
030921c963 | ||
|
|
5e1a193f78 | ||
|
|
109a5e7cd7 | ||
|
|
864555bc0f | ||
|
|
ec0fca24c6 | ||
|
|
feeba9bf90 | ||
|
|
6318c54214 | ||
|
|
5658b14ad4 | ||
|
|
b713b01af1 | ||
|
|
f8187bad14 | ||
|
|
03171f97a5 | ||
|
|
6bfb508ddc | ||
|
|
d7d9352a90 | ||
|
|
4e614a3179 | ||
|
|
a89bfd0b05 | ||
|
|
3bf79355b8 | ||
|
|
b61ba78eb3 | ||
|
|
82fd15272d | ||
|
|
069a19b3f5 | ||
|
|
5d9687fb47 | ||
|
|
976a9d5c77 | ||
|
|
1729a55add | ||
|
|
8d212b8f03 | ||
|
|
aec0101769 | ||
|
|
7f0b2c500f | ||
|
|
582153b193 | ||
|
|
8868b1b0e6 | ||
|
|
bd71e9ae8f | ||
|
|
8329184d99 | ||
|
|
c8c42b5d70 | ||
|
|
34e1ea92b2 | ||
|
|
99e8ffcdeb | ||
|
|
7de098063f | ||
|
|
953e8c1749 | ||
|
|
5ac8b9ad62 | ||
|
|
80fb171e0a | ||
|
|
8876c3c1d7 | ||
|
|
ccb5738826 | ||
|
|
4ee4a00b58 | ||
|
|
f4d401bc56 | ||
|
|
a817a2cc79 | ||
|
|
24a03b5d20 | ||
|
|
ddc4daaa28 | ||
|
|
998e6c690b | ||
|
|
dd062f6e38 | ||
|
|
0320ccf4a0 | ||
|
|
4ecfbbb844 | ||
|
|
050fd3a412 | ||
|
|
ee418bdd0a | ||
|
|
13f401df8c | ||
|
|
73c5418114 | ||
|
|
cb6391085a | ||
|
|
f4ec5735d3 | ||
|
|
0e124f3017 | ||
|
|
52ec6eded8 | ||
|
|
6be2c7cbdf | ||
|
|
c2384d2082 | ||
|
|
13dadeda37 | ||
|
|
9f772b0e13 | ||
|
|
9b9c2a722a | ||
|
|
c95a9842a4 | ||
|
|
82ff8010c5 | ||
|
|
3a97f89199 | ||
|
|
2f94b3cb15 | ||
|
|
be5c1f4832 | ||
|
|
a536cc6f82 | ||
|
|
4839535355 | ||
|
|
4bd0ed97aa | ||
|
|
d2d887a782 | ||
|
|
fbbc506e84 | ||
|
|
1fa93788db | ||
|
|
da01c6ccd1 | ||
|
|
67390bb8ec | ||
|
|
d662cd6b64 | ||
|
|
089600d7f0 | ||
|
|
8501c86663 | ||
|
|
d3bab2f8cb | ||
|
|
b563ab44b0 | ||
|
|
ac936dc673 | ||
|
|
470b1680d0 | ||
|
|
193561c336 | ||
|
|
c10f7a0ef2 | ||
|
|
9abd61e2de | ||
|
|
46dcd105c8 | ||
|
|
4060bef2be | ||
|
|
ff7aa3df79 | ||
|
|
aab0785626 | ||
|
|
af589afa4d | ||
|
|
01a45d3b65 | ||
|
|
ad578b394d | ||
|
|
0ebbd54498 | ||
|
|
e065cbc63b | ||
|
|
c00295f6f2 | ||
|
|
a5fe5836e5 | ||
|
|
755df5e60e | ||
|
|
4cea4e4f5d | ||
|
|
7b6db9404c | ||
|
|
db530a6820 | ||
|
|
73b8059b55 | ||
|
|
991ac3b31b | ||
|
|
931ea33694 | ||
|
|
b145a14ea3 | ||
|
|
2df8a106d4 | ||
|
|
a37c6b5e6f | ||
|
|
546d1e9e73 | ||
|
|
a820082a6a | ||
|
|
5cf306683a | ||
|
|
96967b9e30 | ||
|
|
bbd17e4e64 | ||
|
|
a23850f002 | ||
|
|
68a262fa88 | ||
|
|
aaf3be98b7 | ||
|
|
ea8ed31ba4 | ||
|
|
b1504da012 | ||
|
|
9b6d2e9c3a | ||
|
|
8ad1cddb42 | ||
|
|
c04d93fcea | ||
|
|
8c8fdbc27c | ||
|
|
43133b70f4 | ||
|
|
382273647f | ||
|
|
14e6f1bdb1 | ||
|
|
dc817abe2c | ||
|
|
d8911ef69c | ||
|
|
2e71401342 | ||
|
|
cb1031705b | ||
|
|
c649f2163b | ||
|
|
a417eb46f7 | ||
|
|
264b988b41 | ||
|
|
7b80ee8bf0 | ||
|
|
cdbee03e93 | ||
|
|
cd6cdf7add | ||
|
|
edb73b3556 | ||
|
|
5d8452af06 | ||
|
|
386afdbe8d | ||
|
|
7695416ca0 | ||
|
|
583891a145 | ||
|
|
be0e53fa08 | ||
|
|
1fb4860d68 | ||
|
|
6f80232c32 | ||
|
|
9910bd0dfa | ||
|
|
fb066b3b6f | ||
|
|
c3a0141a47 | ||
|
|
afe126f393 | ||
|
|
3c32dbc701 | ||
|
|
2b80ab52b7 | ||
|
|
79b06d35b9 | ||
|
|
a005ec5354 | ||
|
|
d2ddb9ded5 | ||
|
|
169416b548 | ||
|
|
2d42556b03 | ||
|
|
b1a1ccc145 | ||
|
|
b3911fedd9 | ||
|
|
0370e51e57 | ||
|
|
88f5843e08 | ||
|
|
1c3e58303d | ||
|
|
8c12d4d692 | ||
|
|
27d1a00196 | ||
|
|
624cf26c29 | ||
|
|
934b1fd5ed | ||
|
|
fb6d582560 | ||
|
|
df72a6e094 | ||
|
|
d3343c2747 | ||
|
|
f72838139c | ||
|
|
254ef7081b | ||
|
|
c5cbc1887f | ||
|
|
15c961670e | ||
|
|
85330f932c | ||
|
|
7ed0909169 | ||
|
|
c3de179124 | ||
|
|
c335d3acd4 | ||
|
|
49e2f2c7c9 | ||
|
|
399c9463bf | ||
|
|
858d06380e | ||
|
|
db380cfe24 | ||
|
|
bb6f052f32 | ||
|
|
333480c29b | ||
|
|
7fabb6b554 | ||
|
|
88294de509 | ||
|
|
3220b4a1a7 | ||
|
|
08f389230e | ||
|
|
69e95159e9 | ||
|
|
3f39f96e81 | ||
|
|
5984292ea5 | ||
|
|
408bc86e1d | ||
|
|
95353b5077 | ||
|
|
49ea2a9a5a | ||
|
|
daebea262a | ||
|
|
937f30b48d | ||
|
|
434a7dcf52 | ||
|
|
79e39e7e4f | ||
|
|
d87f62c330 | ||
|
|
7b5a7b8e0f | ||
|
|
f207bf14d7 | ||
|
|
381c3144b6 | ||
|
|
5b0f7b5a02 | ||
|
|
e6000cd3e9 | ||
|
|
379dfedf05 | ||
|
|
35090fcdfb | ||
|
|
f18b87db5d | ||
|
|
c9b8cb677b | ||
|
|
0d513dd63e | ||
|
|
59309c2b1d | ||
|
|
51ba076f26 | ||
|
|
8846dd7edb | ||
|
|
89579aa35e | ||
|
|
6b1e6ef267 | ||
|
|
2c2102f6e6 | ||
|
|
0072dd3481 | ||
|
|
f6447d2020 | ||
|
|
d82ace2f63 | ||
|
|
3a9622fb1e | ||
|
|
050ef73530 | ||
|
|
7fd163d6d4 | ||
|
|
69e061e4c4 | ||
|
|
7889e3a624 | ||
|
|
4b82ec4022 | ||
|
|
0cba056932 | ||
|
|
bb5e09fe8f | ||
|
|
7f3d01ce0e | ||
|
|
ba1d61d8d9 | ||
|
|
16088be597 | ||
|
|
1a2940fba2 | ||
|
|
918108118a | ||
|
|
f8a0f17384 | ||
|
|
fbdc8791cd | ||
|
|
cd18fdf79a | ||
|
|
566bdb8a74 | ||
|
|
7212e9a879 | ||
|
|
ca6af92411 | ||
|
|
2ddcf41201 | ||
|
|
b472be3a1a | ||
|
|
304c348736 | ||
|
|
ce56f49369 | ||
|
|
f5a7461fed | ||
|
|
678a97d01b | ||
|
|
fd28d354e7 | ||
|
|
e7a9241833 | ||
|
|
e7c5727f75 | ||
|
|
2b36858c12 | ||
|
|
eb3b7077fd | ||
|
|
5fc7bab885 | ||
|
|
ace1401a27 | ||
|
|
3b0d7c7f09 | ||
|
|
80921b8367 | ||
|
|
de3f790e0d | ||
|
|
956ae21926 | ||
|
|
8f73a37a53 | ||
|
|
e58cbc9c8a | ||
|
|
21ff7c8502 | ||
|
|
a5edef2db8 | ||
|
|
1bff239183 | ||
|
|
30ffeaed34 | ||
|
|
f08297c36c | ||
|
|
11e86b5805 | ||
|
|
1efcaefe94 | ||
|
|
3762c8d6c3 | ||
|
|
9675f59040 | ||
|
|
972c1ac71d | ||
|
|
b8cda402cb | ||
|
|
5cd05ab008 | ||
|
|
c2868d4100 | ||
|
|
027d932b63 | ||
|
|
e5eb21168b | ||
|
|
fe8dd3fb51 | ||
|
|
18270f8020 | ||
|
|
1b4044814f | ||
|
|
0e659b5b07 | ||
|
|
abf977b791 | ||
|
|
6197cc6a7a | ||
|
|
23c3edbec8 | ||
|
|
5b156c4530 | ||
|
|
87bf52b59e | ||
|
|
3822197830 | ||
|
|
ce76f8fb27 | ||
|
|
ebbc7b5526 | ||
|
|
3e9475087e | ||
|
|
eb57811654 | ||
|
|
591c32ceb3 | ||
|
|
105dfa21fb | ||
|
|
b523505c05 | ||
|
|
89cf71d555 | ||
|
|
b1fd450c91 | ||
|
|
f0d62a8ea2 | ||
|
|
80ca7d05ea | ||
|
|
f2410a6921 | ||
|
|
3942a38f0e | ||
|
|
6c63ef60f3 | ||
|
|
4e39df2707 | ||
|
|
4c81ef19df | ||
|
|
bdc708e658 | ||
|
|
5e2a4b2cef | ||
|
|
6c12476902 | ||
|
|
3817fb04f0 | ||
|
|
d48dfce3a5 | ||
|
|
fd1b1ae635 | ||
|
|
265fef0a93 | ||
|
|
e7239980c6 | ||
|
|
78aa7962e9 | ||
|
|
35a15f0462 | ||
|
|
5d983c5d27 | ||
|
|
3f5ad8f7be | ||
|
|
9a30b935d2 | ||
|
|
2f1eeea77b | ||
|
|
bae16f0b52 | ||
|
|
fc502d81d4 | ||
|
|
bcd2bc066b | ||
|
|
9000a11377 | ||
|
|
b1dad24393 | ||
|
|
69a7ceef6c | ||
|
|
086e2046db | ||
|
|
ee7d20e939 | ||
|
|
64c8c0edfd | ||
|
|
907ff6e844 | ||
|
|
cf7b4ed4e3 | ||
|
|
ab6560d47d | ||
|
|
2c3ca7f906 | ||
|
|
2af1dfbcf4 | ||
|
|
5d81da2486 | ||
|
|
46b17628ac | ||
|
|
7bd4698073 | ||
|
|
fa4180e4ac | ||
|
|
c755908db5 | ||
|
|
862ce9e2c2 | ||
|
|
cf7bdef028 | ||
|
|
c4e3d99392 | ||
|
|
bb3937edb4 | ||
|
|
a6ff66b8ae | ||
|
|
d6c9462b46 | ||
|
|
277a311fdc | ||
|
|
46bc0dd750 | ||
|
|
713e91185b | ||
|
|
b16fd2c4d1 | ||
|
|
24e4b86065 | ||
|
|
f665bec63d | ||
|
|
460be8cbf3 | ||
|
|
2d2ddda2c5 | ||
|
|
893a318fbd | ||
|
|
e84e748959 | ||
|
|
bb5d785cb3 | ||
|
|
68c7f5e884 | ||
|
|
e0db24e9d7 | ||
|
|
9caba9dcd4 | ||
|
|
3f9614dd8f | ||
|
|
5f15f0c3b1 | ||
|
|
8d098cf86d | ||
|
|
3c89c442bc | ||
|
|
42b33ca04c | ||
|
|
e0d0e33408 | ||
|
|
c2f035acc0 | ||
|
|
de2ebb2f7b | ||
|
|
4a02c12acd | ||
|
|
607cdf0c17 | ||
|
|
63fc5e86f6 | ||
|
|
06accf5831 | ||
|
|
18c8156ab2 | ||
|
|
19c36c2979 | ||
|
|
a70101f5e5 | ||
|
|
295f980584 | ||
|
|
89b058fff3 | ||
|
|
97251b345a | ||
|
|
9d32c314d1 | ||
|
|
bc9adc8352 | ||
|
|
dcb10b249a | ||
|
|
aa4d586025 | ||
|
|
656af17b1c | ||
|
|
ae84a64b71 | ||
|
|
eb66182211 | ||
|
|
710f15f790 | ||
|
|
6125394354 | ||
|
|
f8a232385d | ||
|
|
9d8c1f3ffe | ||
|
|
c319b25077 | ||
|
|
234ca70c4e | ||
|
|
913b694f78 | ||
|
|
aa4cfdc237 | ||
|
|
393d3b33f1 | ||
|
|
f37ab8e656 | ||
|
|
2e64d36f39 | ||
|
|
571fb6af4b | ||
|
|
7813d20f8e | ||
|
|
838fb3ed7e | ||
|
|
452d549e43 | ||
|
|
2e85d1356e | ||
|
|
93a17366a7 | ||
|
|
ea34125fad | ||
|
|
03e8316db4 | ||
|
|
44b635c9e4 | ||
|
|
9c9a4f2e7d | ||
|
|
4e39a531b7 | ||
|
|
823624f8f2 | ||
|
|
bab881e75d | ||
|
|
ec629ff081 | ||
|
|
130a83f0b9 | ||
|
|
e97a30fcb1 | ||
|
|
4ced055035 | ||
|
|
a67fe859e8 | ||
|
|
5de551078f | ||
|
|
f89833cc72 | ||
|
|
0225fde2a5 | ||
|
|
e6f7a543cd | ||
|
|
d2f77ed521 | ||
|
|
7bc7265757 | ||
|
|
1f94602404 | ||
|
|
8085d3ead6 | ||
|
|
f7b815bedb | ||
|
|
1cd8eeda4e | ||
|
|
96ff0e59f6 | ||
|
|
863f93a621 | ||
|
|
d38450424b | ||
|
|
577b4c0e0a | ||
|
|
f2d7b115d9 | ||
|
|
8bfd670f8b | ||
|
|
247e30beed | ||
|
|
85e9984153 | ||
|
|
781da12475 | ||
|
|
0f286f68c8 | ||
|
|
7793d2e2a3 | ||
|
|
b965ff1cc6 | ||
|
|
3ee600ea28 | ||
|
|
506996b382 | ||
|
|
3f9f496c8b | ||
|
|
4e2f779b54 | ||
|
|
fb5288d5f1 | ||
|
|
94a5237eaa | ||
|
|
53d0aa8e42 | ||
|
|
f2c27dbdc8 | ||
|
|
e52817f8e9 | ||
|
|
596b8aafba | ||
|
|
6fff7dc17b | ||
|
|
c3f09987aa | ||
|
|
671a102c7c | ||
|
|
c91bacaa91 | ||
|
|
e8cf5fb60d | ||
|
|
f618741211 | ||
|
|
7a4386da81 | ||
|
|
058cdc782a | ||
|
|
71e68dc35b | ||
|
|
dedfd59d0b | ||
|
|
8ebba994e8 | ||
|
|
c5d9fc3c15 | ||
|
|
3ac8ba669b | ||
|
|
1e611a3fb5 | ||
|
|
8bbeb9aadb | ||
|
|
5f75d5a48b | ||
|
|
b39a3d1911 | ||
|
|
1a736f1c8a | ||
|
|
b8ecb03ddd | ||
|
|
ff47080cf1 | ||
|
|
d08aed47e8 | ||
|
|
80c946be44 | ||
|
|
ac4a1de0cd | ||
|
|
d542e4d6a0 | ||
|
|
6257b6dd80 | ||
|
|
29faa7882d | ||
|
|
5401a89356 | ||
|
|
c801e9ce86 | ||
|
|
f134f815d0 | ||
|
|
8155cfee9d | ||
|
|
8f6de1d304 | ||
|
|
4897aa4d09 | ||
|
|
5450c7670f | ||
|
|
1a04f16d1a | ||
|
|
f2e484b18e | ||
|
|
893d363a03 | ||
|
|
db536289c4 | ||
|
|
f2ac68ccf8 | ||
|
|
505a6583ee | ||
|
|
40d6943439 | ||
|
|
835fbb90b7 | ||
|
|
76a05f7d71 | ||
|
|
5be1633843 | ||
|
|
db75a7c910 | ||
|
|
50b0e8bd17 | ||
|
|
0b233f3a3f | ||
|
|
001dce8fef | ||
|
|
708c6da9fe | ||
|
|
c6d437b98e | ||
|
|
5ce4391db4 | ||
|
|
6f62e2c7b1 | ||
|
|
208f57830f | ||
|
|
92d4654b20 | ||
|
|
3d0b0c01b9 | ||
|
|
adb2f48fb5 | ||
|
|
54ab70beb7 | ||
|
|
c41040848c | ||
|
|
6097a0b5b7 | ||
|
|
288fccd414 | ||
|
|
be7865d458 | ||
|
|
e0e0990278 | ||
|
|
b0210e98f1 | ||
|
|
04ac40fa1a | ||
|
|
2ce1e81072 | ||
|
|
09d0f4425c | ||
|
|
73fe985a65 | ||
|
|
ba5c86fbba | ||
|
|
bc55749998 | ||
|
|
33c51b9a10 | ||
|
|
42bdd1c831 | ||
|
|
7c1053df2d | ||
|
|
9b7a83796b | ||
|
|
afe66cd55f | ||
|
|
a8dd229fb2 | ||
|
|
6d15e4861c | ||
|
|
5241a6dfa7 | ||
|
|
2fe0157b2e | ||
|
|
ec159ff85f | ||
|
|
f13a9164c2 | ||
|
|
3287e3c031 | ||
|
|
666015b8eb | ||
|
|
a4af706ebd | ||
|
|
6f8533f5e1 | ||
|
|
9c0fbbf8a4 | ||
|
|
63d7ba07b9 | ||
|
|
8182268a00 | ||
|
|
8de148e562 | ||
|
|
83d4d3e756 | ||
|
|
bfd0fc01b9 | ||
|
|
8f1ff084d0 | ||
|
|
c585b05879 | ||
|
|
e9210872bd | ||
|
|
e561492705 | ||
|
|
096f59ffc1 | ||
|
|
2adde38a9c | ||
|
|
a9f6ebde83 | ||
|
|
f5bb0dbc1d | ||
|
|
7c936cfcac | ||
|
|
d90f3f4f98 | ||
|
|
b94f8c9083 | ||
|
|
30f7077ea3 | ||
|
|
e1412c1f97 | ||
|
|
ec629ac3fe | ||
|
|
45207857c6 | ||
|
|
dea0c336ef | ||
|
|
1e898d57b2 | ||
|
|
b71bb8cd2b | ||
|
|
d9a9cdd374 | ||
|
|
f51d0fb56e | ||
|
|
70c638ad90 | ||
|
|
d8093f64b1 | ||
|
|
58b97e0695 | ||
|
|
d24fe03bdd | ||
|
|
1cf2beb92e | ||
|
|
3e88eed640 | ||
|
|
30e5b3d35e | ||
|
|
1882792a9d | ||
|
|
a935c7cecf | ||
|
|
cf0f71d04f | ||
|
|
d52906fda0 | ||
|
|
7b6b0f66a5 | ||
|
|
5faa439a8b | ||
|
|
524180bec9 | ||
|
|
3c12be354e | ||
|
|
045f2aced9 | ||
|
|
1ead2983ac | ||
|
|
1c26ccecc0 | ||
|
|
6aacb8e654 | ||
|
|
d8b8650048 | ||
|
|
c7b408f79d | ||
|
|
761addb92e | ||
|
|
e874e302e0 | ||
|
|
a6f49665a7 | ||
|
|
9836a47f65 | ||
|
|
a6d90c319e | ||
|
|
211305c572 | ||
|
|
54c1605de7 | ||
|
|
dca9a1da47 | ||
|
|
466ae37b85 | ||
|
|
b586971f00 | ||
|
|
bb01918120 | ||
|
|
f2bda2e5ec | ||
|
|
e12d924c59 | ||
|
|
a972891059 | ||
|
|
dd259bab33 | ||
|
|
19d3c5a10e | ||
|
|
d5ba3ffc04 | ||
|
|
882cf6ee59 | ||
|
|
086e929e32 | ||
|
|
cba4d25424 | ||
|
|
1176093567 | ||
|
|
b5a9b346c6 | ||
|
|
6b8036eac4 | ||
|
|
139e9fd3ef | ||
|
|
259b476139 | ||
|
|
92f07c5615 | ||
|
|
dceabbb51e | ||
|
|
96ae047271 | ||
|
|
ab99dc34f3 | ||
|
|
331124c92a | ||
|
|
13aaaeacd8 | ||
|
|
64c237a4e5 | ||
|
|
60dafd9267 | ||
|
|
1783fbab59 | ||
|
|
78d614129a | ||
|
|
5051eb6d40 | ||
|
|
cf174c7734 | ||
|
|
0e59ea570e | ||
|
|
e3832ee19b | ||
|
|
5cb0231cf9 | ||
|
|
ed5246e8c3 | ||
|
|
806bc2729a | ||
|
|
e05aa611eb | ||
|
|
9014424f0d | ||
|
|
e84eca2bca | ||
|
|
dd9d7ac0c8 | ||
|
|
71448d9712 | ||
|
|
d3e0cdd135 | ||
|
|
ce01e8f70c | ||
|
|
7a7ce69709 | ||
|
|
9232da7421 | ||
|
|
dcbe7df007 | ||
|
|
48bd284ef1 | ||
|
|
41cb143e0d | ||
|
|
a9141763e1 | ||
|
|
60ce65470f | ||
|
|
deb5da4866 | ||
|
|
b65546487d | ||
|
|
109150e693 | ||
|
|
93b139da62 | ||
|
|
f33e2fbcfb | ||
|
|
3c85220c7c | ||
|
|
cde8a343cb | ||
|
|
e26fb94933 | ||
|
|
83af151c0d | ||
|
|
185979f82c | ||
|
|
36b50576d9 | ||
|
|
fd47391afe | ||
|
|
fd83560852 | ||
|
|
d837fbb4c9 | ||
|
|
2d1a151195 | ||
|
|
fe0723889d | ||
|
|
173a40394e | ||
|
|
df9d648988 | ||
|
|
4339247815 | ||
|
|
40f9622722 | ||
|
|
8a5e05c0ac | ||
|
|
69c53aca06 | ||
|
|
2c4279621d | ||
|
|
1835c2238e | ||
|
|
a9df6fcc61 | ||
|
|
2fc7e505a3 | ||
|
|
2079493fc4 | ||
|
|
7d3ac9ec8a | ||
|
|
55d59fcfd2 | ||
|
|
115c622dca | ||
|
|
a151bba3d0 | ||
|
|
fe1e0e46c3 | ||
|
|
fdc751f9e5 | ||
|
|
eeafeeff17 | ||
|
|
0d665c41d2 | ||
|
|
69dd0b8e5e | ||
|
|
9f8a28c6d0 | ||
|
|
59fb175637 | ||
|
|
a4925de4a1 | ||
|
|
0d923b5caa | ||
|
|
7b13b9e72a | ||
|
|
d7888c57fa | ||
|
|
010a51949c | ||
|
|
c878b07458 | ||
|
|
98ccb5e0ae | ||
|
|
64eec9844b | ||
|
|
f800e20b05 | ||
|
|
7a34b8480e | ||
|
|
51f9fdd58c | ||
|
|
989c63a717 | ||
|
|
48e791c634 | ||
|
|
e722f1eb61 | ||
|
|
98ba083172 | ||
|
|
307cff87e7 | ||
|
|
36bb89fd36 | ||
|
|
2751855186 | ||
|
|
6581fe18a3 | ||
|
|
146e4e5533 | ||
|
|
7c63d654f2 | ||
|
|
19d66f37d4 | ||
|
|
4c9df0bfef | ||
|
|
7b919ba44a | ||
|
|
291db1f30b | ||
|
|
238d4e0004 | ||
|
|
f6d920366d | ||
|
|
aac880c575 | ||
|
|
8affb2564b | ||
|
|
b7c7eabf2a | ||
|
|
f48eba6a8e | ||
|
|
307703cbb7 | ||
|
|
45df452a38 | ||
|
|
9d67d5d6dc | ||
|
|
1978c8e17e | ||
|
|
507256e0dd | ||
|
|
90f38c3a58 | ||
|
|
6553012372 | ||
|
|
5c4db83b40 | ||
|
|
9b330164a7 | ||
|
|
4bc7e86448 | ||
|
|
3750c7e26e | ||
|
|
eb47c19172 | ||
|
|
393366d9cc | ||
|
|
5d7c84d357 | ||
|
|
6713c3bd50 | ||
|
|
4f7972df90 | ||
|
|
780ea487aa | ||
|
|
4f1ad11d79 | ||
|
|
f3b8589fea | ||
|
|
628f86e1a1 | ||
|
|
65859a9269 | ||
|
|
7b696f5f54 | ||
|
|
5eb9d230e2 | ||
|
|
c3d741ebd1 | ||
|
|
df97b7c444 | ||
|
|
333285747b | ||
|
|
2a8e7cbe94 | ||
|
|
f180fd39e1 | ||
|
|
f654af9d8a | ||
|
|
258c4e0111 | ||
|
|
54ce0a5a92 | ||
|
|
c5133e98f8 | ||
|
|
bd030e2e96 | ||
|
|
e9d07b3939 | ||
|
|
d586c39008 | ||
|
|
31d7e135cb | ||
|
|
5db9384e7f | ||
|
|
4feaec5e00 | ||
|
|
6054c5700d | ||
|
|
d2c8991916 | ||
|
|
ff0466872a | ||
|
|
163632249e | ||
|
|
7351cf1dd3 | ||
|
|
d2164118bc | ||
|
|
425d07fa8f | ||
|
|
fe046477f5 | ||
|
|
edd0ac9b77 | ||
|
|
0dcd480d43 | ||
|
|
e407e03e90 | ||
|
|
b23ff488ed | ||
|
|
57a22384b2 | ||
|
|
390da16129 | ||
|
|
71d9f25557 | ||
|
|
55774b0292 | ||
|
|
068ba3b717 | ||
|
|
d435231f55 | ||
|
|
f07070d65e | ||
|
|
f0b6fa622c | ||
|
|
f9582bb007 | ||
|
|
201acc8140 | ||
|
|
e5a064d0c3 | ||
|
|
131252f19d | ||
|
|
273d5feb94 | ||
|
|
4455509e32 | ||
|
|
5dcac034ac | ||
|
|
ef5ed755be | ||
|
|
720e4d2c87 | ||
|
|
356b49dba9 | ||
|
|
59c18f6b23 | ||
|
|
59eeb1ff0c | ||
|
|
8c8a4ab9e8 | ||
|
|
e441a67e2b | ||
|
|
82f8bc62a0 | ||
|
|
eeb0c11c65 | ||
|
|
66ea8ceda0 | ||
|
|
77e56f86a9 | ||
|
|
09eeebba94 | ||
|
|
f1298c3133 | ||
|
|
826308cbad | ||
|
|
ff48af5090 | ||
|
|
6f8072d1bb | ||
|
|
0a0365c266 | ||
|
|
6785f124e9 | ||
|
|
2422e77dd0 | ||
|
|
64fe4dfac6 | ||
|
|
9b2ef76250 | ||
|
|
8eee85a05e | ||
|
|
13e07eb2e8 | ||
|
|
aa2f969be6 | ||
|
|
01ea413afd | ||
|
|
79d6745ee6 | ||
|
|
2de4d49fd8 | ||
|
|
94f1e45b9a | ||
|
|
10bba83ee3 | ||
|
|
59413f3051 | ||
|
|
23a7e3b535 | ||
|
|
4c1fedddef | ||
|
|
14a6ba586a | ||
|
|
349625f748 | ||
|
|
e278982e6d | ||
|
|
c7038d695f | ||
|
|
eefca1e3f4 | ||
|
|
37158a83dc | ||
|
|
e4f3a56851 | ||
|
|
8cbc5f1e93 | ||
|
|
8d5f866ed0 | ||
|
|
62151727f2 | ||
|
|
7e65f1afa7 | ||
|
|
f6890e4e27 | ||
|
|
1dbb5f0c83 | ||
|
|
664f0d0d59 | ||
|
|
8d15f0478f | ||
|
|
fde5d57b16 | ||
|
|
12beeea8ee | ||
|
|
7840bc3318 | ||
|
|
d3abc47365 | ||
|
|
26124ba10b | ||
|
|
34355121b1 | ||
|
|
5df019e485 | ||
|
|
625a12f4e2 | ||
|
|
9069b11036 | ||
|
|
0fb64c5ff7 | ||
|
|
2b865ab56d | ||
|
|
759971d139 | ||
|
|
3373d3a958 | ||
|
|
7c0e0c49d3 | ||
|
|
aa4270883d | ||
|
|
8a5732cc47 | ||
|
|
128155714d | ||
|
|
7b453076db | ||
|
|
769e92f49d | ||
|
|
eef3f42d3f | ||
|
|
90c8a9df4c | ||
|
|
7e1998174c | ||
|
|
aa9ac205c1 | ||
|
|
7cffdd88bb | ||
|
|
44f395815d | ||
|
|
de927b62bd | ||
|
|
3dd4249220 | ||
|
|
a370112bc5 | ||
|
|
09f66359ad | ||
|
|
0f06838c25 | ||
|
|
2a0eeed55f | ||
|
|
555fc983f6 | ||
|
|
f3fea8cfb4 | ||
|
|
c0d7afe86c | ||
|
|
8ebd76b77b | ||
|
|
8f9340038b | ||
|
|
6e7c02c5b6 | ||
|
|
54e6369d41 | ||
|
|
8028c7b2d4 | ||
|
|
17f886d661 | ||
|
|
9ef9fb4b09 | ||
|
|
c2636e6e2f | ||
|
|
49517e2264 | ||
|
|
86aa23ef3f | ||
|
|
49ec21c243 | ||
|
|
3f26794038 | ||
|
|
3c06e0ffb1 | ||
|
|
6d7205625a | ||
|
|
83d14bd0e3 | ||
|
|
1a63b77a2f | ||
|
|
286d9c8347 | ||
|
|
c89ae5782e | ||
|
|
84eb0a9abb | ||
|
|
e1b23e8ea5 | ||
|
|
7a61217eb6 | ||
|
|
50c7dea2bd | ||
|
|
ed316fb06b | ||
|
|
95723479d6 | ||
|
|
5c01aaf996 | ||
|
|
25e9313d17 | ||
|
|
9bd07468c2 | ||
|
|
39eed5d85a | ||
|
|
ee972cff35 | ||
|
|
b06f80e862 | ||
|
|
d0efe26ce7 | ||
|
|
26a764a634 | ||
|
|
bcebac529b | ||
|
|
fc03d8032e | ||
|
|
15ee49282d | ||
|
|
ac41580ef7 | ||
|
|
516c7c93ae | ||
|
|
d268577443 | ||
|
|
d3fb507faa | ||
|
|
f4a0cd5af3 | ||
|
|
e44322adaa | ||
|
|
98e818cc60 | ||
|
|
03f13eeb4b | ||
|
|
23747a9334 | ||
|
|
5dea9e3cef | ||
|
|
0abc631773 | ||
|
|
03633c70ca | ||
|
|
67eb47adb1 | ||
|
|
0cea52ca80 | ||
|
|
e7c1925c76 | ||
|
|
bb77163955 | ||
|
|
cc679e3d6b | ||
|
|
1125c4e8f5 | ||
|
|
aa1aaeea38 | ||
|
|
742fa14286 | ||
|
|
7f741260d1 | ||
|
|
9e1f863a67 | ||
|
|
72dc9368fb | ||
|
|
71dc25099e | ||
|
|
ab44d5daf5 | ||
|
|
e6861ea988 | ||
|
|
62bf7db348 | ||
|
|
0a76405d90 | ||
|
|
70b36d64b5 | ||
|
|
24bd1d0c97 | ||
|
|
e41c3284ff | ||
|
|
03b646cb33 | ||
|
|
80593e28db | ||
|
|
9435ba9b14 | ||
|
|
39223f0270 | ||
|
|
4332f311b3 | ||
|
|
3d984d8917 | ||
|
|
30b6fff4d4 | ||
|
|
5062c10849 | ||
|
|
8b5676bd39 | ||
|
|
4c43bc99da | ||
|
|
13aa96faef | ||
|
|
80947207ab | ||
|
|
bf6109d18b | ||
|
|
19a499f6cc | ||
|
|
2db23b5f7f | ||
|
|
7c4ad3abb7 | ||
|
|
b4431aab54 | ||
|
|
cd62b0a722 | ||
|
|
194f57acc3 | ||
|
|
74193a006d | ||
|
|
cd0a0405a9 | ||
|
|
0f21c72936 | ||
|
|
576d20723d | ||
|
|
2e5b55a213 | ||
|
|
07f82d3a22 | ||
|
|
a0bff70dc2 | ||
|
|
5742968f4c | ||
|
|
e8f55c6456 | ||
|
|
b00eb413fa | ||
|
|
6fc5084931 | ||
|
|
1155e7d5b1 | ||
|
|
b776957d0e | ||
|
|
f29a6337a0 | ||
|
|
0ad1d75fe7 | ||
|
|
f76638f077 | ||
|
|
fd628fffd2 | ||
|
|
ff8b441bf6 | ||
|
|
7981ecc82c | ||
|
|
925506269d | ||
|
|
0c864b7c76 | ||
|
|
1bfe1e4844 | ||
|
|
1e00c08f7d | ||
|
|
4f019c6e49 | ||
|
|
5e7f938442 | ||
|
|
5fb309d00c | ||
|
|
83639a5991 | ||
|
|
d1fe0cb941 | ||
|
|
f3938fb336 | ||
|
|
d12a8ba1c1 | ||
|
|
382de9ba03 | ||
|
|
25094be07c | ||
|
|
ad879fcf63 | ||
|
|
50f91e8b91 | ||
|
|
1f3bd7614e | ||
|
|
c5fae65f2d | ||
|
|
2bff998340 | ||
|
|
f103fe9c79 | ||
|
|
dae8544938 | ||
|
|
b4aca59372 | ||
|
|
ffe88a5364 | ||
|
|
0d7f2ce838 | ||
|
|
26c66a97b5 | ||
|
|
808030d176 | ||
|
|
267d680ffc | ||
|
|
058e8ab833 | ||
|
|
23d1e94ddf | ||
|
|
89d7e52dc4 | ||
|
|
2f93aa8777 | ||
|
|
6f0c65d84c | ||
|
|
07ce3d5e5f | ||
|
|
ba541129ba | ||
|
|
b9d4805d56 | ||
|
|
302aee325f | ||
|
|
3b648a1d9f | ||
|
|
a36d35912a | ||
|
|
326f059d1e | ||
|
|
3762fb7b2a | ||
|
|
653e70070c | ||
|
|
941f890e46 | ||
|
|
a0bd0bb619 | ||
|
|
be2d976124 | ||
|
|
c48436b0a6 | ||
|
|
c50c3f26c8 | ||
|
|
ae22f4685d | ||
|
|
84694d8a36 | ||
|
|
f73046e652 | ||
|
|
3727bc408b | ||
|
|
69ea1c8e51 | ||
|
|
43fba40076 | ||
|
|
a6f3e09a6c | ||
|
|
7dce71d8fa | ||
|
|
c2f2dc46d9 | ||
|
|
e02dfce152 | ||
|
|
55b4ddc453 | ||
|
|
8d529e1ea9 | ||
|
|
b2f2a409c3 | ||
|
|
aee6ffdbf5 | ||
|
|
da85e59424 | ||
|
|
e6f109de74 | ||
|
|
df1bf82e76 | ||
|
|
f5d8b9df8b | ||
|
|
2c3da65063 | ||
|
|
4e6b3a142b | ||
|
|
976ff9970a | ||
|
|
c5073303ba | ||
|
|
3b81490ac1 | ||
|
|
eb8b19ceb9 | ||
|
|
4f5acc5c29 | ||
|
|
5f6e65f768 | ||
|
|
37b6f84d9f | ||
|
|
f1c3a881f1 | ||
|
|
822d464a6e | ||
|
|
34e5e7de5b | ||
|
|
5068247807 | ||
|
|
0c06bd4271 | ||
|
|
d029c9eca0 | ||
|
|
1d49ee3d57 | ||
|
|
751cbdcaa9 | ||
|
|
d5a2910029 | ||
|
|
3613bed5fa | ||
|
|
19fa7e25d5 | ||
|
|
7accbc198b | ||
|
|
e2df96ea4b |
272
.claude/CLAUDE.md
Normal file
272
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# Amethyst
|
||||
|
||||
## Project Overview
|
||||
|
||||
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
|
||||
over to a Kotlin Multiplatform project. This project has 4 main modules: `quartz`, `commons`,
|
||||
`amethyst` and `desktopApp`. Quartz should contain implementations of Nostr specifications and
|
||||
utilities to help implement them. Commons stores shared code between Amethyst Android (`amethyst`)
|
||||
and Amethyst Desktop (`desktopApp`). The Desktop App is designed to be mouse first and so uses a
|
||||
completely different screen and navigation architecture while sharing the back end components with
|
||||
the android counterpart.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
amethyst/
|
||||
├── quartz/ # Nostr KMP library (protocol only, no UI)
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # Shared Nostr protocol, data models
|
||||
│ ├── androidMain/ # Android-specific (crypto, storage)
|
||||
│ ├── jvmMain/ # Desktop JVM-specific
|
||||
│ └── iosMain/ # iOS-specific
|
||||
├── commons/ # Shared UI components (convert to KMP)
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # Shared composables, icons, state
|
||||
│ ├── androidMain/ # Android-specific UI utilities
|
||||
│ └── jvmMain/ # Desktop-specific UI utilities
|
||||
├── desktopApp/ # Desktop JVM application (layouts, navigation)
|
||||
├── amethyst/ # Android app (layouts, navigation)
|
||||
└── ammolite/ # Support module (unused)
|
||||
```
|
||||
|
||||
**Sharing Philosophy:**
|
||||
- `quartz/` = Nostr business logic, protocol, data (no UI)
|
||||
- `commons/` = Shared UI components, icons, composables, flows and ViewModels
|
||||
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Core** | Quartz (Nostr KMP) |
|
||||
| **UI** | Compose Multiplatform 1.7.x |
|
||||
| **Async** | kotlinx.coroutines + Flow |
|
||||
| **Network** | OkHttp (JVM) |
|
||||
| **Serialization** | Jackson |
|
||||
| **DI** | Manual / Koin |
|
||||
| **Build** | Gradle 8.x, Kotlin 2.1.0 |
|
||||
|
||||
## Skills
|
||||
|
||||
Specialized skills provide domain expertise with bundled resources and patterns:
|
||||
|
||||
| Skill | Expertise | When to Use |
|
||||
|-------|-----------|-------------|
|
||||
| `nostr-expert` | Nostr protocol (Quartz library) | Event types, NIPs, tags, signing, Bech32 |
|
||||
| `kotlin-expert` | Advanced Kotlin patterns | StateFlow, sealed classes, @Immutable, DSLs |
|
||||
| `kotlin-coroutines` | Advanced async patterns | supervisorScope, callbackFlow, relay pools, testing |
|
||||
| `kotlin-multiplatform` | Platform abstraction | expect/actual, source sets, sharing decisions |
|
||||
| `compose-expert` | Shared UI components | Material3, state hoisting, recomposition |
|
||||
| `android-expert` | Android platform | Navigation, permissions, lifecycle, Material3 |
|
||||
| `desktop-expert` | Desktop platform | Window, MenuBar, Tray, keyboard shortcuts |
|
||||
| `gradle-expert` | Build system | Dependencies, versioning, packaging, optimization |
|
||||
|
||||
## Workflow
|
||||
|
||||
**When you ask for a feature:**
|
||||
|
||||
1. **Quick skill assessment** - I identify which skills are relevant
|
||||
2. **Propose which skills** - I present which skills I'll use for the task
|
||||
3. **Get approval** - You review and approve (or adjust) the skill selection
|
||||
4. **Review plan using approved skills** - I invoke the approved skills to create detailed implementation plan
|
||||
5. **Execute with skills** - Skills collaborate to implement the feature
|
||||
|
||||
**Example:**
|
||||
```
|
||||
You: "Add video support to notes"
|
||||
Me: "I'll use:
|
||||
- /nostr-expert (NIP-71 video events)
|
||||
- /compose-expert (video player UI)
|
||||
- /android-expert (platform video APIs)
|
||||
Proceed?"
|
||||
You: "yes"
|
||||
Me: [invokes skills to create plan]
|
||||
"Plan from skills:
|
||||
1. nostr-expert: Use NIP-71 kind 34235 for video events...
|
||||
2. compose-expert: Create VideoPlayer composable in commons...
|
||||
3. android-expert: Use ExoPlayer for Android...
|
||||
Proceed with implementation?"
|
||||
You: "yes"
|
||||
Me: [implements using skill guidance]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `/desktop-run` - Build and run desktop app
|
||||
- `/nip <number>` - Get NIP implementation guidance
|
||||
|
||||
## Feature Workflow
|
||||
|
||||
**CRITICAL: Always check existing implementations first before creating new code!**
|
||||
|
||||
When picking up a new task or feature, follow this process:
|
||||
|
||||
### Step 0: Survey Existing Implementation (MANDATORY)
|
||||
|
||||
**Before writing ANY code, thoroughly audit ALL modules:**
|
||||
|
||||
1. **Search for existing implementations across all modules:**
|
||||
```bash
|
||||
# Search in quartz for protocol/business logic
|
||||
grep -r "class.*Manager\|object.*Cache\|class.*Filter" quartz/src/commonMain/
|
||||
|
||||
# Search in commons for UI components
|
||||
grep -r "@Composable.*Card\|@Composable.*View\|@Composable.*Dialog" commons/src/
|
||||
|
||||
# Search in amethyst for Android patterns
|
||||
grep -r "class.*ViewModel\|class.*Account\|class.*State" amethyst/src/main/java/
|
||||
|
||||
# Search for specific functionality
|
||||
grep -r "fun isFollowing\|fun subscribe\|fun getMetadata" {quartz,commons,amethyst}/src/
|
||||
```
|
||||
|
||||
2. **Understand existing architecture patterns:**
|
||||
- Event stores and caching systems
|
||||
- State management patterns (StateFlow, mutable states)
|
||||
- ViewModel patterns and lifecycle handling
|
||||
- Filter builders and relay subscription patterns
|
||||
- UI component hierarchies
|
||||
|
||||
3. **Key principle:** Most logic already exists! Your job is to:
|
||||
- **Reuse** existing protocol/business logic from quartz
|
||||
- **Extract** shareable UI components AND ViewModels from amethyst to commons
|
||||
- Create **platform-specific** layouts/navigation for Desktop
|
||||
- **NOT** duplicate existing managers, caches, or state systems
|
||||
|
||||
4. **Document findings in implementation plan as a matrix:**
|
||||
|
||||
| File/Component | Status | Location | Action |
|
||||
|----------------|--------|----------|--------|
|
||||
| FilterBuilders | ✅ Exists | quartz/relay/filters/ | Reuse as-is |
|
||||
| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons |
|
||||
| HomeFeedViewModel | 📦 Extract | amethyst/ → commons/commonMain/viewmodels/ | Extract to commons |
|
||||
| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern |
|
||||
|
||||
**Legend:**
|
||||
- ✅ **Reuse** - Exists and can be used directly
|
||||
- 📦 **Extract** - Exists in Android, needs extraction to commons
|
||||
- 🆕 **New** - Doesn't exist, needs creation (platform-specific only)
|
||||
- ⚠️ **Avoid** - Duplicate functionality, use existing pattern instead
|
||||
|
||||
### Step 1: Analyze Android Implementation
|
||||
|
||||
After surveying (Step 0), deeply examine the Android implementation:
|
||||
1. Find the relevant feature/component in `amethyst/` module
|
||||
2. Understand the current implementation patterns
|
||||
3. Identify dependencies and integrations
|
||||
4. Map out what code can be shared vs platform-specific
|
||||
|
||||
### Step 2: Create Implementation Plan
|
||||
|
||||
Before coding, create a plan that categorizes work into three buckets:
|
||||
|
||||
| Category | Description | Location |
|
||||
|----------|-------------|----------|
|
||||
| **Android-Specific** | Platform-native layouts, navigation patterns | `amethyst/`, `androidMain/` |
|
||||
| **Reusable (Shared)** | Business logic, UI components, **ViewModels**, state management | `quartz/commonMain/`, `commons/commonMain/` |
|
||||
| **Desktop-Specific** | Desktop-native layouts, navigation patterns, platform APIs | `desktopApp/`, `jvmMain/` |
|
||||
|
||||
### Step 3: Code Sharing Strategy
|
||||
|
||||
**Share:**
|
||||
- Business logic and data models → `quartz/commonMain/`
|
||||
- Major UI components (cards, lists, dialogs) → `commons/commonMain/`
|
||||
- **ViewModels** (state, business logic) → `commons/commonMain/viewmodels/`
|
||||
- Icons and visual assets → `commons/commonMain/`
|
||||
|
||||
**Keep Platform-Native:**
|
||||
- **Screen composables** (layout, scaffolding) - Desktop uses `Window`, Android uses `Activity`
|
||||
- Navigation patterns (sidebar vs bottom nav)
|
||||
- Platform-specific interactions (gestures, keyboard shortcuts)
|
||||
- System integrations (notifications, file pickers)
|
||||
|
||||
**Rationale:** ViewModels contain platform-agnostic state management (StateFlow/SharedFlow) and business logic. Screens consume ViewModels but render differently (Desktop sidebar + content area vs Android bottom nav).
|
||||
|
||||
### Step 4: Extract Shared Components
|
||||
|
||||
When extracting UI components:
|
||||
1. Identify reusable composables in Android code
|
||||
2. Move to `commons/commonMain/` (consult `/compose-expert` for patterns)
|
||||
3. Create expect/actual declarations for platform-specific behavior (consult `/kotlin-multiplatform`)
|
||||
4. Update both Android and Desktop to use shared component
|
||||
|
||||
**Note:** `quartz/` is protocol-only (no composables). Shared UI goes in `commons/` after converting it to KMP.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Run desktop app
|
||||
./gradlew :desktopApp:run
|
||||
|
||||
# Run Android app
|
||||
./gradlew :amethyst:installDebug
|
||||
|
||||
# Build Quartz for all targets
|
||||
./gradlew :quartz:build
|
||||
|
||||
# Run tests
|
||||
./gradlew test
|
||||
|
||||
# Format code
|
||||
./gradlew spotlessApply
|
||||
```
|
||||
|
||||
## Quartz KMP Structure
|
||||
|
||||
The Quartz library uses expect/actual for platform-specific implementations:
|
||||
|
||||
```kotlin
|
||||
// commonMain - shared protocol logic
|
||||
expect class CryptoProvider {
|
||||
fun sign(message: ByteArray, privateKey: ByteArray): ByteArray
|
||||
fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean
|
||||
}
|
||||
|
||||
// androidMain - uses secp256k1-kmp-jni-android
|
||||
actual class CryptoProvider { /* Android implementation */ }
|
||||
|
||||
// jvmMain - uses secp256k1-kmp-jni-jvm
|
||||
actual class CryptoProvider { /* JVM implementation */ }
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Platform Abstraction
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect fun openExternalUrl(url: String)
|
||||
|
||||
// androidMain
|
||||
actual fun openExternalUrl(url: String) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}
|
||||
|
||||
// jvmMain (Desktop)
|
||||
actual fun openExternalUrl(url: String) {
|
||||
Desktop.getDesktop().browse(URI(url))
|
||||
}
|
||||
```
|
||||
|
||||
## Code Formatting
|
||||
After completing any task that modifies Kotlin files, always run:
|
||||
```
|
||||
./gradlew spotlessApply
|
||||
```
|
||||
Do this before considering the task complete.
|
||||
|
||||
### Navigation Shell
|
||||
- **Desktop**: Sidebar + main content area
|
||||
- **Android**: Bottom navigation
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- Branch: `feat/desktop-<feature>` or `fix/desktop-<issue>`
|
||||
- Commits: Conventional commits (`feat:`, `fix:`, etc.)
|
||||
- Never use `--no-verify`
|
||||
|
||||
## Resources
|
||||
|
||||
- [Nostr NIPs](https://github.com/nostr-protocol/nips)
|
||||
- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
|
||||
- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html)
|
||||
45
.claude/commands/desktop-run.md
Normal file
45
.claude/commands/desktop-run.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: Build and run the desktop app
|
||||
---
|
||||
|
||||
Build and run the Amethyst Desktop application:
|
||||
|
||||
```bash
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the build fails, check:
|
||||
|
||||
1. **JDK Version**: Requires JDK 17+
|
||||
```bash
|
||||
java -version
|
||||
```
|
||||
|
||||
2. **Compose Multiplatform Plugin**: Verify version in `gradle/libs.versions.toml`
|
||||
|
||||
3. **Quartz Build**: Ensure Quartz compiles first
|
||||
```bash
|
||||
./gradlew :quartz:build
|
||||
```
|
||||
|
||||
4. **Desktop Dependencies**: Check `desktopApp/build.gradle.kts` has:
|
||||
```kotlin
|
||||
implementation(compose.desktop.currentOs)
|
||||
```
|
||||
|
||||
## Creating Distributable
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
./gradlew :desktopApp:packageDmg
|
||||
|
||||
# Windows
|
||||
./gradlew :desktopApp:packageMsi
|
||||
|
||||
# Linux
|
||||
./gradlew :desktopApp:packageDeb
|
||||
```
|
||||
|
||||
Outputs will be in `desktopApp/build/compose/binaries/`
|
||||
50
.claude/commands/extract.md
Normal file
50
.claude/commands/extract.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
description: Extract a composable from amethyst to shared code
|
||||
---
|
||||
|
||||
Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
|
||||
|
||||
## Process
|
||||
|
||||
1. **Locate the component** in the amethyst module:
|
||||
```bash
|
||||
find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*"
|
||||
grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/
|
||||
```
|
||||
|
||||
2. **Analyze dependencies**:
|
||||
- Android-specific imports (Context, Intent, etc.)
|
||||
- Platform APIs (Camera, MediaStore, etc.)
|
||||
- Android Compose specifics vs standard Compose
|
||||
|
||||
3. **Identify what can be shared**:
|
||||
- Pure Composable functions → `shared-ui/commonMain/`
|
||||
- Business logic → `quartz/commonMain/`
|
||||
- Platform-specific → create expect/actual
|
||||
|
||||
4. **Create shared version**:
|
||||
- Move to appropriate shared module
|
||||
- Replace Android imports with multiplatform alternatives
|
||||
- Add expect declarations for platform-specific parts
|
||||
|
||||
5. **Update references**:
|
||||
- Change imports in amethyst module
|
||||
- Add implementations in desktopApp if needed
|
||||
|
||||
## Common Replacements
|
||||
|
||||
| Android | Multiplatform |
|
||||
|---------|---------------|
|
||||
| `LocalContext.current` | expect/actual or parameter |
|
||||
| `stringResource()` | `Res.string.*` |
|
||||
| `painterResource()` | `painterResource(Res.drawable.*)` |
|
||||
| `Toast.makeText()` | Custom snackbar/notification |
|
||||
| `Intent` | expect/actual for navigation |
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
/extract NoteCard
|
||||
```
|
||||
|
||||
This will find NoteCard, analyze its dependencies, and guide you through extracting it to shared code.
|
||||
32
.claude/commands/nip.md
Normal file
32
.claude/commands/nip.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: Get NIP specification and implementation guidance
|
||||
---
|
||||
|
||||
Fetch and explain NIP-$ARGUMENTS from the Nostr protocol:
|
||||
|
||||
1. **Get the specification** from https://github.com/nostr-protocol/nips/blob/master/$ARGUMENTS.md
|
||||
|
||||
2. **Show key details**:
|
||||
- Event kind(s) used
|
||||
- Required and optional fields
|
||||
- Tag structure
|
||||
- Message flow between client and relay
|
||||
|
||||
3. **Check implementation status** in Quartz:
|
||||
```bash
|
||||
grep -r "NIP-$ARGUMENTS\|nip$ARGUMENTS\|kind.*=" quartz/src/
|
||||
```
|
||||
|
||||
4. **Provide implementation guidance**:
|
||||
- Which Quartz classes to use or create
|
||||
- Event construction example
|
||||
- Relay subscription filters
|
||||
- Verification/validation logic
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/nip 01 # Basic protocol
|
||||
/nip 44 # Versioned encryption
|
||||
/nip 57 # Zaps
|
||||
```
|
||||
322
.claude/core-skills-plan.md
Normal file
322
.claude/core-skills-plan.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# AmethystMultiplatform Skills Creation Plan
|
||||
|
||||
## Overview
|
||||
Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns.
|
||||
|
||||
**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase.
|
||||
|
||||
## Skills to Implement
|
||||
|
||||
### 1. kotlin-multiplatform ✅ COMPLETED
|
||||
**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Mental model: KMP hierarchy as dependency graph
|
||||
- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain}
|
||||
- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149)
|
||||
- expect/actual mechanics with 24+ examples from codebase
|
||||
- iOS framework setup for Quartz distribution
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/source-set-hierarchy.md` - Visual diagram + examples
|
||||
- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns
|
||||
- `scripts/validate-kmp-structure.sh` - Verify source set dependencies
|
||||
- `assets/kmp-hierarchy-diagram.png` - Visual graph
|
||||
|
||||
**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples.
|
||||
|
||||
**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/`
|
||||
|
||||
---
|
||||
|
||||
### 2. gradle-expert ✅ COMPLETED
|
||||
**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Build architecture: 4 modules, dependency flow
|
||||
- Version catalog mastery (libs.versions.toml)
|
||||
- Module dependency patterns (api vs implementation)
|
||||
- Android-specific: compileSdk, proguard
|
||||
- Desktop packaging: TargetFormat, distributions
|
||||
- Build performance: daemon, parallel, caching
|
||||
- Common errors: compose version conflicts, secp256k1 JNI variants
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/build-commands.md` - Common gradle tasks
|
||||
- `references/dependency-graph.md` - Module visualization
|
||||
- `references/version-catalog-guide.md` - Version catalog patterns
|
||||
- `references/common-errors.md` - Troubleshooting guide
|
||||
- `scripts/analyze-build-time.sh` - Performance report
|
||||
- `scripts/fix-dependency-conflicts.sh` - Conflict patterns
|
||||
|
||||
**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts).
|
||||
|
||||
**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/`
|
||||
|
||||
---
|
||||
|
||||
### 3. kotlin-expert ✅ DRAFT COMPLETE
|
||||
**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager)
|
||||
- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult)
|
||||
- Immutability: @Immutable for Compose performance (173+ event classes)
|
||||
- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder)
|
||||
- Inline functions: reified generics, performance optimization (OptimizedJsonMapper)
|
||||
- Value classes: Zero-cost wrappers (optimization opportunity)
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns
|
||||
- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns
|
||||
- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns
|
||||
- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set
|
||||
|
||||
**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders).
|
||||
|
||||
**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/`
|
||||
|
||||
**10-Step Progress:**
|
||||
1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline)
|
||||
2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder
|
||||
3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025
|
||||
4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf)
|
||||
5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability)
|
||||
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
|
||||
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
|
||||
8. ⏸️ TEST - Deferred to later (requires real usage scenarios)
|
||||
9. ⏸️ FINALIZE - Deferred to later
|
||||
10. ✅ DOCUMENT - Updated plan
|
||||
|
||||
---
|
||||
|
||||
### 4. compose-expert ✅ COMPLETED
|
||||
**Focus:** Shared composables, state management, animations, Material3
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Shared composables philosophy (100+ already shared in commons/commonMain)
|
||||
- State management: remember, derivedStateOf, produceState (visual patterns)
|
||||
- Recomposition optimization: @Stable/@Immutable (visual usage)
|
||||
- Material3 conventions: theming
|
||||
- Custom icons: ImageVector builders (robohash pattern)
|
||||
- Platform differences: Desktop vs Android UI
|
||||
- Performance: lazy lists, image loading
|
||||
- Decision framework: share by default in commonMain
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/shared-composables-catalog.md` - Complete catalog with patterns
|
||||
- `references/state-patterns.md` - State hoisting, derivedStateOf examples
|
||||
- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL
|
||||
- `scripts/find-composables.sh` - Grep @Composable utility
|
||||
|
||||
**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert.
|
||||
|
||||
**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/`
|
||||
|
||||
---
|
||||
|
||||
### 5. ios-expert
|
||||
**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation
|
||||
|
||||
**SKILL.md sections:**
|
||||
- iOS source sets: iosMain, iosArm64Main
|
||||
- Swift interop: type mapping, nullability
|
||||
- expect/actual iOS: 10+ examples from quartz/iosMain
|
||||
- XCFramework setup: baseName = "quartz-kmpKit"
|
||||
- Platform APIs: platform.posix, CFNetwork, Security
|
||||
- CocoaPods integration
|
||||
- XCode project setup
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/ios-actual-implementations.md` - 10 iosMain actuals
|
||||
- `references/swift-interop-guide.md` - Type mapping
|
||||
- `references/xcode-integration.md` - XCode setup
|
||||
- `scripts/generate-xcframework.sh` - Build all iOS targets
|
||||
|
||||
**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup.
|
||||
|
||||
---
|
||||
|
||||
### 6. desktop-expert ✅ DRAFT COMPLETE
|
||||
**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Desktop entry point: application {} DSL
|
||||
- Window management: WindowState, positioning, multi-window
|
||||
- Menu system: MenuBar, keyboard shortcuts (OS-aware)
|
||||
- System tray: minimize to tray
|
||||
- Desktop navigation: NavigationRail pattern (vs Android bottom nav)
|
||||
- File system: Desktop.getDesktop(), file pickers, drag-drop
|
||||
- Desktop UX principles: keyboard-first, native feel, tooltips
|
||||
- OS-specific behavior: macOS vs Windows vs Linux
|
||||
- Platform detection: PlatformDetector utility
|
||||
- Packaging: DMG, MSI, DEB distribution
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.)
|
||||
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
|
||||
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper
|
||||
- `references/os-detection.md` - Platform detection, file paths, system integration
|
||||
|
||||
**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert.
|
||||
|
||||
**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/`
|
||||
|
||||
**10-Step Progress:**
|
||||
1. ✅ UNDERSTAND - Defined desktop usage scenarios
|
||||
2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt)
|
||||
3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG)
|
||||
4. ✅ SYNTHESIZE - Extracted desktop principles from codebase
|
||||
5. ✅ DRAFT - Created SKILL.md + 4 reference files
|
||||
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
|
||||
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
|
||||
8. ⏸️ TEST - Deferred to later (requires real desktop scenarios)
|
||||
9. ⏸️ FINALIZE - Deferred to later
|
||||
10. ✅ DOCUMENT - Updated plan
|
||||
|
||||
---
|
||||
|
||||
### 7. android-expert ✅ DRAFT COMPLETE
|
||||
**Focus:** Android platform APIs, navigation, permissions, Material Design
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Android module structure: amethyst/ layout
|
||||
- Navigation: Navigation Compose, bottom nav
|
||||
- Permissions: runtime (camera, biometric)
|
||||
- Platform APIs: Intent, Context, ContentResolver
|
||||
- Lifecycle: Lifecycle-aware, ViewModel
|
||||
- Material Design: Android Material 3
|
||||
- Build config: Proguard, R8
|
||||
- Android UX: mobile-first patterns
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/android-navigation.md` - Navigation Compose
|
||||
- `references/android-permissions.md` - Permission handling
|
||||
- `references/proguard-rules.md` - Proguard explanation
|
||||
- `scripts/analyze-apk-size.sh` - APK optimization
|
||||
|
||||
**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions.
|
||||
|
||||
**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/`
|
||||
|
||||
**10-Step Progress:**
|
||||
1. ✅ UNDERSTAND - Defined Android usage scenarios
|
||||
2. ✅ EXPLORE - Analyzed amethyst/ module patterns
|
||||
3. ✅ RESEARCH - Android best practices + KMP Android patterns
|
||||
4. ✅ SYNTHESIZE - Extracted Android principles from codebase
|
||||
5. ✅ DRAFT - Initialized skill, created resources
|
||||
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
|
||||
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
|
||||
8. ⏸️ TEST - Deferred to later
|
||||
9. ⏸️ FINALIZE - Deferred to later
|
||||
10. ✅ DOCUMENT - Updated plan
|
||||
|
||||
---
|
||||
|
||||
### 8. nostr-expert ✅ COMPLETED
|
||||
**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns
|
||||
|
||||
**SKILL.md sections:**
|
||||
- Quartz architecture: package structure by NIP (57 NIPs implemented)
|
||||
- Event anatomy: IEvent, Event, kinds, tags
|
||||
- EventTemplate & TagArrayBuilder DSL patterns
|
||||
- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events
|
||||
- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders
|
||||
- Threading (NIP-10): reply/root markers
|
||||
- Cryptography: secp256k1 signing, NIP-44 encryption
|
||||
- Bech32 encoding: npub, nsec, note, nevent
|
||||
- Event validation & verification
|
||||
- Common workflows: publishing, querying, zaps, gift-wrapped DMs
|
||||
|
||||
**Bundled resources:**
|
||||
- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines)
|
||||
- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines)
|
||||
- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines)
|
||||
- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term
|
||||
|
||||
**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase.
|
||||
|
||||
**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Workflow
|
||||
|
||||
Using skill-creator 10-step methodology per skill:
|
||||
|
||||
**Overall Plan:**
|
||||
1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained
|
||||
2. **EXPLORE** ✅ - Codebase analyzed via Explore agent
|
||||
3. **RESEARCH** ✅ - Domain patterns identified via Plan agent
|
||||
4. **SYNTHESIZE** ✅ - Skills designed above
|
||||
|
||||
**Per-Skill Implementation:**
|
||||
- kotlin-multiplatform: ✅ COMPLETED
|
||||
- gradle-expert: ✅ COMPLETED
|
||||
- kotlin-expert: ✅ COMPLETED
|
||||
- compose-expert: ✅ COMPLETED
|
||||
- desktop-expert: ✅ COMPLETED
|
||||
- android-expert: ✅ COMPLETED
|
||||
- nostr-expert: ✅ COMPLETED
|
||||
- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform)
|
||||
|
||||
## Critical Files Referenced
|
||||
|
||||
**Build patterns:**
|
||||
- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set
|
||||
- `/commons/build.gradle.kts` - Shared UI setup
|
||||
|
||||
**Code patterns:**
|
||||
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure
|
||||
- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern
|
||||
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual
|
||||
|
||||
**Documentation:**
|
||||
- `/docs/shared-ui-analysis.md` - UI migration strategy
|
||||
|
||||
## Output Location
|
||||
`.claude/skills/<skill-name>/` for each skill
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference
|
||||
2. ✅ Completed kotlin-multiplatform skill
|
||||
3. ✅ Completed gradle-expert skill
|
||||
4. ✅ Completed kotlin-expert skill
|
||||
5. ✅ Completed compose-expert skill
|
||||
6. ✅ Completed desktop-expert skill
|
||||
7. ✅ Completed android-expert skill
|
||||
8. ✅ Completed nostr-expert skill
|
||||
9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase)
|
||||
|
||||
## Current Status: 7/8 Skills Completed
|
||||
|
||||
**Completed Skills (Auto-loaded from `.claude/skills/`):**
|
||||
1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual)
|
||||
2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting)
|
||||
3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders)
|
||||
4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector)
|
||||
5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs)
|
||||
6. ✅ android-expert (Android platform APIs, navigation, permissions)
|
||||
7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags)
|
||||
|
||||
**Deferred:**
|
||||
- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform)
|
||||
|
||||
## Skill Loading
|
||||
|
||||
**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory.
|
||||
|
||||
To manually verify skills are loaded:
|
||||
```bash
|
||||
ls -1 .claude/skills/
|
||||
```
|
||||
|
||||
Should show:
|
||||
- android-expert/
|
||||
- compose-expert/
|
||||
- desktop-expert/
|
||||
- gradle-expert/
|
||||
- kotlin-expert/
|
||||
- kotlin-multiplatform/
|
||||
- nostr-expert/
|
||||
180
.claude/hooks/session-start.sh
Executable file
180
.claude/hooks/session-start.sh
Executable file
@@ -0,0 +1,180 @@
|
||||
#!/bin/bash
|
||||
# Session start hook: Configure proxy auth, SSL trust, and Android SDK for Claude Code on the web
|
||||
set -euo pipefail
|
||||
|
||||
# Only run in remote (web) environments
|
||||
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Proxy credentials: configure Maven/Gradle if authenticated proxy is set ---
|
||||
proxy="${https_proxy:-${HTTPS_PROXY:-}}"
|
||||
if [ -n "$proxy" ] && echo "$proxy" | grep -q '@'; then
|
||||
rest="${proxy#*://}"
|
||||
userpass="${rest%@*}"
|
||||
hostport="${rest##*@}"
|
||||
user="${userpass%%:*}"
|
||||
pass="${userpass#*:}"
|
||||
host="${hostport%%:*}"
|
||||
port="${hostport##*:}"
|
||||
port="${port%/}"
|
||||
|
||||
mkdir -p ~/.m2
|
||||
cat > ~/.m2/settings.xml << EOF
|
||||
<settings>
|
||||
<proxies>
|
||||
<proxy>
|
||||
<id>ccw</id><active>true</active><protocol>https</protocol>
|
||||
<host>$host</host><port>$port</port>
|
||||
<username>$user</username>
|
||||
<password><![CDATA[$pass]]></password>
|
||||
</proxy>
|
||||
</proxies>
|
||||
</settings>
|
||||
EOF
|
||||
|
||||
# Force wagon transport for Maven 3.9+ proxy auth compatibility
|
||||
cat > ~/.mavenrc << 'MAVENRC'
|
||||
MAVEN_OPTS="$MAVEN_OPTS -Dmaven.resolver.transport=wagon"
|
||||
MAVENRC
|
||||
|
||||
mkdir -p ~/.gradle
|
||||
cat > ~/.gradle/gradle.properties << EOF
|
||||
systemProp.https.proxyHost=$host
|
||||
systemProp.https.proxyPort=$port
|
||||
systemProp.https.proxyUser=$user
|
||||
systemProp.https.proxyPassword=$pass
|
||||
systemProp.http.proxyHost=$host
|
||||
systemProp.http.proxyPort=$port
|
||||
systemProp.http.proxyUser=$user
|
||||
systemProp.http.proxyPassword=$pass
|
||||
# Override nonProxyHosts: route all external traffic (incl. *.google.com) through proxy
|
||||
systemProp.http.nonProxyHosts=localhost|127.0.0.1
|
||||
systemProp.https.nonProxyHosts=localhost|127.0.0.1
|
||||
# Use Ubuntu's Java trust store (includes Anthropic TLS inspection CA) for all Gradle JVMs.
|
||||
# This is needed because Gradle may download a custom JDK (e.g. JetBrains) whose bundled
|
||||
# trust store doesn't include the Anthropic CA, causing TLS inspection failures.
|
||||
systemProp.javax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts
|
||||
systemProp.javax.net.ssl.trustStoreType=JKS
|
||||
systemProp.javax.net.ssl.trustStorePassword=changeit
|
||||
systemProp.jdk.http.auth.tunneling.disabledSchemes=
|
||||
systemProp.jdk.http.auth.proxying.disabledSchemes=
|
||||
EOF
|
||||
|
||||
echo "Configured Maven/Gradle proxy from HTTPS_PROXY" >&2
|
||||
fi
|
||||
|
||||
# --- SSL trust: import Anthropic TLS inspection CA into JVM trust stores ---
|
||||
ANTHROPIC_CA_PEM=$(python3 -c "
|
||||
import re, ssl, sys
|
||||
try:
|
||||
with open('/etc/ssl/certs/ca-certificates.crt') as f:
|
||||
certs = re.findall(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', f.read(), re.DOTALL)
|
||||
for cert in certs:
|
||||
der = ssl.PEM_cert_to_DER_cert(cert)
|
||||
if b'Anthropic' in der and b'sandbox-egress-production' in der:
|
||||
print(cert)
|
||||
break
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'CA extraction failed: {e}\n')
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -n "$ANTHROPIC_CA_PEM" ]; then
|
||||
TMPCA=$(mktemp /tmp/anthropic-ca.XXXXXX.pem)
|
||||
echo "$ANTHROPIC_CA_PEM" > "$TMPCA"
|
||||
for cacerts in \
|
||||
/usr/lib/jvm/java-21-openjdk-amd64/lib/security/cacerts \
|
||||
/root/.gradle/jdks/*/lib/security/cacerts; do
|
||||
[ -f "$cacerts" ] || continue
|
||||
keytool -list -keystore "$cacerts" -storepass changeit \
|
||||
-alias anthropic-egress-production-ca >/dev/null 2>&1 && continue
|
||||
keytool -import \
|
||||
-alias anthropic-egress-production-ca \
|
||||
-file "$TMPCA" \
|
||||
-keystore "$cacerts" \
|
||||
-storepass changeit \
|
||||
-noprompt >/dev/null 2>&1 && \
|
||||
echo "Imported Anthropic CA into $cacerts" >&2
|
||||
done
|
||||
rm -f "$TMPCA"
|
||||
fi
|
||||
|
||||
ANDROID_SDK_DIR="/root/android-sdk"
|
||||
SDK_REPO_BASE="https://dl.google.com/android/repository"
|
||||
|
||||
# Install Android SDK packages by downloading directly with curl
|
||||
# (sdkmanager cannot reach the SDK repository through the proxy)
|
||||
install_sdk_package() {
|
||||
local zip_url="$1"
|
||||
local dest_dir="$2"
|
||||
local inner_dir="$3" # top-level dir inside the zip
|
||||
|
||||
if [ -d "$dest_dir" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Downloading $zip_url..."
|
||||
local TMP_ZIP
|
||||
TMP_ZIP=$(mktemp /tmp/sdk-pkg.XXXXXX.zip)
|
||||
curl -fsSL "$zip_url" -o "$TMP_ZIP"
|
||||
|
||||
local TMP_DIR
|
||||
TMP_DIR=$(mktemp -d)
|
||||
unzip -q "$TMP_ZIP" -d "$TMP_DIR"
|
||||
rm -f "$TMP_ZIP"
|
||||
|
||||
mkdir -p "$(dirname "$dest_dir")"
|
||||
mv "$TMP_DIR/$inner_dir" "$dest_dir"
|
||||
rm -rf "$TMP_DIR"
|
||||
echo "Installed to $dest_dir"
|
||||
}
|
||||
|
||||
# Install Android platform 36
|
||||
install_sdk_package \
|
||||
"$SDK_REPO_BASE/platform-36_r02.zip" \
|
||||
"$ANDROID_SDK_DIR/platforms/android-36" \
|
||||
"android-36"
|
||||
|
||||
# Install build-tools 36.0.0 (zip uses "android-16" as inner dir name)
|
||||
install_sdk_package \
|
||||
"$SDK_REPO_BASE/build-tools_r36_linux.zip" \
|
||||
"$ANDROID_SDK_DIR/build-tools/36.0.0" \
|
||||
"android-16"
|
||||
|
||||
# Install platform-tools
|
||||
install_sdk_package \
|
||||
"$SDK_REPO_BASE/platform-tools_r37.0.0-linux.zip" \
|
||||
"$ANDROID_SDK_DIR/platform-tools" \
|
||||
"platform-tools"
|
||||
|
||||
# Accept SDK licenses (create license files manually)
|
||||
echo "Writing SDK license files..."
|
||||
mkdir -p "$ANDROID_SDK_DIR/licenses"
|
||||
# android-sdk-license
|
||||
echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_DIR/licenses/android-sdk-license"
|
||||
echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" >> "$ANDROID_SDK_DIR/licenses/android-sdk-license"
|
||||
# android-sdk-preview-license
|
||||
echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license"
|
||||
echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license"
|
||||
# intel-android-extra-license
|
||||
echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license"
|
||||
|
||||
# Create local.properties if missing
|
||||
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")"
|
||||
LOCAL_PROPS="$REPO_ROOT/local.properties"
|
||||
if [ ! -f "$LOCAL_PROPS" ]; then
|
||||
echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS"
|
||||
echo "Created local.properties with sdk.dir=$ANDROID_SDK_DIR"
|
||||
fi
|
||||
|
||||
# Export ANDROID_HOME for the session
|
||||
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
|
||||
echo "export ANDROID_HOME=$ANDROID_SDK_DIR" >> "$CLAUDE_ENV_FILE"
|
||||
echo "export ANDROID_SDK_ROOT=$ANDROID_SDK_DIR" >> "$CLAUDE_ENV_FILE"
|
||||
echo "export PATH=\$PATH:$ANDROID_SDK_DIR/platform-tools" >> "$CLAUDE_ENV_FILE"
|
||||
fi
|
||||
|
||||
cd "$CLAUDE_PROJECT_DIR"
|
||||
./gradlew --version > /dev/null 2>&1
|
||||
|
||||
echo "Android SDK setup complete."
|
||||
26
.claude/settings.json
Normal file
26
.claude/settings.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "./gradlew spotlessApply 2>/dev/null || spotless-apply",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1066
.claude/skills/android-expert/SKILL.md
Normal file
1066
.claude/skills/android-expert/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
615
.claude/skills/android-expert/references/android-navigation.md
Normal file
615
.claude/skills/android-expert/references/android-navigation.md
Normal file
@@ -0,0 +1,615 @@
|
||||
# Android Navigation Patterns
|
||||
|
||||
Complete navigation implementation patterns for Amethyst Android app using Navigation Compose with type safety.
|
||||
|
||||
## Type-Safe Routes (Navigation 2.8.0+)
|
||||
|
||||
### Route Definitions
|
||||
|
||||
```kotlin
|
||||
// Routes.kt - All 40+ routes in Amethyst
|
||||
@Serializable
|
||||
sealed class Route {
|
||||
// Bottom nav routes
|
||||
@Serializable object Home : Route()
|
||||
@Serializable object Messages : Route()
|
||||
@Serializable object Video : Route()
|
||||
@Serializable object Discover : Route()
|
||||
@Serializable object Notification : Route()
|
||||
|
||||
// Content routes with parameters
|
||||
@Serializable data class Profile(val pubkey: String) : Route()
|
||||
@Serializable data class Note(val id: String) : Route()
|
||||
@Serializable data class Channel(val id: String) : Route()
|
||||
@Serializable data class Thread(
|
||||
val id: String,
|
||||
val replyTo: String? = null
|
||||
) : Route()
|
||||
|
||||
// New content routes
|
||||
@Serializable data class NewPost(
|
||||
val message: String? = null,
|
||||
val attachment: String? = null,
|
||||
val replyTo: String? = null
|
||||
) : Route()
|
||||
|
||||
// Settings
|
||||
@Serializable object Settings : Route()
|
||||
@Serializable object Security : Route()
|
||||
@Serializable object Relays : Route()
|
||||
|
||||
// Search
|
||||
@Serializable data class Search(val query: String = "") : Route()
|
||||
|
||||
// Media
|
||||
@Serializable data class Image(val url: String) : Route()
|
||||
@Serializable data class Video(val url: String) : Route()
|
||||
}
|
||||
```
|
||||
|
||||
## NavHost Configuration
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
accountViewModel: AccountViewModel,
|
||||
drawerState: DrawerState
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val nav = remember {
|
||||
Nav(navController, drawerState, scope)
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Route.Home,
|
||||
enterTransition = { fadeIn(animationSpec = tween(200)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(200)) },
|
||||
popEnterTransition = { fadeIn(animationSpec = tween(200)) },
|
||||
popExitTransition = { fadeOut(animationSpec = tween(200)) }
|
||||
) {
|
||||
// Define routes
|
||||
composable<Route.Home> {
|
||||
HomeScreen(accountViewModel, nav)
|
||||
}
|
||||
|
||||
composable<Route.Profile> { backStackEntry ->
|
||||
val profile = backStackEntry.toRoute<Route.Profile>()
|
||||
ProfileScreen(
|
||||
pubkey = profile.pubkey,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
composable<Route.Note> { backStackEntry ->
|
||||
val note = backStackEntry.toRoute<Route.Note>()
|
||||
NoteScreen(
|
||||
noteId = note.id,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
composable<Route.NewPost> { backStackEntry ->
|
||||
val newPost = backStackEntry.toRoute<Route.NewPost>()
|
||||
NewPostScreen(
|
||||
initialMessage = newPost.message,
|
||||
initialAttachment = newPost.attachment,
|
||||
replyTo = newPost.replyTo,
|
||||
accountViewModel = accountViewModel,
|
||||
onPost = { nav.popBack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Transitions
|
||||
|
||||
```kotlin
|
||||
composable<Route.Profile>(
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Start,
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Start,
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.End,
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.End,
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
}
|
||||
) { backStackEntry ->
|
||||
val profile = backStackEntry.toRoute<Route.Profile>()
|
||||
ProfileScreen(profile.pubkey, accountViewModel, nav)
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Manager
|
||||
|
||||
### Nav Wrapper Class
|
||||
|
||||
```kotlin
|
||||
class Nav(
|
||||
val controller: NavHostController,
|
||||
val drawerState: DrawerState,
|
||||
val scope: CoroutineScope
|
||||
) {
|
||||
/**
|
||||
* Navigate to a route, closing drawer if open
|
||||
*/
|
||||
fun nav(route: Route) {
|
||||
scope.launch {
|
||||
if (!controller.popBackStack(route, inclusive = false)) {
|
||||
controller.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate with new stack (clear back stack to Home)
|
||||
*/
|
||||
fun newStack(route: Route) {
|
||||
scope.launch {
|
||||
controller.navigate(route) {
|
||||
popUpTo(Route.Home) {
|
||||
inclusive = false
|
||||
}
|
||||
launchSingleTop = true
|
||||
}
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop back stack
|
||||
*/
|
||||
fun popBack() {
|
||||
controller.popBackStack()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop up to specific route
|
||||
*/
|
||||
inline fun <reified T : Route> popUpTo(inclusive: Boolean = false) {
|
||||
controller.popBackStack<T>(inclusive = inclusive)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current route
|
||||
*/
|
||||
fun currentRoute(): Route? {
|
||||
return controller.currentBackStackEntry?.toRoute<Route>()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Bottom Navigation
|
||||
|
||||
### Material3 NavigationBar
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AppBottomBar(
|
||||
currentRoute: Route?,
|
||||
nav: Nav
|
||||
) {
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
BottomBarRoute.entries.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute?.let { it::class == item.route::class } ?: false,
|
||||
onClick = { nav.nav(item.route) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = if (currentRoute?.let { it::class == item.route::class } == true) {
|
||||
item.selectedIcon
|
||||
} else {
|
||||
item.unselectedIcon
|
||||
},
|
||||
contentDescription = item.label
|
||||
)
|
||||
},
|
||||
label = { Text(item.label) },
|
||||
alwaysShowLabel = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class BottomBarRoute(
|
||||
val route: Route,
|
||||
val selectedIcon: ImageVector,
|
||||
val unselectedIcon: ImageVector,
|
||||
val label: String
|
||||
) {
|
||||
HOME(
|
||||
route = Route.Home,
|
||||
selectedIcon = Icons.Filled.Home,
|
||||
unselectedIcon = Icons.Outlined.Home,
|
||||
label = "Home"
|
||||
),
|
||||
MESSAGES(
|
||||
route = Route.Messages,
|
||||
selectedIcon = Icons.Filled.Message,
|
||||
unselectedIcon = Icons.Outlined.Message,
|
||||
label = "Messages"
|
||||
),
|
||||
VIDEOS(
|
||||
route = Route.Video,
|
||||
selectedIcon = Icons.Filled.VideoLibrary,
|
||||
unselectedIcon = Icons.Outlined.VideoLibrary,
|
||||
label = "Videos"
|
||||
),
|
||||
DISCOVER(
|
||||
route = Route.Discover,
|
||||
selectedIcon = Icons.Filled.Explore,
|
||||
unselectedIcon = Icons.Outlined.Explore,
|
||||
label = "Discover"
|
||||
),
|
||||
NOTIFICATIONS(
|
||||
route = Route.Notification,
|
||||
selectedIcon = Icons.Filled.Notifications,
|
||||
unselectedIcon = Icons.Outlined.Notifications,
|
||||
label = "Notifications"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Observing Current Route
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val navController = rememberNavController()
|
||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = currentBackStackEntry?.toRoute<Route>()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (shouldShowTopBar(currentRoute)) {
|
||||
AppTopBar(currentRoute)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
if (shouldShowBottomBar(currentRoute)) {
|
||||
AppBottomBar(currentRoute, nav)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
AppNavigation(
|
||||
navController = navController,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldShowBottomBar(route: Route?): Boolean {
|
||||
return when (route) {
|
||||
is Route.Home,
|
||||
is Route.Messages,
|
||||
is Route.Video,
|
||||
is Route.Discover,
|
||||
is Route.Notification -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Drawer
|
||||
|
||||
### Material3 ModalDrawerSheet
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AppDrawer(
|
||||
drawerState: DrawerState,
|
||||
nav: Nav,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
ModalDrawerSheet {
|
||||
// User profile header
|
||||
DrawerHeader(accountViewModel.account)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Menu items
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Home") },
|
||||
selected = false,
|
||||
onClick = { nav.nav(Route.Home) },
|
||||
icon = { Icon(Icons.Default.Home, "Home") }
|
||||
)
|
||||
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Profile") },
|
||||
selected = false,
|
||||
onClick = { nav.nav(Route.Profile(accountViewModel.account.pubkey)) },
|
||||
icon = { Icon(Icons.Default.Person, "Profile") }
|
||||
)
|
||||
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Settings") },
|
||||
selected = false,
|
||||
onClick = { nav.nav(Route.Settings) },
|
||||
icon = { Icon(Icons.Default.Settings, "Settings") }
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Logout") },
|
||||
selected = false,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
accountViewModel.logout()
|
||||
drawerState.close()
|
||||
}
|
||||
},
|
||||
icon = { Icon(Icons.Default.Logout, "Logout") }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Main Scaffold with Drawer
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val nav = remember { Nav(navController, drawerState, scope) }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
AppDrawer(drawerState, nav, accountViewModel)
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Amethyst") },
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { scope.launch { drawerState.open() } }
|
||||
) {
|
||||
Icon(Icons.Default.Menu, "Menu")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = { AppBottomBar(currentRoute, nav) }
|
||||
) { paddingValues ->
|
||||
AppNavigation(
|
||||
navController = navController,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deep Link Handling
|
||||
|
||||
### Intent Processing
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val activity = LocalContext.current as? Activity
|
||||
|
||||
// Handle incoming intents
|
||||
LaunchedEffect(activity?.intent) {
|
||||
activity?.intent?.let { intent ->
|
||||
handleIntent(intent, navController)
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(navController = navController) {
|
||||
// Routes...
|
||||
}
|
||||
}
|
||||
|
||||
fun handleIntent(intent: Intent, navController: NavHostController) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
// Share text/image
|
||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val sharedUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
|
||||
navController.navigate(
|
||||
Route.NewPost(
|
||||
message = sharedText,
|
||||
attachment = sharedUri?.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Intent.ACTION_VIEW -> {
|
||||
// Deep link
|
||||
intent.data?.let { uri ->
|
||||
when (uri.scheme) {
|
||||
"nostr" -> handleNostrUri(uri, navController)
|
||||
"https", "http" -> handleWebUri(uri, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleNostrUri(uri: Uri, navController: NavHostController) {
|
||||
val path = uri.pathSegments.firstOrNull() ?: return
|
||||
|
||||
when {
|
||||
path.startsWith("npub") -> {
|
||||
navController.navigate(Route.Profile(path))
|
||||
}
|
||||
path.startsWith("note") -> {
|
||||
navController.navigate(Route.Note(path))
|
||||
}
|
||||
path.startsWith("nevent") -> {
|
||||
// Decode and navigate to event
|
||||
val eventId = decodeNevent(path)
|
||||
navController.navigate(Route.Note(eventId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleWebUri(uri: Uri, navController: NavHostController) {
|
||||
// Handle web-based deep links
|
||||
// https://njump.me/npub1...
|
||||
// https://primal.net/profile/npub1...
|
||||
when (uri.host) {
|
||||
"njump.me" -> {
|
||||
val id = uri.pathSegments.lastOrNull()
|
||||
if (id?.startsWith("npub") == true) {
|
||||
navController.navigate(Route.Profile(id))
|
||||
}
|
||||
}
|
||||
"primal.net" -> {
|
||||
// Parse primal.net URLs
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AndroidManifest Intent Filters
|
||||
|
||||
```xml
|
||||
<!-- MainActivity -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="njump.me" />
|
||||
<data android:scheme="https" android:host="primal.net" />
|
||||
<data android:scheme="https" android:host="iris.to" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
```
|
||||
|
||||
## Nested Navigation
|
||||
|
||||
### Tab Navigation Inside Screen
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
pubkey: String,
|
||||
nav: Nav
|
||||
) {
|
||||
val nestedNavController = rememberNavController()
|
||||
|
||||
Column {
|
||||
ProfileHeader(pubkey)
|
||||
|
||||
// Tab row
|
||||
TabRow(selectedTabIndex = currentTab) {
|
||||
Tab(selected = currentTab == 0, onClick = { /* Notes */ })
|
||||
Tab(selected = currentTab == 1, onClick = { /* Replies */ })
|
||||
Tab(selected = currentTab == 2, onClick = { /* Likes */ })
|
||||
}
|
||||
|
||||
// Nested NavHost for tabs
|
||||
NavHost(
|
||||
navController = nestedNavController,
|
||||
startDestination = ProfileTab.Notes
|
||||
) {
|
||||
composable<ProfileTab.Notes> {
|
||||
NotesTabContent(pubkey)
|
||||
}
|
||||
composable<ProfileTab.Replies> {
|
||||
RepliesTabContent(pubkey)
|
||||
}
|
||||
composable<ProfileTab.Likes> {
|
||||
LikesTabContent(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ProfileTab {
|
||||
@Serializable object Notes : ProfileTab()
|
||||
@Serializable object Replies : ProfileTab()
|
||||
@Serializable object Likes : ProfileTab()
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Navigation
|
||||
|
||||
### Navigation Test Example
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun testNavigationToProfile() {
|
||||
val navController = TestNavHostController(
|
||||
ApplicationProvider.getApplicationContext()
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
navController.navigatorProvider.addNavigator(
|
||||
ComposeNavigator()
|
||||
)
|
||||
AppNavigation(navController, accountViewModel)
|
||||
}
|
||||
|
||||
// Navigate to profile
|
||||
composeTestRule.onNodeWithText("Profile").performClick()
|
||||
|
||||
// Verify navigation
|
||||
val currentRoute = navController.currentBackStackEntry?.toRoute<Route>()
|
||||
assertTrue(currentRoute is Route.Profile)
|
||||
}
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt`
|
||||
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt`
|
||||
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Nav.kt`
|
||||
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt`
|
||||
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt`
|
||||
659
.claude/skills/android-expert/references/android-permissions.md
Normal file
659
.claude/skills/android-expert/references/android-permissions.md
Normal file
@@ -0,0 +1,659 @@
|
||||
# Android Runtime Permissions
|
||||
|
||||
Complete permission handling patterns for Amethyst using Accompanist Permissions library and Android best practices.
|
||||
|
||||
## Permission Categories in Amethyst
|
||||
|
||||
### Network Permissions (Normal - Auto-granted)
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
```
|
||||
|
||||
### Media Permissions (Dangerous - Runtime request)
|
||||
|
||||
```xml
|
||||
<!-- Camera -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Audio -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- Storage (version-specific) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
```
|
||||
|
||||
### Notification Permissions (Android 13+)
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
```
|
||||
|
||||
### Location Permissions
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
```
|
||||
|
||||
### NFC Permissions
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
```
|
||||
|
||||
### Foreground Service Permissions
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
```
|
||||
|
||||
## Accompanist Permissions Library
|
||||
|
||||
### Setup
|
||||
|
||||
```gradle
|
||||
dependencies {
|
||||
implementation("com.google.accompanist:accompanist-permissions:0.36.0")
|
||||
}
|
||||
```
|
||||
|
||||
### Single Permission Pattern
|
||||
|
||||
```kotlin
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.shouldShowRationale
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun CameraFeature() {
|
||||
val cameraPermissionState = rememberPermissionState(
|
||||
Manifest.permission.CAMERA
|
||||
)
|
||||
|
||||
when {
|
||||
// Permission granted - show feature
|
||||
cameraPermissionState.status.isGranted -> {
|
||||
CameraPreview()
|
||||
}
|
||||
|
||||
// Should show rationale - explain why permission is needed
|
||||
cameraPermissionState.status.shouldShowRationale -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Camera permission is needed to scan QR codes for login",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = { cameraPermissionState.launchPermissionRequest() }
|
||||
) {
|
||||
Text("Grant Permission")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First time - request permission
|
||||
else -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Button(
|
||||
onClick = { cameraPermissionState.launchPermissionRequest() }
|
||||
) {
|
||||
Icon(Icons.Default.CameraAlt, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Enable Camera")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Permissions Pattern
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun MediaUploadFeature() {
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = buildList {
|
||||
add(Manifest.permission.CAMERA)
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
when {
|
||||
// All permissions granted
|
||||
permissionsState.allPermissionsGranted -> {
|
||||
MediaUploadUI()
|
||||
}
|
||||
|
||||
// Some permissions need rationale
|
||||
permissionsState.shouldShowRationale -> {
|
||||
RationaleDialog(
|
||||
title = "Permissions Required",
|
||||
message = "Camera and storage access are needed to upload photos",
|
||||
onConfirm = {
|
||||
permissionsState.launchMultiplePermissionRequest()
|
||||
},
|
||||
onDismiss = { /* Handle dismissal */ }
|
||||
)
|
||||
}
|
||||
|
||||
// Request all permissions
|
||||
else -> {
|
||||
PermissionRequestScreen(
|
||||
permissions = permissionsState.permissions,
|
||||
onRequestPermissions = {
|
||||
permissionsState.launchMultiplePermissionRequest()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RationaleDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text("Continue")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Lifecycle-Aware Permission Requests
|
||||
|
||||
### Amethyst Pattern: POST_NOTIFICATIONS
|
||||
|
||||
**File:** `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt`
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun NotificationRegistration(accountViewModel: AccountViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Only request on Android 13+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val notificationPermissionState = rememberPermissionState(
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
|
||||
// Register for push notifications when permission is granted
|
||||
if (notificationPermissionState.status.isGranted) {
|
||||
LifecycleResumeEffect(
|
||||
key1 = accountViewModel,
|
||||
key2 = notificationPermissionState.status.isGranted
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
PushNotificationUtils.checkAndInit(
|
||||
context = context,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
|
||||
onPauseOrDispose {
|
||||
// Cleanup when composable pauses or disposes
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Show prompt to enable notifications
|
||||
NotificationPermissionPrompt(
|
||||
onEnableClick = {
|
||||
notificationPermissionState.launchPermissionRequest()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationPermissionPrompt(onEnableClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Notifications,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Enable Notifications",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "Get notified when someone mentions you or replies to your posts",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onEnableClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Enable Notifications")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Permission Best Practices
|
||||
|
||||
### 1. Request Contextually
|
||||
|
||||
**Bad:**
|
||||
```kotlin
|
||||
// Requesting permission on app launch
|
||||
@Composable
|
||||
fun AppContent() {
|
||||
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// DON'T DO THIS - user doesn't know why
|
||||
permissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Good:**
|
||||
```kotlin
|
||||
// Request when user explicitly wants to use camera
|
||||
@Composable
|
||||
fun QRScannerButton() {
|
||||
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (permissionState.status.isGranted) {
|
||||
// Open scanner
|
||||
} else {
|
||||
// Request permission
|
||||
permissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Scan QR Code")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Show Rationale
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun LocationFeature() {
|
||||
val locationPermissionState = rememberPermissionState(
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
|
||||
// Always show rationale first for sensitive permissions
|
||||
if (!locationPermissionState.status.isGranted) {
|
||||
LocationRationaleCard(
|
||||
onEnableClick = {
|
||||
locationPermissionState.launchPermissionRequest()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
LocationMap()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LocationRationaleCard(onEnableClick: () -> Unit) {
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Why location access?",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "Location is used for geohashing your posts. " +
|
||||
"This helps other users discover local content. " +
|
||||
"Your exact location is never shared.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row {
|
||||
OutlinedButton(onClick = { /* Skip */ }) {
|
||||
Text("Skip")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = onEnableClick) {
|
||||
Text("Enable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Handle Permanent Denial
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun CameraFeatureWithSettings() {
|
||||
val context = LocalContext.current
|
||||
val cameraPermissionState = rememberPermissionState(
|
||||
Manifest.permission.CAMERA
|
||||
)
|
||||
|
||||
when {
|
||||
cameraPermissionState.status.isGranted -> {
|
||||
CameraPreview()
|
||||
}
|
||||
|
||||
cameraPermissionState.status.shouldShowRationale -> {
|
||||
// User denied once, show rationale
|
||||
RationaleDialog(
|
||||
onConfirm = { cameraPermissionState.launchPermissionRequest() }
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Might be permanently denied - offer settings
|
||||
PermanentlyDeniedDialog(
|
||||
onOpenSettings = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermanentlyDeniedDialog(onOpenSettings: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
title = { Text("Permission Denied") },
|
||||
text = {
|
||||
Text(
|
||||
"Camera permission is required for QR scanning. " +
|
||||
"Please enable it in Settings."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onOpenSettings) {
|
||||
Text("Open Settings")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { /* Cancel */ }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Version-Specific Permissions
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun StoragePermissionRequest() {
|
||||
val permissions = remember {
|
||||
buildList {
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
|
||||
add(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
add(Manifest.permission.READ_MEDIA_VIDEO)
|
||||
}
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
|
||||
// Android 10-12: No permission needed for scoped storage
|
||||
}
|
||||
else -> {
|
||||
// Android 9 and below
|
||||
add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (permissions.isNotEmpty()) {
|
||||
val permissionsState = rememberMultiplePermissionsState(permissions)
|
||||
|
||||
if (!permissionsState.allPermissionsGranted) {
|
||||
StoragePermissionUI(
|
||||
onRequest = { permissionsState.launchMultiplePermissionRequest() }
|
||||
)
|
||||
} else {
|
||||
MediaPickerUI()
|
||||
}
|
||||
} else {
|
||||
// No permission needed
|
||||
MediaPickerUI()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Permission Groups
|
||||
|
||||
### Camera + Storage (Media Upload)
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun MediaCaptureFeature() {
|
||||
val mediaPermissions = buildList {
|
||||
add(Manifest.permission.CAMERA)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
add(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionsState = rememberMultiplePermissionsState(mediaPermissions)
|
||||
|
||||
when {
|
||||
permissionsState.allPermissionsGranted -> {
|
||||
MediaCaptureUI()
|
||||
}
|
||||
else -> {
|
||||
MediaPermissionScreen(
|
||||
permissions = permissionsState.permissions,
|
||||
onRequest = { permissionsState.launchMultiplePermissionRequest() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Audio + Storage (Voice Recording)
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun VoiceRecordingFeature() {
|
||||
val audioPermissions = buildList {
|
||||
add(Manifest.permission.RECORD_AUDIO)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
add(Manifest.permission.READ_MEDIA_AUDIO)
|
||||
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionsState = rememberMultiplePermissionsState(audioPermissions)
|
||||
|
||||
if (permissionsState.allPermissionsGranted) {
|
||||
AudioRecorderUI()
|
||||
} else {
|
||||
AudioPermissionScreen(
|
||||
onRequest = { permissionsState.launchMultiplePermissionRequest() }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Permissions
|
||||
|
||||
### Grant Permission in Tests
|
||||
|
||||
```kotlin
|
||||
@get:Rule
|
||||
val permissionRule = GrantPermissionRule.grant(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testCameraFeatureWithPermission() {
|
||||
composeTestRule.setContent {
|
||||
CameraFeature()
|
||||
}
|
||||
|
||||
// Permission is already granted by rule
|
||||
composeTestRule.onNodeWithText("Take Photo").assertExists()
|
||||
}
|
||||
```
|
||||
|
||||
### Test Permission Request Flow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun testPermissionRequestFlow() {
|
||||
composeTestRule.setContent {
|
||||
CameraFeature()
|
||||
}
|
||||
|
||||
// Initially shows permission request button
|
||||
composeTestRule.onNodeWithText("Enable Camera").assertExists()
|
||||
|
||||
// Click to request
|
||||
composeTestRule.onNodeWithText("Enable Camera").performClick()
|
||||
|
||||
// System permission dialog appears (can't test dialog itself)
|
||||
// Would need UiAutomator to interact with system dialog
|
||||
}
|
||||
```
|
||||
|
||||
## Permission State Checking
|
||||
|
||||
### Check Permission Before Action
|
||||
|
||||
```kotlin
|
||||
fun checkAndRequestCameraPermission(
|
||||
context: Context,
|
||||
permissionState: PermissionState,
|
||||
onGranted: () -> Unit
|
||||
) {
|
||||
when {
|
||||
permissionState.status.isGranted -> {
|
||||
onGranted()
|
||||
}
|
||||
permissionState.status.shouldShowRationale -> {
|
||||
// Show rationale dialog
|
||||
}
|
||||
else -> {
|
||||
permissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Permission Check (Non-Compose)
|
||||
|
||||
```kotlin
|
||||
fun hasCameraPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.CAMERA
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun requestCameraPermission(activity: ComponentActivity) {
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(Manifest.permission.CAMERA),
|
||||
REQUEST_CAMERA_PERMISSION
|
||||
)
|
||||
}
|
||||
|
||||
// In Activity
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
when (requestCode) {
|
||||
REQUEST_CAMERA_PERMISSION -> {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
// Permission granted
|
||||
} else {
|
||||
// Permission denied
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_CAMERA_PERMISSION = 100
|
||||
}
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
- `amethyst/src/main/AndroidManifest.xml` - Permission declarations
|
||||
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt` - Notification permission pattern
|
||||
- `amethyst/build.gradle` - Accompanist dependency
|
||||
|
||||
## Resources
|
||||
|
||||
- [Accompanist Permissions Documentation](https://google.github.io/accompanist/permissions/)
|
||||
- [Android Permissions Guide](https://developer.android.com/guide/topics/permissions/overview)
|
||||
- [Request Runtime Permissions](https://developer.android.com/training/permissions/requesting)
|
||||
468
.claude/skills/android-expert/references/proguard-rules.md
Normal file
468
.claude/skills/android-expert/references/proguard-rules.md
Normal file
@@ -0,0 +1,468 @@
|
||||
# Proguard Rules for Amethyst
|
||||
|
||||
Proguard configuration for optimizing and obfuscating Android APK while preserving necessary code.
|
||||
|
||||
## What is Proguard/R8?
|
||||
|
||||
**R8** is Android's default code shrinker and obfuscator (replaced Proguard in AGP 3.4.0+). It:
|
||||
- **Shrinks** code by removing unused classes/methods
|
||||
- **Obfuscates** code by renaming classes/methods to short names
|
||||
- **Optimizes** code by inlining methods and removing dead code
|
||||
|
||||
## Amethyst Proguard Configuration
|
||||
|
||||
**File:** `amethyst/proguard-rules.pro`
|
||||
|
||||
### Keep Kotlin Metadata
|
||||
|
||||
```proguard
|
||||
# Kotlin metadata is required for reflection
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-keep class kotlin.** { *; }
|
||||
-dontwarn kotlin.**
|
||||
|
||||
# Kotlin serialization
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
-dontnote kotlinx.serialization.SerializationKt
|
||||
|
||||
-keep,includedescriptorclasses class com.vitorpamplona.**$$serializer { *; }
|
||||
-keepclassmembers class com.vitorpamplona.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.vitorpamplona.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
```
|
||||
|
||||
### Keep Nostr Event Classes
|
||||
|
||||
```proguard
|
||||
# Nostr events are serialized/deserialized
|
||||
-keep class com.vitorpamplona.quartz.events.** { *; }
|
||||
-keep class com.vitorpamplona.quartz.encoders.** { *; }
|
||||
|
||||
# Keep event builders
|
||||
-keep class com.vitorpamplona.quartz.builders.** { *; }
|
||||
|
||||
# Keep tag classes
|
||||
-keep class com.vitorpamplona.quartz.nip01Core.tags.** { *; }
|
||||
```
|
||||
|
||||
### Keep Data Classes
|
||||
|
||||
```proguard
|
||||
# Data classes used in ViewModels and serialization
|
||||
-keep @kotlinx.serialization.Serializable class * { *; }
|
||||
|
||||
# Keep all data classes
|
||||
-keep class com.vitorpamplona.amethyst.model.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
|
||||
```
|
||||
|
||||
### Keep Compose Classes
|
||||
|
||||
```proguard
|
||||
# Jetpack Compose
|
||||
-keep class androidx.compose.** { *; }
|
||||
-dontwarn androidx.compose.**
|
||||
|
||||
# Compose runtime
|
||||
-keep class androidx.compose.runtime.** { *; }
|
||||
|
||||
# Compose UI
|
||||
-keep class androidx.compose.ui.** { *; }
|
||||
|
||||
# Material3
|
||||
-keep class androidx.compose.material3.** { *; }
|
||||
|
||||
# Navigation Compose - Keep serializable routes
|
||||
-keep class * implements java.io.Serializable { *; }
|
||||
-keepclassmembers class * implements java.io.Serializable {
|
||||
static final long serialVersionUID;
|
||||
private static final java.io.ObjectStreamField[] serialPersistentFields;
|
||||
!static !transient <fields>;
|
||||
private void writeObject(java.io.ObjectOutputStream);
|
||||
private void readObject(java.io.ObjectInputStream);
|
||||
java.lang.Object writeReplace();
|
||||
java.lang.Object readResolve();
|
||||
}
|
||||
```
|
||||
|
||||
### Keep OkHttp/Retrofit
|
||||
|
||||
```proguard
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep class okio.** { *; }
|
||||
|
||||
# OkHttp WebSockets (for Nostr relays)
|
||||
-keep class okhttp3.internal.ws.** { *; }
|
||||
|
||||
# Retrofit (if used)
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keep class retrofit2.** { *; }
|
||||
```
|
||||
|
||||
### Keep Jackson (JSON)
|
||||
|
||||
```proguard
|
||||
# Jackson JSON library
|
||||
-keep class com.fasterxml.jackson.** { *; }
|
||||
-keep class org.codehaus.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@com.fasterxml.jackson.annotation.* <methods>;
|
||||
}
|
||||
|
||||
# Jackson polymorphic types
|
||||
-keepattributes RuntimeVisibleAnnotations
|
||||
-keep @com.fasterxml.jackson.annotation.JsonTypeInfo class *
|
||||
```
|
||||
|
||||
### Keep Secp256k1 (Crypto)
|
||||
|
||||
```proguard
|
||||
# Secp256k1 native library
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
|
||||
# Keep native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
```
|
||||
|
||||
### Keep Tor
|
||||
|
||||
```proguard
|
||||
# Tor library
|
||||
-keep class com.msopentech.thali.toronionproxy.** { *; }
|
||||
-dontwarn com.msopentech.thali.toronionproxy.**
|
||||
```
|
||||
|
||||
### Keep ExoPlayer (Media)
|
||||
|
||||
```proguard
|
||||
# ExoPlayer (Media3)
|
||||
-keep class androidx.media3.** { *; }
|
||||
-dontwarn androidx.media3.**
|
||||
|
||||
-keep class com.google.android.exoplayer2.** { *; }
|
||||
-dontwarn com.google.android.exoplayer2.**
|
||||
```
|
||||
|
||||
### Keep Coil (Image Loading)
|
||||
|
||||
```proguard
|
||||
# Coil image loading
|
||||
-keep class coil.** { *; }
|
||||
-keep class coil3.** { *; }
|
||||
-dontwarn coil.**
|
||||
-dontwarn coil3.**
|
||||
```
|
||||
|
||||
### Keep ViewModels
|
||||
|
||||
```proguard
|
||||
# ViewModel classes
|
||||
-keep class * extends androidx.lifecycle.ViewModel {
|
||||
<init>();
|
||||
}
|
||||
|
||||
# ViewModel factories
|
||||
-keep class * extends androidx.lifecycle.ViewModelProvider$Factory {
|
||||
<init>(...);
|
||||
}
|
||||
|
||||
# Keep ViewModel constructors for reflection
|
||||
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
|
||||
<init>(...);
|
||||
}
|
||||
```
|
||||
|
||||
### Keep Parcelable
|
||||
|
||||
```proguard
|
||||
# Parcelable
|
||||
-keep class * implements android.os.Parcelable {
|
||||
public static final android.os.Parcelable$Creator *;
|
||||
}
|
||||
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public <fields>;
|
||||
private <fields>;
|
||||
}
|
||||
```
|
||||
|
||||
### Keep Enums
|
||||
|
||||
```proguard
|
||||
# Enums
|
||||
-keepclassmembers enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
```
|
||||
|
||||
### Remove Logging (Production)
|
||||
|
||||
```proguard
|
||||
# Remove debug logging in release builds
|
||||
-assumenosideeffects class android.util.Log {
|
||||
public static *** d(...);
|
||||
public static *** v(...);
|
||||
public static *** i(...);
|
||||
}
|
||||
|
||||
# Keep error/warning logs
|
||||
-assumenosideeffects class android.util.Log {
|
||||
public static *** e(...) return false;
|
||||
public static *** w(...) return false;
|
||||
}
|
||||
```
|
||||
|
||||
### Keep Crashlytics/Firebase
|
||||
|
||||
```proguard
|
||||
# Firebase Crashlytics
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keep public class * extends java.lang.Exception
|
||||
|
||||
# Firebase
|
||||
-keep class com.google.firebase.** { *; }
|
||||
-dontwarn com.google.firebase.**
|
||||
```
|
||||
|
||||
## Build Configuration
|
||||
|
||||
### Enable R8 in build.gradle
|
||||
|
||||
```gradle
|
||||
android {
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled = true
|
||||
shrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
|
||||
debug {
|
||||
minifyEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Proguard Files
|
||||
|
||||
```gradle
|
||||
android {
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
"proguard-quartz.pro", // Library-specific rules
|
||||
"proguard-compose.pro" // Compose-specific rules
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging Proguard Issues
|
||||
|
||||
### Generate Mapping File
|
||||
|
||||
R8 generates `mapping.txt` in `app/build/outputs/mapping/release/`:
|
||||
|
||||
```
|
||||
# Original class name -> Obfuscated name
|
||||
com.vitorpamplona.amethyst.ui.MainActivity -> a.b.c:
|
||||
void onCreate(Bundle) -> a
|
||||
```
|
||||
|
||||
### Deobfuscate Stack Traces
|
||||
|
||||
```bash
|
||||
# Using retrace (part of Android SDK)
|
||||
retrace.sh mapping.txt stacktrace.txt
|
||||
```
|
||||
|
||||
### Enable Proguard Output
|
||||
|
||||
```gradle
|
||||
android {
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(...)
|
||||
|
||||
// Generate reports
|
||||
postprocessing {
|
||||
proguardFiles = [...]
|
||||
obfuscate = true
|
||||
optimizeCode = true
|
||||
removeUnusedCode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output files:**
|
||||
- `build/outputs/mapping/release/configuration.txt` - All Proguard rules applied
|
||||
- `build/outputs/mapping/release/mapping.txt` - Obfuscation mappings
|
||||
- `build/outputs/mapping/release/seeds.txt` - Classes kept by `-keep` rules
|
||||
- `build/outputs/mapping/release/usage.txt` - Code removed by R8
|
||||
|
||||
### Test Release Build
|
||||
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
|
||||
# Install and test
|
||||
adb install app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Issue: NoSuchMethodException at Runtime
|
||||
|
||||
**Cause:** Proguard removed or renamed a method used via reflection.
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
-keep class com.example.YourClass {
|
||||
public <methods>;
|
||||
}
|
||||
```
|
||||
|
||||
### Issue: Serialization Fails
|
||||
|
||||
**Cause:** Data class fields were renamed.
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
-keep @kotlinx.serialization.Serializable class * { *; }
|
||||
-keepclassmembers class * {
|
||||
@kotlinx.serialization.SerialName <fields>;
|
||||
}
|
||||
```
|
||||
|
||||
### Issue: Compose Navigation Crashes
|
||||
|
||||
**Cause:** @Serializable route classes were obfuscated.
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
# Keep all route classes
|
||||
-keep @kotlinx.serialization.Serializable class com.vitorpamplona.amethyst.ui.navigation.routes.** { *; }
|
||||
```
|
||||
|
||||
### Issue: Native Library Crashes
|
||||
|
||||
**Cause:** Native method signatures were changed.
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
```
|
||||
|
||||
## Optimization Tips
|
||||
|
||||
### 1. Keep Only What's Necessary
|
||||
|
||||
Don't use broad wildcards:
|
||||
```proguard
|
||||
# Bad - keeps everything
|
||||
-keep class com.vitorpamplona.** { *; }
|
||||
|
||||
# Good - keeps only specific packages
|
||||
-keep class com.vitorpamplona.quartz.events.** { *; }
|
||||
```
|
||||
|
||||
### 2. Test Thoroughly
|
||||
|
||||
- Test all app features after enabling Proguard
|
||||
- Test deep links and navigation
|
||||
- Test serialization/deserialization
|
||||
- Test external library integrations
|
||||
|
||||
### 3. Use AGP's Proguard Analysis
|
||||
|
||||
```gradle
|
||||
android {
|
||||
buildTypes {
|
||||
release {
|
||||
// Generate R8 configuration
|
||||
android.debug.obsoleteApi = true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Analyze APK Size
|
||||
|
||||
```bash
|
||||
# Build release APK
|
||||
./gradlew assembleRelease
|
||||
|
||||
# Analyze APK with Android Studio
|
||||
# Build > Analyze APK > Select app-release.apk
|
||||
```
|
||||
|
||||
See `scripts/analyze-apk-size.sh` for automated analysis.
|
||||
|
||||
## Product Flavor Specific Rules
|
||||
|
||||
### Play Flavor (Firebase)
|
||||
|
||||
```proguard
|
||||
# proguard-play.pro
|
||||
-keep class com.google.firebase.** { *; }
|
||||
-keep class com.google.android.gms.** { *; }
|
||||
```
|
||||
|
||||
### F-Droid Flavor (No Google Services)
|
||||
|
||||
```proguard
|
||||
# proguard-fdroid.pro
|
||||
# UnifiedPush
|
||||
-keep class org.unifiedpush.** { *; }
|
||||
```
|
||||
|
||||
**Configure in build.gradle:**
|
||||
```gradle
|
||||
android {
|
||||
flavorDimensions = ["channel"]
|
||||
productFlavors {
|
||||
create("play") {
|
||||
dimension = "channel"
|
||||
proguardFiles("proguard-play.pro")
|
||||
}
|
||||
create("fdroid") {
|
||||
dimension = "channel"
|
||||
proguardFiles("proguard-fdroid.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
- `amethyst/proguard-rules.pro` - Main Proguard rules
|
||||
- `amethyst/build/outputs/mapping/release/` - Proguard output files
|
||||
- `amethyst/build.gradle` - Proguard configuration
|
||||
|
||||
## Resources
|
||||
|
||||
- [Android R8 Documentation](https://developer.android.com/build/shrink-code)
|
||||
- [Proguard Manual](https://www.guardsquare.com/manual/configuration)
|
||||
- [Kotlinx Serialization Proguard](https://github.com/Kotlin/kotlinx.serialization#android)
|
||||
230
.claude/skills/android-expert/scripts/analyze-apk-size.sh
Executable file
230
.claude/skills/android-expert/scripts/analyze-apk-size.sh
Executable file
@@ -0,0 +1,230 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# APK Size Analysis Script for Amethyst
|
||||
#
|
||||
# Usage:
|
||||
# ./analyze-apk-size.sh [apk-path]
|
||||
#
|
||||
# If no APK path provided, uses latest release build
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Default APK path
|
||||
DEFAULT_APK="amethyst/build/outputs/apk/release/amethyst-release.apk"
|
||||
APK_PATH="${1:-$DEFAULT_APK}"
|
||||
|
||||
# Check if APK exists
|
||||
if [ ! -f "$APK_PATH" ]; then
|
||||
echo -e "${RED}Error: APK not found at $APK_PATH${NC}"
|
||||
echo "Build the APK first: ./gradlew :amethyst:assembleRelease"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} Amethyst APK Size Analysis${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo
|
||||
|
||||
# APK basic info
|
||||
echo -e "${GREEN}APK Path:${NC} $APK_PATH"
|
||||
APK_SIZE=$(du -h "$APK_PATH" | cut -f1)
|
||||
APK_SIZE_BYTES=$(stat -f%z "$APK_PATH" 2>/dev/null || stat -c%s "$APK_PATH" 2>/dev/null)
|
||||
echo -e "${GREEN}APK Size:${NC} $APK_SIZE ($APK_SIZE_BYTES bytes)"
|
||||
echo
|
||||
|
||||
# Extract APK to temp directory
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
echo -e "${YELLOW}Extracting APK to $TEMP_DIR...${NC}"
|
||||
unzip -q "$APK_PATH" -d "$TEMP_DIR"
|
||||
|
||||
# Analyze APK contents
|
||||
echo
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} Top-Level Contents${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
du -sh "$TEMP_DIR"/* | sort -hr
|
||||
|
||||
# Analyze DEX files
|
||||
echo
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} DEX Files (Code)${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
DEX_TOTAL=0
|
||||
for dex in "$TEMP_DIR"/*.dex; do
|
||||
if [ -f "$dex" ]; then
|
||||
DEX_NAME=$(basename "$dex")
|
||||
DEX_SIZE=$(stat -f%z "$dex" 2>/dev/null || stat -c%s "$dex" 2>/dev/null)
|
||||
DEX_SIZE_MB=$(echo "scale=2; $DEX_SIZE / 1024 / 1024" | bc)
|
||||
echo -e " $DEX_NAME: ${GREEN}${DEX_SIZE_MB} MB${NC}"
|
||||
DEX_TOTAL=$((DEX_TOTAL + DEX_SIZE))
|
||||
fi
|
||||
done
|
||||
DEX_TOTAL_MB=$(echo "scale=2; $DEX_TOTAL / 1024 / 1024" | bc)
|
||||
echo -e "${YELLOW}Total DEX: $DEX_TOTAL_MB MB${NC}"
|
||||
|
||||
# Analyze resources
|
||||
echo
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} Resources${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
if [ -d "$TEMP_DIR/res" ]; then
|
||||
RES_SIZE=$(du -sh "$TEMP_DIR/res" | cut -f1)
|
||||
echo -e " res/: ${GREEN}$RES_SIZE${NC}"
|
||||
|
||||
# Top resource folders
|
||||
echo " Top resource folders:"
|
||||
du -sh "$TEMP_DIR/res"/* | sort -hr | head -10 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# Analyze assets
|
||||
echo
|
||||
if [ -d "$TEMP_DIR/assets" ]; then
|
||||
ASSETS_SIZE=$(du -sh "$TEMP_DIR/assets" | cut -f1)
|
||||
echo -e " assets/: ${GREEN}$ASSETS_SIZE${NC}"
|
||||
|
||||
# Top asset files
|
||||
echo " Top asset files:"
|
||||
find "$TEMP_DIR/assets" -type f -exec du -h {} \; | sort -hr | head -10 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# Analyze native libraries
|
||||
echo
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} Native Libraries${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
if [ -d "$TEMP_DIR/lib" ]; then
|
||||
LIB_SIZE=$(du -sh "$TEMP_DIR/lib" | cut -f1)
|
||||
echo -e " lib/: ${GREEN}$LIB_SIZE${NC}"
|
||||
|
||||
# By architecture
|
||||
for arch in "$TEMP_DIR/lib"/*; do
|
||||
if [ -d "$arch" ]; then
|
||||
ARCH_NAME=$(basename "$arch")
|
||||
ARCH_SIZE=$(du -sh "$arch" | cut -f1)
|
||||
echo -e " $ARCH_NAME: ${GREEN}$ARCH_SIZE${NC}"
|
||||
|
||||
# Top libraries in architecture
|
||||
find "$arch" -type f -name "*.so" -exec du -h {} \; | sort -hr | head -5 | sed 's/^/ /'
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo " No native libraries"
|
||||
fi
|
||||
|
||||
# Analyze Kotlin metadata
|
||||
echo
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} Kotlin Metadata${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
if [ -d "$TEMP_DIR/kotlin" ]; then
|
||||
KOTLIN_SIZE=$(du -sh "$TEMP_DIR/kotlin" | cut -f1)
|
||||
echo -e " kotlin/: ${GREEN}$KOTLIN_SIZE${NC}"
|
||||
fi
|
||||
|
||||
# Method count (if dexdump available)
|
||||
echo
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} Method Count${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
|
||||
# Try to find dexdump
|
||||
DEXDUMP=$(which dexdump 2>/dev/null || find "$ANDROID_HOME/build-tools" -name dexdump 2>/dev/null | head -1 || echo "")
|
||||
|
||||
if [ -n "$DEXDUMP" ] && [ -x "$DEXDUMP" ]; then
|
||||
TOTAL_METHODS=0
|
||||
for dex in "$TEMP_DIR"/*.dex; do
|
||||
if [ -f "$dex" ]; then
|
||||
DEX_NAME=$(basename "$dex")
|
||||
METHOD_COUNT=$("$DEXDUMP" -l xml "$dex" | grep -c "<method " || echo "0")
|
||||
echo -e " $DEX_NAME: ${GREEN}$METHOD_COUNT methods${NC}"
|
||||
TOTAL_METHODS=$((TOTAL_METHODS + METHOD_COUNT))
|
||||
fi
|
||||
done
|
||||
echo -e "${YELLOW}Total methods: $TOTAL_METHODS${NC}"
|
||||
|
||||
# Check multidex threshold
|
||||
if [ $TOTAL_METHODS -gt 65536 ]; then
|
||||
echo -e "${RED} ⚠ Exceeded 64K method limit (multidex required)${NC}"
|
||||
else
|
||||
REMAINING=$((65536 - TOTAL_METHODS))
|
||||
echo -e "${GREEN} ✓ Below 64K limit ($REMAINING methods remaining)${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} dexdump not found - cannot analyze method count${NC}"
|
||||
echo " Set ANDROID_HOME or install Android SDK build-tools"
|
||||
fi
|
||||
|
||||
# Size breakdown percentages
|
||||
echo
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} Size Breakdown${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
|
||||
# Calculate percentages
|
||||
CODE_SIZE=$(du -s "$TEMP_DIR"/*.dex 2>/dev/null | awk '{sum+=$1} END {print sum}' || echo "0")
|
||||
RES_SIZE_BYTES=$(du -s "$TEMP_DIR/res" 2>/dev/null | awk '{print $1}' || echo "0")
|
||||
ASSETS_SIZE_BYTES=$(du -s "$TEMP_DIR/assets" 2>/dev/null | awk '{print $1}' || echo "0")
|
||||
LIB_SIZE_BYTES=$(du -s "$TEMP_DIR/lib" 2>/dev/null | awk '{print $1}' || echo "0")
|
||||
|
||||
# Convert to KB for bc
|
||||
APK_SIZE_KB=$((APK_SIZE_BYTES / 1024))
|
||||
CODE_PCT=$(echo "scale=1; $CODE_SIZE * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
|
||||
RES_PCT=$(echo "scale=1; $RES_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
|
||||
ASSETS_PCT=$(echo "scale=1; $ASSETS_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
|
||||
LIB_PCT=$(echo "scale=1; $LIB_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
|
||||
|
||||
echo -e " Code (DEX): ${CODE_PCT}%"
|
||||
echo -e " Resources: ${RES_PCT}%"
|
||||
echo -e " Assets: ${ASSETS_PCT}%"
|
||||
echo -e " Native libs: ${LIB_PCT}%"
|
||||
|
||||
# Recommendations
|
||||
echo
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} Optimization Recommendations${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
|
||||
# Check if resources are large
|
||||
if [ $(echo "$RES_PCT > 30" | bc 2>/dev/null || echo "0") -eq 1 ]; then
|
||||
echo -e "${YELLOW} • Resources are ${RES_PCT}% of APK - consider:${NC}"
|
||||
echo " - Enable resource shrinking (shrinkResources = true)"
|
||||
echo " - Use WebP for images instead of PNG/JPG"
|
||||
echo " - Remove unused resources"
|
||||
fi
|
||||
|
||||
# Check if native libs are large
|
||||
if [ $(echo "$LIB_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then
|
||||
echo -e "${YELLOW} • Native libraries are ${LIB_PCT}% of APK - consider:${NC}"
|
||||
echo " - Use App Bundle to serve ABI-specific APKs"
|
||||
echo " - Remove unused ABIs"
|
||||
fi
|
||||
|
||||
# Check if code is large
|
||||
if [ $(echo "$CODE_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then
|
||||
echo -e "${YELLOW} • Code is ${CODE_PCT}% of APK - consider:${NC}"
|
||||
echo " - Enable Proguard/R8 (minifyEnabled = true)"
|
||||
echo " - Review dependencies for bloat"
|
||||
echo " - Enable code shrinking"
|
||||
fi
|
||||
|
||||
# General recommendations
|
||||
echo -e "${GREEN} General optimizations:${NC}"
|
||||
echo " - Use Android App Bundle (.aab) instead of APK"
|
||||
echo " - Enable R8 optimization (minifyEnabled = true)"
|
||||
echo " - Enable resource shrinking (shrinkResources = true)"
|
||||
echo " - Analyze with APK Analyzer in Android Studio"
|
||||
|
||||
# Cleanup
|
||||
echo
|
||||
echo -e "${YELLOW}Cleaning up temporary files...${NC}"
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}✓ Analysis complete${NC}"
|
||||
339
.claude/skills/compose-desktop.md
Normal file
339
.claude/skills/compose-desktop.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Compose Desktop Skill
|
||||
|
||||
## Desktop Application Entry Point
|
||||
|
||||
```kotlin
|
||||
// desktopApp/src/jvmMain/kotlin/Main.kt
|
||||
package com.vitorpamplona.amethyst.desktop
|
||||
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.*
|
||||
|
||||
fun main() = application {
|
||||
val windowState = rememberWindowState(
|
||||
width = 1200.dp,
|
||||
height = 800.dp,
|
||||
position = WindowPosition.Aligned(Alignment.Center)
|
||||
)
|
||||
|
||||
// System tray
|
||||
Tray(
|
||||
icon = painterResource("icon.png"),
|
||||
tooltip = "Amethyst",
|
||||
menu = {
|
||||
Item("Show", onClick = { windowState.isMinimized = false })
|
||||
Separator()
|
||||
Item("Exit", onClick = ::exitApplication)
|
||||
}
|
||||
)
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = windowState,
|
||||
title = "Amethyst",
|
||||
icon = painterResource("icon.png")
|
||||
) {
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { }
|
||||
Separator()
|
||||
Item("Settings", shortcut = KeyShortcut(Key.Comma, ctrl = true)) { }
|
||||
Separator()
|
||||
Item("Quit", shortcut = KeyShortcut(Key.Q, ctrl = true), onClick = ::exitApplication)
|
||||
}
|
||||
Menu("Edit") {
|
||||
Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true)) { }
|
||||
Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true)) { }
|
||||
}
|
||||
Menu("View") {
|
||||
Item("Feed") { }
|
||||
Item("Messages") { }
|
||||
Item("Notifications") { }
|
||||
}
|
||||
Menu("Help") {
|
||||
Item("About Amethyst") { }
|
||||
}
|
||||
}
|
||||
|
||||
App()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Desktop-Specific Components
|
||||
|
||||
### File Dialog
|
||||
```kotlin
|
||||
@Composable
|
||||
fun rememberFileDialog(): FileDialogState {
|
||||
return remember { FileDialogState() }
|
||||
}
|
||||
|
||||
class FileDialogState {
|
||||
var isOpen by mutableStateOf(false)
|
||||
var result by mutableStateOf<File?>(null)
|
||||
|
||||
fun open() { isOpen = true }
|
||||
|
||||
@Composable
|
||||
fun Dialog(
|
||||
title: String = "Select File",
|
||||
allowedExtensions: List<String> = emptyList()
|
||||
) {
|
||||
if (isOpen) {
|
||||
DisposableEffect(Unit) {
|
||||
val dialog = java.awt.FileDialog(null as java.awt.Frame?, title)
|
||||
if (allowedExtensions.isNotEmpty()) {
|
||||
dialog.setFilenameFilter { _, name ->
|
||||
allowedExtensions.any { name.endsWith(it) }
|
||||
}
|
||||
}
|
||||
dialog.isVisible = true
|
||||
result = dialog.file?.let { File(dialog.directory, it) }
|
||||
isOpen = false
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scroll Behavior
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DesktopScrollableColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Box(modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
adapter = rememberScrollbarAdapter(scrollState)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
```kotlin
|
||||
@Composable
|
||||
fun KeyboardNavigableList(
|
||||
items: List<Note>,
|
||||
selectedIndex: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
onActivate: (Note) -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onKeyEvent { event ->
|
||||
when {
|
||||
event.key == Key.DirectionDown && event.type == KeyEventType.KeyDown -> {
|
||||
onSelect((selectedIndex + 1).coerceAtMost(items.lastIndex))
|
||||
true
|
||||
}
|
||||
event.key == Key.DirectionUp && event.type == KeyEventType.KeyDown -> {
|
||||
onSelect((selectedIndex - 1).coerceAtLeast(0))
|
||||
true
|
||||
}
|
||||
event.key == Key.Enter && event.type == KeyEventType.KeyDown -> {
|
||||
items.getOrNull(selectedIndex)?.let { onActivate(it) }
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
) {
|
||||
itemsIndexed(items) { index, note ->
|
||||
NoteCard(
|
||||
note = note,
|
||||
isSelected = index == selectedIndex,
|
||||
modifier = Modifier.clickable { onSelect(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Window Support
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ApplicationScope.NoteDetailWindow(
|
||||
note: Note,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Window(
|
||||
onCloseRequest = onClose,
|
||||
title = "Note by ${note.author.name}",
|
||||
state = rememberWindowState(width = 600.dp, height = 400.dp)
|
||||
) {
|
||||
NoteDetailScreen(note)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in main application
|
||||
var openNotes by remember { mutableStateOf<List<Note>>(emptyList()) }
|
||||
|
||||
openNotes.forEach { note ->
|
||||
key(note.id) {
|
||||
NoteDetailWindow(
|
||||
note = note,
|
||||
onClose = { openNotes = openNotes - note }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tooltips
|
||||
```kotlin
|
||||
@Composable
|
||||
fun TooltipButton(
|
||||
tooltip: String,
|
||||
onClick: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
TooltipArea(
|
||||
tooltip = {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.inverseSurface
|
||||
) {
|
||||
Text(
|
||||
text = tooltip,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Desktop Layout Pattern
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DesktopAppLayout(
|
||||
currentScreen: Screen,
|
||||
onNavigate: (Screen) -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// Sidebar navigation
|
||||
NavigationRail(
|
||||
modifier = Modifier.width(72.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Home, "Feed") },
|
||||
label = { Text("Feed") },
|
||||
selected = currentScreen == Screen.Feed,
|
||||
onClick = { onNavigate(Screen.Feed) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Email, "Messages") },
|
||||
label = { Text("DMs") },
|
||||
selected = currentScreen == Screen.Messages,
|
||||
onClick = { onNavigate(Screen.Messages) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Notifications, "Notifications") },
|
||||
label = { Text("Alerts") },
|
||||
selected = currentScreen == Screen.Notifications,
|
||||
onClick = { onNavigate(Screen.Notifications) }
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Settings, "Settings") },
|
||||
label = { Text("Settings") },
|
||||
selected = currentScreen == Screen.Settings,
|
||||
onClick = { onNavigate(Screen.Settings) }
|
||||
)
|
||||
}
|
||||
|
||||
// Divider
|
||||
VerticalDivider()
|
||||
|
||||
// Main content
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Build Configuration
|
||||
|
||||
```kotlin
|
||||
// desktopApp/build.gradle.kts
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.material3)
|
||||
implementation(project(":quartz"))
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb
|
||||
)
|
||||
|
||||
packageName = "Amethyst"
|
||||
packageVersion = "1.0.0"
|
||||
|
||||
macOS {
|
||||
bundleID = "com.vitorpamplona.amethyst.desktop"
|
||||
iconFile.set(project.file("icons/icon.icns"))
|
||||
}
|
||||
|
||||
windows {
|
||||
iconFile.set(project.file("icons/icon.ico"))
|
||||
menuGroup = "Amethyst"
|
||||
}
|
||||
|
||||
linux {
|
||||
iconFile.set(project.file("icons/icon.png"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
577
.claude/skills/compose-expert/SKILL.md
Normal file
577
.claude/skills/compose-expert/SKILL.md
Normal file
@@ -0,0 +1,577 @@
|
||||
---
|
||||
name: compose-expert
|
||||
description: Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector icons, or determining whether to share UI in commonMain vs keep platform-specific. Delegates navigation to android-expert/desktop-expert. Complements kotlin-expert (handles Kotlin language aspects of state/annotations).
|
||||
---
|
||||
|
||||
# Compose Multiplatform Expert
|
||||
|
||||
Visual UI patterns for sharing composables across Android and Desktop.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Creating or refactoring shared UI components
|
||||
- Deciding whether to share UI in `commonMain` or keep platform-specific
|
||||
- Building custom ImageVector icons (robohash pattern)
|
||||
- State management: remember, derivedStateOf, produceState
|
||||
- Recomposition optimization: visual usage of @Stable/@Immutable
|
||||
- Material3 theming and styling
|
||||
- Performance: lazy lists, image loading
|
||||
|
||||
**Delegate to other skills:**
|
||||
- Navigation structure → `android-expert`, `desktop-expert`
|
||||
- Kotlin state patterns (StateFlow, sealed classes) → `kotlin-expert`
|
||||
- Build configuration → `gradle-expert`
|
||||
|
||||
## Philosophy: Share by Default
|
||||
|
||||
**Default to `commons/commonMain`** unless platform experts indicate otherwise.
|
||||
|
||||
### Always Share
|
||||
|
||||
- **UI components**: Buttons, cards, lists, dialogs, inputs
|
||||
- **State visualization**: Loading, empty, error states
|
||||
- **Custom icons**: ImageVector assets (robohash, custom paths)
|
||||
- **Theme utilities**: Color calculations, style helpers
|
||||
- **Material3 components**: Any UI using Material primitives
|
||||
|
||||
### Keep Platform-Specific
|
||||
|
||||
- **Navigation structure**: Bottom nav (Android) vs Sidebar (Desktop)
|
||||
- **Screen layouts**: Platform-specific scaffolding
|
||||
- **System integrations**: File pickers, notifications, share sheets
|
||||
- **Platform UX**: Gestures, keyboard shortcuts, window management
|
||||
|
||||
### Decision Framework
|
||||
|
||||
1. **Uses only Material3 primitives?** → Share in `commonMain`
|
||||
2. **Requires platform system APIs?** → Platform-specific
|
||||
3. **Pure visual component without navigation?** → Share in `commonMain`
|
||||
4. **Needs platform UX patterns?** → Ask `android-expert` or `desktop-expert`
|
||||
|
||||
If uncertain, **default to sharing** - easier to split later than merge.
|
||||
|
||||
## Shared Composable Anatomy
|
||||
|
||||
### Structure
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SharedComponent(
|
||||
// State parameters (read-only)
|
||||
data: DataClass,
|
||||
isLoading: Boolean,
|
||||
// Event parameters (write-only)
|
||||
onAction: () -> Unit,
|
||||
// Visual parameters
|
||||
modifier: Modifier = Modifier,
|
||||
// Optional customization
|
||||
colors: ComponentColors = ComponentDefaults.colors()
|
||||
) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: State down, events up
|
||||
- Parameters above modifier = required state/events
|
||||
- `modifier` parameter = layout control
|
||||
- Parameters below modifier = optional customization
|
||||
|
||||
### Example: AddButton
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Add",
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
shape = ActionButtonShape,
|
||||
contentPadding = ActionButtonPadding
|
||||
) {
|
||||
Text(text = text, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared constants for consistency
|
||||
val ActionButtonShape = RoundedCornerShape(20.dp)
|
||||
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
|
||||
```
|
||||
|
||||
**Why this works on all platforms:**
|
||||
- Material3 primitives (OutlinedButton, Text)
|
||||
- No platform APIs
|
||||
- Configurable through parameters
|
||||
- Consistent styling via shared constants
|
||||
|
||||
## State Management Patterns
|
||||
|
||||
### remember - Cache Across Recompositions
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ExpandableCard() {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column {
|
||||
IconButton(onClick = { isExpanded = !isExpanded }) {
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand"
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
Text("Expanded content...")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual pattern**: Toggle button → state changes → UI expands/collapses
|
||||
**Use for**: Simple UI state (toggles, counters, text input)
|
||||
|
||||
### derivedStateOf - Optimize Frequent Changes
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ScrollToTopButton(listState: LazyListState) {
|
||||
// Only recomposes when showButton changes, not every scroll pixel
|
||||
val showButton by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0
|
||||
}
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
FloatingActionButton(onClick = { /* scroll to top */ }) {
|
||||
Icon(Icons.Default.ArrowUpward, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → Button visibility
|
||||
**Use for**: Input changes frequently, derived result changes rarely
|
||||
**Performance**: Prevents recomposition on every scroll event
|
||||
|
||||
### produceState - Async to Compose State
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoadUserProfile(userId: String): State<User?> {
|
||||
return produceState<User?>(initialValue = null, userId) {
|
||||
value = repository.fetchUser(userId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(userId: String) {
|
||||
val user by LoadUserProfile(userId)
|
||||
|
||||
when (user) {
|
||||
null -> LoadingState("Loading profile...")
|
||||
else -> ProfileCard(user!!)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual pattern**: Async operation → state updates → UI reflects changes
|
||||
**Use for**: Convert Flow, LiveData, callbacks into Compose state
|
||||
**Lifecycle**: Coroutine cancelled when composable leaves composition
|
||||
|
||||
For Kotlin-specific state patterns (StateFlow, sealed classes), see `kotlin-expert`.
|
||||
|
||||
## State Hoisting
|
||||
|
||||
Move state up to make composables reusable:
|
||||
|
||||
```kotlin
|
||||
// ❌ Stateful - hard to test, can't control externally
|
||||
@Composable
|
||||
fun BadSearchBar() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
TextField(value = query, onValueChange = { query = it })
|
||||
}
|
||||
|
||||
// ✅ Stateless - reusable, testable
|
||||
@Composable
|
||||
fun GoodSearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchScreen() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
Column {
|
||||
GoodSearchBar(query = query, onQueryChange = { query = it })
|
||||
SearchResults(query = query)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Principle**: State up, events down
|
||||
- State: `query: String` (read-only parameter)
|
||||
- Events: `onQueryChange: (String) -> Unit` (callback parameter)
|
||||
|
||||
## Recomposition Optimization
|
||||
|
||||
### Visual Usage of @Immutable
|
||||
|
||||
Use @Immutable on data classes passed to composables:
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class UserProfile(val name: String, val avatar: String)
|
||||
|
||||
@Composable
|
||||
fun ProfileCard(profile: UserProfile) {
|
||||
// Only recomposes when profile instance changes
|
||||
Row {
|
||||
RobohashImage(robot = profile.avatar)
|
||||
Text(profile.name, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual effect**: Prevents recomposition when parent recomposes with same data
|
||||
**Pattern**: Mark parameter data classes as @Immutable
|
||||
**Note**: For Kotlin language details on @Immutable, see `kotlin-expert`
|
||||
|
||||
### Stable Parameters
|
||||
|
||||
```kotlin
|
||||
// ✅ Stable - won't trigger recomposition unless colors instance changes
|
||||
@Composable
|
||||
fun ThemedCard(
|
||||
content: String,
|
||||
colors: CardColors = CardDefaults.colors(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(colors = colors, modifier = modifier) {
|
||||
Text(content)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For @Stable annotation details, see `kotlin-expert`.
|
||||
|
||||
## Material3 Theming
|
||||
|
||||
All shared composables use Material3 for consistency:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ThemedComponent() {
|
||||
val bg = MaterialTheme.colorScheme.background
|
||||
val fg = MaterialTheme.colorScheme.onBackground
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(bg)
|
||||
) {
|
||||
Text(
|
||||
"Title",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = fg
|
||||
)
|
||||
Button(
|
||||
onClick = { /* ... */ },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = primary)
|
||||
) {
|
||||
Text("Action")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Principles:**
|
||||
- Colors: `MaterialTheme.colorScheme.*`
|
||||
- Typography: `MaterialTheme.typography.*`
|
||||
- Shapes: `MaterialTheme.shapes.*`
|
||||
|
||||
### Theme Detection
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun isLightTheme(): Boolean {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemedIcon() {
|
||||
val isDark = !isLightTheme()
|
||||
val tint = if (isDark) Color.White else Color.Black
|
||||
Icon(Icons.Default.Face, null, tint = tint)
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Icons: ImageVector Pattern
|
||||
|
||||
Amethyst uses ImageVector for multiplatform icons.
|
||||
|
||||
### roboBuilder DSL
|
||||
|
||||
```kotlin
|
||||
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = "Robohash",
|
||||
defaultWidth = 300.dp,
|
||||
defaultHeight = 300.dp,
|
||||
viewportWidth = 300f,
|
||||
viewportHeight = 300f
|
||||
).apply(block).build()
|
||||
}
|
||||
```
|
||||
|
||||
### Building Icons
|
||||
|
||||
```kotlin
|
||||
fun customIcon(fgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
|
||||
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
|
||||
builder.addPath(pathData3, fill = Black, fillAlpha = 0.2f)
|
||||
}
|
||||
|
||||
private val pathData1 = PathData {
|
||||
moveTo(144.5f, 87.5f)
|
||||
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
|
||||
lineToRelative(16.0f, 16.0f)
|
||||
close()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomIcon() {
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
customIcon(SolidColor(Color.Blue), this)
|
||||
}
|
||||
),
|
||||
contentDescription = "Custom icon"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Why ImageVector?**
|
||||
- Pure Kotlin, no XML
|
||||
- Works on Android, Desktop, iOS
|
||||
- GPU-accelerated
|
||||
- Type-safe
|
||||
|
||||
### Caching Pattern
|
||||
|
||||
```kotlin
|
||||
object CustomIcons {
|
||||
private val cache = mutableMapOf<String, ImageVector>()
|
||||
|
||||
fun get(key: String): ImageVector {
|
||||
return cache.getOrPut(key) {
|
||||
buildIcon(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CachedIcon(key: String) {
|
||||
Image(imageVector = CustomIcons.get(key), contentDescription = null)
|
||||
}
|
||||
```
|
||||
|
||||
For detailed icon patterns, see `references/icon-assets.md`.
|
||||
|
||||
## Common Visual Patterns
|
||||
|
||||
### State Visualization
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DataScreen(uiState: UiState) {
|
||||
when (uiState) {
|
||||
is UiState.Loading -> LoadingState("Loading...")
|
||||
is UiState.Empty -> EmptyState(
|
||||
title = "No data",
|
||||
onRefresh = { /* refresh */ }
|
||||
)
|
||||
is UiState.Error -> ErrorState(
|
||||
message = uiState.message,
|
||||
onRetry = { /* retry */ }
|
||||
)
|
||||
is UiState.Success -> ContentList(uiState.items)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Components** (all in `commons/commonMain`):
|
||||
- `LoadingState` - Progress indicator + message
|
||||
- `EmptyState` - Empty message + optional refresh button
|
||||
- `ErrorState` - Error message + optional retry button
|
||||
|
||||
### Relay Status (Amethyst Pattern)
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayStatusIndicator(connectedCount: Int) {
|
||||
val statusColor = when {
|
||||
connectedCount == 0 -> RelayStatusColors.Disconnected
|
||||
connectedCount < 3 -> RelayStatusColors.Connecting
|
||||
else -> RelayStatusColors.Connected
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(
|
||||
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual mapping**:
|
||||
- 0 relays → Red + X icon
|
||||
- 1-2 relays → Yellow + Check icon
|
||||
- 3+ relays → Green + Check icon
|
||||
|
||||
### Placeholder Pattern
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun PlaceholderScreen(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
// Specific implementations
|
||||
@Composable
|
||||
fun SearchPlaceholder() = PlaceholderScreen(
|
||||
title = "Search",
|
||||
description = "Search for users, notes, and hashtags."
|
||||
)
|
||||
```
|
||||
|
||||
**Pattern**: Generic composable + specific wrappers with preset text
|
||||
|
||||
## Performance
|
||||
|
||||
### Avoid Unnecessary Recomposition
|
||||
|
||||
```kotlin
|
||||
// ❌ Bad - recomposes on every scroll
|
||||
@Composable
|
||||
fun BadButton(scrollState: ScrollState) {
|
||||
if (scrollState.value > 100) {
|
||||
Button(onClick = {}) { Text("Top") }
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Good - only recomposes when visibility changes
|
||||
@Composable
|
||||
fun GoodButton(scrollState: ScrollState) {
|
||||
val show by remember { derivedStateOf { scrollState.value > 100 } }
|
||||
if (show) {
|
||||
Button(onClick = {}) { Text("Top") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Lazy Lists
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedList(items: List<Item>) {
|
||||
LazyColumn {
|
||||
items(items, key = { it.id }) { item ->
|
||||
FeedItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key principle**: Use `key` parameter for stable item identity
|
||||
|
||||
## Bundled Resources
|
||||
|
||||
- **references/shared-composables-catalog.md** - Complete catalog of shared UI components
|
||||
- **references/state-patterns.md** - State management patterns with visual examples
|
||||
- **references/icon-assets.md** - Custom ImageVector icon patterns
|
||||
- **scripts/find-composables.sh** - Find all @Composable functions in codebase
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Pattern | Location |
|
||||
|------|---------|----------|
|
||||
| Reusable UI | State hoisting | commons/commonMain |
|
||||
| Simple state | remember { mutableStateOf() } | Composable scope |
|
||||
| Derived state | derivedStateOf { } | remember block |
|
||||
| Async → state | produceState { } | Composable function |
|
||||
| Custom icons | roboBuilder + PathData | commons/icons |
|
||||
| Loading/Error | LoadingState, ErrorState | commons/ui/components |
|
||||
| Theme colors | MaterialTheme.colorScheme | Any @Composable |
|
||||
| Navigation | Delegate to platform expert | amethyst/, desktopApp/ |
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Creating a Shared Component
|
||||
|
||||
1. Start in `commons/src/commonMain/kotlin/.../ui/components/`
|
||||
2. Use Material3 primitives only
|
||||
3. Hoist state (parameters for data, callbacks for events)
|
||||
4. Add modifier parameter
|
||||
5. Use MaterialTheme for colors/typography
|
||||
6. Test on both Android and Desktop
|
||||
|
||||
### Converting Existing Component
|
||||
|
||||
1. Read current implementation in `amethyst/` or `desktopApp/`
|
||||
2. Identify pure visual logic (no platform APIs)
|
||||
3. Create in `commons/commonMain` with hoisted state
|
||||
4. Replace platform implementations with shared component
|
||||
5. Keep platform-specific wrappers if needed
|
||||
|
||||
### Custom Icon
|
||||
|
||||
1. Export SVG from design tool
|
||||
2. Convert to PathData using Android Studio
|
||||
3. Create icon function with roboBuilder
|
||||
4. Add caching if generated dynamically
|
||||
5. Wrap in @Composable for easy use
|
||||
|
||||
### Navigation (Delegate)
|
||||
|
||||
For navigation patterns:
|
||||
- Android bottom nav → `android-expert`
|
||||
- Desktop sidebar → `desktop-expert`
|
||||
- Multi-window → `desktop-expert`
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **kotlin-expert** - Kotlin language aspects (@Immutable details, StateFlow, sealed classes)
|
||||
- **android-expert** - Android navigation, platform APIs
|
||||
- **desktop-expert** - Desktop navigation, window management, OS specifics
|
||||
- **kotlin-coroutines** - Async patterns, Flow integration
|
||||
365
.claude/skills/compose-expert/references/icon-assets.md
Normal file
365
.claude/skills/compose-expert/references/icon-assets.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# Custom Icon Assets and ImageVector Patterns
|
||||
|
||||
Guide to creating and using custom ImageVector icons in Compose Multiplatform.
|
||||
|
||||
## Why ImageVector?
|
||||
|
||||
ImageVector is the native Compose format for vector graphics:
|
||||
- **Pure Kotlin**: No XML, no asset files
|
||||
- **Multiplatform**: Works on Android, Desktop, iOS without conversion
|
||||
- **Performant**: Lightweight, composable, GPU-accelerated
|
||||
- **Type-safe**: Compile-time checking, no resource IDs
|
||||
|
||||
## Amethyst Pattern: Robohash
|
||||
|
||||
Amethyst generates deterministic avatars using ImageVector builders.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
commons/robohash/
|
||||
├── RobohashAssembler.kt # Main assembly logic
|
||||
├── CachedRobohash.kt # Caching layer
|
||||
└── parts/
|
||||
├── Face0C3po.kt # Face variants (0-9)
|
||||
├── Eyes2Single.kt # Eye variants (0-9)
|
||||
├── Mouth3Grid.kt # Mouth variants (0-9)
|
||||
├── Body2Thinnest.kt # Body variants (0-9)
|
||||
└── Accessory7Antenna.kt # Accessory variants (0-9)
|
||||
```
|
||||
|
||||
**Pattern**: 10 variants per feature × 5 features = 100,000+ unique combinations
|
||||
|
||||
### roboBuilder DSL
|
||||
|
||||
Custom ImageVector builder with sensible defaults:
|
||||
|
||||
```kotlin
|
||||
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = "Robohash",
|
||||
defaultWidth = 300.dp,
|
||||
defaultHeight = 300.dp,
|
||||
viewportWidth = 300f,
|
||||
viewportHeight = 300f
|
||||
).apply(block).build()
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun CustomIcon() {
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
// Add paths here
|
||||
}
|
||||
),
|
||||
contentDescription = "Custom icon"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Path Building Pattern
|
||||
|
||||
```kotlin
|
||||
fun face0C3po(fgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
|
||||
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
|
||||
builder.addPath(pathData5, fill = Black, fillAlpha = 0.2f)
|
||||
builder.addPath(pathData6, stroke = Black, strokeLineWidth = 1.0f)
|
||||
builder.addPath(pathData7, fill = Black, stroke = Black, fillAlpha = 0.2f, strokeLineWidth = 0.75f)
|
||||
}
|
||||
|
||||
private val pathData1 = PathData {
|
||||
moveTo(144.5f, 87.5f)
|
||||
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
|
||||
curveToRelative(0.0f, 0.0f, 0.0f, 27.0f, 5.0f, 42.0f)
|
||||
reflectiveCurveToRelative(10.0f, 38.0f, 10.0f, 38.0f)
|
||||
lineToRelative(16.0f, 16.0f)
|
||||
// ...
|
||||
close()
|
||||
}
|
||||
```
|
||||
|
||||
**Key elements**:
|
||||
- `pathData` variables for path commands
|
||||
- `addPath()` for each layer
|
||||
- Parameterized colors (`fgColor`)
|
||||
- Constant colors (`Black`)
|
||||
- Alpha for shadows/highlights
|
||||
|
||||
### PathData DSL
|
||||
|
||||
Compose's PathData builder provides SVG-like commands:
|
||||
|
||||
| Command | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| `moveTo(x, y)` | Move pen without drawing | `moveTo(100f, 100f)` |
|
||||
| `lineTo(x, y)` | Draw line to point | `lineTo(200f, 150f)` |
|
||||
| `curveToRelative(...)` | Relative cubic Bézier | `curveToRelative(10f, 20f, 30f, 40f, 50f, 60f)` |
|
||||
| `reflectiveCurveToRelative(...)` | Smooth curve | `reflectiveCurveToRelative(-51f, 3f, -53f, 55f)` |
|
||||
| `horizontalLineTo(x)` | Horizontal line | `horizontalLineTo(250f)` |
|
||||
| `verticalLineTo(y)` | Vertical line | `verticalLineTo(300f)` |
|
||||
| `close()` | Close path | `close()` |
|
||||
|
||||
**Relative vs Absolute**:
|
||||
- `moveTo` / `lineTo` - Absolute coordinates
|
||||
- `moveToRelative` / `lineToRelative` - Relative to current position
|
||||
|
||||
## Creating Custom Icons
|
||||
|
||||
### Method 1: From SVG (Recommended)
|
||||
|
||||
1. **Export SVG** from design tool (Figma, Illustrator)
|
||||
2. **Convert to ImageVector** using Android Studio's Vector Asset tool
|
||||
3. **Extract path data** and adapt to roboBuilder pattern
|
||||
|
||||
```kotlin
|
||||
// SVG path: M 10 10 L 20 20 ...
|
||||
// Becomes:
|
||||
private val myIconPath = PathData {
|
||||
moveTo(10f, 10f)
|
||||
lineTo(20f, 20f)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Programmatic
|
||||
|
||||
Build paths programmatically for simple shapes:
|
||||
|
||||
```kotlin
|
||||
fun simpleIcon(): ImageVector = roboBuilder {
|
||||
addPath(
|
||||
pathData = PathData {
|
||||
moveTo(50f, 50f)
|
||||
lineTo(150f, 50f)
|
||||
lineTo(150f, 150f)
|
||||
lineTo(50f, 150f)
|
||||
close()
|
||||
},
|
||||
fill = SolidColor(Color.Blue),
|
||||
stroke = SolidColor(Color.Black),
|
||||
strokeLineWidth = 2f
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Method 3: Material Icons Extensions
|
||||
|
||||
Extend Material Icons when you need platform-consistent icons:
|
||||
|
||||
```kotlin
|
||||
// For standard icons, use Material Icons
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
Icon(Icons.Default.Check, contentDescription = "Success")
|
||||
Icon(Icons.Default.Close, contentDescription = "Error")
|
||||
```
|
||||
|
||||
## CachedRobohash Pattern
|
||||
|
||||
Performance optimization for generated icons:
|
||||
|
||||
```kotlin
|
||||
object CachedRobohash {
|
||||
private val cache = mutableMapOf<Pair<String, Boolean>, ImageVector>()
|
||||
|
||||
fun get(seed: String, isLight: Boolean): ImageVector {
|
||||
return cache.getOrPut(seed to isLight) {
|
||||
RobohashAssembler.assemble(seed, isLight)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**:
|
||||
- Key: `(seed, theme)` pair
|
||||
- Value: Assembled ImageVector
|
||||
- Lifecycle: Application lifetime (never cleared)
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RobohashImage(robot: String) {
|
||||
Image(
|
||||
imageVector = CachedRobohash.get(robot, isLightTheme()),
|
||||
contentDescription = "Avatar for $robot"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Color Management
|
||||
|
||||
### Dynamic Colors
|
||||
|
||||
Pass colors as parameters for theme adaptation:
|
||||
|
||||
```kotlin
|
||||
fun themedIcon(fgColor: SolidColor, bgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = bgColor)
|
||||
builder.addPath(pathData2, fill = fgColor)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemedIcon() {
|
||||
val fg = MaterialTheme.colorScheme.primary
|
||||
val bg = MaterialTheme.colorScheme.surface
|
||||
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
themedIcon(SolidColor(fg), SolidColor(bg), this)
|
||||
}
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Static Colors
|
||||
|
||||
Define constants for colors that don't change:
|
||||
|
||||
```kotlin
|
||||
val Black = SolidColor(Color.Black)
|
||||
val White = SolidColor(Color.White)
|
||||
val Transparent = SolidColor(Color.Transparent)
|
||||
```
|
||||
|
||||
## Advanced Techniques
|
||||
|
||||
### Layering
|
||||
|
||||
Build complex icons with multiple layers:
|
||||
|
||||
```kotlin
|
||||
fun complexIcon(builder: Builder) {
|
||||
// Layer 1: Background
|
||||
builder.addPath(bgPath, fill = SolidColor(Color.White))
|
||||
|
||||
// Layer 2: Shadow
|
||||
builder.addPath(shadowPath, fill = SolidColor(Color.Black), fillAlpha = 0.2f)
|
||||
|
||||
// Layer 3: Main shape
|
||||
builder.addPath(mainPath, fill = SolidColor(Color.Blue))
|
||||
|
||||
// Layer 4: Highlight
|
||||
builder.addPath(highlightPath, fill = SolidColor(Color.White), fillAlpha = 0.3f)
|
||||
|
||||
// Layer 5: Stroke
|
||||
builder.addPath(outlinePath, stroke = SolidColor(Color.Black), strokeLineWidth = 1f)
|
||||
}
|
||||
```
|
||||
|
||||
**Render order**: Bottom to top (first addPath = bottom layer)
|
||||
|
||||
### Alpha for Visual Effects
|
||||
|
||||
```kotlin
|
||||
// Shadow
|
||||
builder.addPath(shadowPath, fill = Black, fillAlpha = 0.4f)
|
||||
|
||||
// Highlight
|
||||
builder.addPath(highlightPath, fill = White, fillAlpha = 0.2f)
|
||||
|
||||
// Glass effect
|
||||
builder.addPath(glassPath, fill = White, fillAlpha = 0.1f)
|
||||
```
|
||||
|
||||
### Stroke Styles
|
||||
|
||||
```kotlin
|
||||
// Outline only
|
||||
builder.addPath(path, stroke = Black, strokeLineWidth = 1.5f)
|
||||
|
||||
// Fill + outline
|
||||
builder.addPath(path, fill = fgColor, stroke = Black, strokeLineWidth = 1f)
|
||||
|
||||
// Dashed (not supported directly, use multiple segments)
|
||||
```
|
||||
|
||||
## Composable Icon Pattern
|
||||
|
||||
Wrap ImageVector in a Composable for reusability:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MyCustomIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = Color.Unspecified
|
||||
) {
|
||||
Image(
|
||||
painter = rememberVectorPainter(myIconVector()),
|
||||
contentDescription = "My custom icon",
|
||||
modifier = modifier,
|
||||
colorFilter = if (tint != Color.Unspecified) {
|
||||
ColorFilter.tint(tint)
|
||||
} else null
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
MyCustomIcon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO
|
||||
✅ Cache generated ImageVectors for performance
|
||||
✅ Use PathData DSL for readability
|
||||
✅ Parameterize colors for theme support
|
||||
✅ Use Material Icons for standard icons
|
||||
✅ Keep viewport size consistent (e.g., 300×300)
|
||||
✅ Layer paths from back to front
|
||||
✅ Use alpha for shadows and highlights
|
||||
|
||||
### DON'T
|
||||
❌ Generate ImageVectors in @Composable without caching
|
||||
❌ Hardcode theme-specific colors
|
||||
❌ Create custom icons for standard Material icons
|
||||
❌ Use extreme viewport sizes (stay 24-1000dp)
|
||||
❌ Mix absolute and relative coordinates unnecessarily
|
||||
❌ Forget to close() paths
|
||||
|
||||
## Icon Organization
|
||||
|
||||
### Structure
|
||||
```
|
||||
commons/icons/
|
||||
├── CustomIcons.kt # Icon collection object
|
||||
├── icons/
|
||||
│ ├── Zap.kt # Lightning bolt
|
||||
│ ├── Relay.kt # Relay indicator
|
||||
│ └── Bitcoin.kt # Bitcoin symbol
|
||||
└── builders/
|
||||
└── IconBuilder.kt # Shared builder utilities
|
||||
```
|
||||
|
||||
### Collection Object
|
||||
```kotlin
|
||||
object CustomIcons {
|
||||
val Zap: ImageVector by lazy { ZapIcon.create() }
|
||||
val Relay: ImageVector by lazy { RelayIcon.create() }
|
||||
val Bitcoin: ImageVector by lazy { BitcoinIcon.create() }
|
||||
}
|
||||
|
||||
// Usage
|
||||
Icon(CustomIcons.Zap, contentDescription = "Zap")
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Compose ImageVector API](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/vector/ImageVector)
|
||||
- [SVG Path Commands](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths)
|
||||
- [Material Icons](https://fonts.google.com/icons)
|
||||
- Robohash implementation: `commons/robohash/` in AmethystMultiplatform
|
||||
@@ -0,0 +1,281 @@
|
||||
# Shared Composables Catalog
|
||||
|
||||
This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
commons/src/commonMain/kotlin/.../commons/ui/
|
||||
├── components/ # Reusable UI components
|
||||
├── screens/ # Screen-level composables
|
||||
├── theme/ # Theming and styling
|
||||
└── feed/ # Feed-specific components
|
||||
```
|
||||
|
||||
## Components (`ui/components/`)
|
||||
|
||||
### State Visualization
|
||||
|
||||
**LoadingState** - Centered loading indicator with message
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoadingState(message: String, modifier: Modifier = Modifier)
|
||||
```
|
||||
- Use for: Async operations, data fetching
|
||||
- Pattern: fillMaxSize, centered Column, CircularProgressIndicator
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**EmptyState** - Centered empty state with optional refresh
|
||||
```kotlin
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
description: String? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
refreshLabel: String = "Refresh"
|
||||
)
|
||||
```
|
||||
- Use for: Empty lists, no data scenarios
|
||||
- Pattern: Centered Column, optional OutlinedButton
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**ErrorState** - Centered error message with retry
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ErrorState(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
retryLabel: String = "Try Again"
|
||||
)
|
||||
```
|
||||
- Use for: Error handling, failed operations
|
||||
- Pattern: error color, optional Button
|
||||
- Works on: Android, Desktop
|
||||
|
||||
### Feed-Specific States
|
||||
|
||||
**FeedEmptyState** - Pre-configured empty state for feeds
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedEmptyState(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Feed is empty",
|
||||
onRefresh: (() -> Unit)? = null
|
||||
)
|
||||
```
|
||||
|
||||
**FeedErrorState** - Pre-configured error state for feeds
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedErrorState(
|
||||
errorMessage: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null
|
||||
)
|
||||
```
|
||||
|
||||
### Action Buttons
|
||||
|
||||
**Shared Constants**:
|
||||
```kotlin
|
||||
val ActionButtonShape = RoundedCornerShape(20.dp)
|
||||
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
|
||||
```
|
||||
|
||||
**AddButton** - Consistent "Add" action button
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Add",
|
||||
enabled: Boolean = true
|
||||
)
|
||||
```
|
||||
- Pattern: OutlinedButton with consistent shape/padding
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**RemoveButton** - Consistent "Remove" action button
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RemoveButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Remove",
|
||||
enabled: Boolean = true
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Images
|
||||
|
||||
**RobohashImage** - Deterministic avatar generation
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RobohashImage(
|
||||
robot: String, // Seed (e.g., pubkey)
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
loadRobohash: Boolean = true
|
||||
)
|
||||
|
||||
// Overload with more options
|
||||
@Composable
|
||||
fun RobohashImage(
|
||||
robot: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
colorFilter: ColorFilter? = null,
|
||||
loadRobohash: Boolean = true
|
||||
)
|
||||
```
|
||||
- Use for: User avatars, deterministic graphics
|
||||
- Pattern: Uses CachedRobohash.get(), isLightTheme() detection
|
||||
- Fallback: Icons.Default.Face
|
||||
- Works on: Android, Desktop (pure ImageVector)
|
||||
|
||||
**Theme Detection Helper**:
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun isLightTheme(): Boolean {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
||||
}
|
||||
```
|
||||
|
||||
## Feed Components (`ui/feed/`)
|
||||
|
||||
### FeedHeader
|
||||
|
||||
**FeedHeader** - Screen header with title and relay status
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedHeader(
|
||||
title: String,
|
||||
connectedRelayCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
```
|
||||
- Pattern: Row with SpaceBetween, title + RelayStatusIndicator
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**RelayStatusIndicator** - Compact relay connection indicator
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayStatusIndicator(
|
||||
connectedCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
```
|
||||
- Pattern: Status icon + count text + refresh button
|
||||
- Colors: RelayStatusColors.{Disconnected, Connecting, Connected}
|
||||
- Visual cues: Check icon (connected), Close icon (disconnected)
|
||||
|
||||
## Screens (`ui/screens/`)
|
||||
|
||||
### Placeholder Pattern
|
||||
|
||||
**PlaceholderScreen** - Generic placeholder
|
||||
```kotlin
|
||||
@Composable
|
||||
fun PlaceholderScreen(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
```
|
||||
- Pattern: Column with title (headlineMedium) + description
|
||||
- Use for: Unimplemented screens, coming soon features
|
||||
|
||||
**Specific Placeholders**:
|
||||
- `SearchPlaceholder()` - Search screen
|
||||
- `MessagesPlaceholder()` - DMs screen
|
||||
- `NotificationsPlaceholder()` - Notifications screen
|
||||
|
||||
Pattern: Specific implementations wrap PlaceholderScreen with preset text.
|
||||
|
||||
## Custom Icons (`robohash/parts/`)
|
||||
|
||||
### ImageVector Builder Pattern
|
||||
|
||||
Amethyst uses a custom DSL for building ImageVector assets:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Face0C3po() {
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
face0C3po(SolidColor(Color.Blue), this)
|
||||
}
|
||||
),
|
||||
contentDescription = ""
|
||||
)
|
||||
}
|
||||
|
||||
fun face0C3po(fgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
|
||||
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
|
||||
// ...
|
||||
}
|
||||
|
||||
private val pathData1 = PathData {
|
||||
moveTo(144.5f, 87.5f)
|
||||
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
|
||||
// ... path commands
|
||||
}
|
||||
```
|
||||
|
||||
**roboBuilder** - Custom ImageVector.Builder DSL
|
||||
- Located in: `commons/robohash/`
|
||||
- Pattern: Builder-based, composable paths
|
||||
- Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each)
|
||||
- Colors: Dynamic (fgColor parameter) + Black constants
|
||||
|
||||
### CachedRobohash
|
||||
|
||||
```kotlin
|
||||
CachedRobohash.get(seed: String, isLight: Boolean): ImageVector
|
||||
```
|
||||
- Deterministic: Same seed → same avatar
|
||||
- Theme-aware: Different colors for light/dark
|
||||
- Cached: Performance optimization
|
||||
- Pure ImageVector: Works on all platforms
|
||||
|
||||
## Sharing Guidelines
|
||||
|
||||
### Always Share
|
||||
- State visualization (Loading, Empty, Error)
|
||||
- Action buttons with consistent styling
|
||||
- Generic placeholders
|
||||
- Custom ImageVector icons
|
||||
- Material3 themed components
|
||||
- Theme utilities (isLightTheme)
|
||||
|
||||
### Platform-Specific (Delegate to Experts)
|
||||
- Navigation structure (android-expert, desktop-expert)
|
||||
- Screen layouts and scaffolds
|
||||
- Platform system integrations
|
||||
- Gesture handling specifics
|
||||
|
||||
### Decision Framework
|
||||
1. **Can it use Material3 primitives?** → Share
|
||||
2. **Does it need platform system APIs?** → Platform-specific
|
||||
3. **Is it a visual component without navigation?** → Share
|
||||
4. **Does it require platform UX patterns?** → Ask platform expert
|
||||
|
||||
## Material3 Usage
|
||||
|
||||
All shared composables use Material3:
|
||||
- `MaterialTheme.colorScheme.*` for colors
|
||||
- `MaterialTheme.typography.*` for text styles
|
||||
- `OutlinedButton`, `Button`, `IconButton` for actions
|
||||
- `CircularProgressIndicator` for loading
|
||||
- `Icon`, `Image` for visuals
|
||||
|
||||
This ensures consistent theming across Android and Desktop.
|
||||
334
.claude/skills/compose-expert/references/state-patterns.md
Normal file
334
.claude/skills/compose-expert/references/state-patterns.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# Compose State Management Patterns
|
||||
|
||||
Visual guide to state management in Compose Multiplatform. For Kotlin-specific patterns (StateFlow, sealed classes), see `kotlin-expert` skill.
|
||||
|
||||
## Core State Functions
|
||||
|
||||
### remember
|
||||
|
||||
Cache values across recompositions:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Counter() {
|
||||
var count by remember { mutableStateOf(0) }
|
||||
|
||||
Button(onClick = { count++ }) {
|
||||
Text("Clicked $count times")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Simple UI state (toggles, counters, text input)
|
||||
**Visual pattern**: Button press → state changes → UI updates
|
||||
|
||||
### derivedStateOf
|
||||
|
||||
Compute state from other state, recompose only when result changes:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ScrollToTopButton(listState: LazyListState) {
|
||||
// Only recomposes when showButton value changes (not every scroll pixel)
|
||||
val showButton by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0
|
||||
}
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
FloatingActionButton(onClick = { /* scroll to top */ }) {
|
||||
Icon(Icons.Default.ArrowUpward, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Input state changes frequently, but derived result changes rarely
|
||||
**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → FAB visibility
|
||||
**Performance**: Prevents recomposition on every scroll event
|
||||
|
||||
### produceState
|
||||
|
||||
Convert non-Compose state into Compose state:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoadUserProfile(userId: String): State<User?> {
|
||||
return produceState<User?>(initialValue = null, userId) {
|
||||
value = repository.fetchUser(userId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(userId: String) {
|
||||
val user by LoadUserProfile(userId)
|
||||
|
||||
when (user) {
|
||||
null -> LoadingState("Loading profile...")
|
||||
else -> ProfileCard(user!!)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Convert Flow, LiveData, callbacks into Compose state
|
||||
**Visual pattern**: Async operation → state updates → UI reflects changes
|
||||
**Lifecycle**: Coroutine cancelled when composable leaves composition
|
||||
|
||||
## State Hoisting Pattern
|
||||
|
||||
Move state up to make composables reusable and testable:
|
||||
|
||||
### Before (Stateful)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchBar() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search...") }
|
||||
)
|
||||
}
|
||||
```
|
||||
❌ Hard to test, can't control state externally
|
||||
|
||||
### After (Stateless)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Search...") },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchScreen() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
Column {
|
||||
SearchBar(query = query, onQueryChange = { query = it })
|
||||
SearchResults(query = query)
|
||||
}
|
||||
}
|
||||
```
|
||||
✅ Reusable, testable, state controlled by parent
|
||||
|
||||
**Hoisting principle**: State goes up, events go down
|
||||
- State: `query: String` (read-only)
|
||||
- Events: `onQueryChange: (String) -> Unit` (write-only)
|
||||
|
||||
## Amethyst State Patterns
|
||||
|
||||
### Theme-Aware State
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun isLightTheme(): Boolean {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemedContent() {
|
||||
val isDark = !isLightTheme()
|
||||
// Adjust visuals based on theme
|
||||
val iconTint = if (isDark) Color.White else Color.Black
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Derive state from MaterialTheme
|
||||
**Visual**: Component adapts to light/dark theme automatically
|
||||
|
||||
### Relay Status State
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayStatusIndicator(
|
||||
connectedCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val statusColor = when {
|
||||
connectedCount == 0 -> RelayStatusColors.Disconnected
|
||||
connectedCount < 3 -> RelayStatusColors.Connecting
|
||||
else -> RelayStatusColors.Connected
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
|
||||
tint = statusColor
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Visual state derived from domain state
|
||||
**Visual mapping**:
|
||||
- 0 relays → Red + X icon
|
||||
- 1-2 relays → Yellow + Check icon
|
||||
- 3+ relays → Green + Check icon
|
||||
|
||||
### Loading/Empty/Error States
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedScreen(viewModel: FeedViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
when (uiState) {
|
||||
is UiState.Loading -> LoadingState("Loading feed...")
|
||||
is UiState.Empty -> FeedEmptyState(onRefresh = { viewModel.refresh() })
|
||||
is UiState.Error -> FeedErrorState(
|
||||
errorMessage = uiState.message,
|
||||
onRetry = { viewModel.retry() }
|
||||
)
|
||||
is UiState.Success -> LazyColumn {
|
||||
items(uiState.items) { FeedItem(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Sealed class → visual state component
|
||||
**Components**:
|
||||
- `LoadingState` - Progress indicator
|
||||
- `EmptyState` - Empty message + refresh
|
||||
- `ErrorState` - Error message + retry
|
||||
- Success - Actual content
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Toggle State
|
||||
```kotlin
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(onClick = { isExpanded = !isExpanded }) {
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand"
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
Text("Expanded content...")
|
||||
}
|
||||
```
|
||||
|
||||
### List State with Actions
|
||||
```kotlin
|
||||
var items by remember { mutableStateOf(listOf("Item 1", "Item 2")) }
|
||||
|
||||
Column {
|
||||
AddButton(onClick = {
|
||||
items = items + "Item ${items.size + 1}"
|
||||
})
|
||||
|
||||
items.forEachIndexed { index, item ->
|
||||
Row {
|
||||
Text(item)
|
||||
RemoveButton(onClick = {
|
||||
items = items.filterIndexed { i, _ -> i != index }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TextField State
|
||||
```kotlin
|
||||
var text by remember { mutableStateOf("") }
|
||||
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Enter text") }
|
||||
)
|
||||
```
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### Avoid Unnecessary Recomposition
|
||||
```kotlin
|
||||
// ❌ Bad: Recomposes on every scroll position change
|
||||
@Composable
|
||||
fun BadScrollButton(scrollState: ScrollState) {
|
||||
if (scrollState.value > 100) { // scrollState.value changes constantly
|
||||
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Good: Only recomposes when visibility changes
|
||||
@Composable
|
||||
fun GoodScrollButton(scrollState: ScrollState) {
|
||||
val showButton by remember {
|
||||
derivedStateOf { scrollState.value > 100 }
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Stable Parameters
|
||||
Use `@Immutable` data classes (see `kotlin-expert`) to prevent recomposition:
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class UserProfile(val name: String, val avatar: String)
|
||||
|
||||
@Composable
|
||||
fun ProfileCard(profile: UserProfile) {
|
||||
// Only recomposes when profile instance changes
|
||||
Row {
|
||||
RobohashImage(robot = profile.avatar)
|
||||
Text(profile.name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Kotlin State
|
||||
|
||||
For ViewModel state, Flow, StateFlow → See `kotlin-expert` skill
|
||||
|
||||
Common integration pattern:
|
||||
```kotlin
|
||||
// ViewModel (Kotlin state)
|
||||
class FeedViewModel {
|
||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
}
|
||||
|
||||
// Composable (Compose state)
|
||||
@Composable
|
||||
fun FeedScreen(viewModel: FeedViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
// Use uiState to render UI
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Function | Use Case | Recomposes When |
|
||||
|----------|----------|----------------|
|
||||
| `remember { mutableStateOf() }` | Local UI state | State value changes |
|
||||
| `derivedStateOf { }` | Computed state | Derived result changes |
|
||||
| `produceState { }` | Async/Flow → State | Async operation updates value |
|
||||
| `collectAsState()` | Flow → State | Flow emits new value |
|
||||
| State hoisting | Reusable components | Parent passes new state |
|
||||
|
||||
## Sources
|
||||
|
||||
State management patterns based on:
|
||||
- [State and Jetpack Compose - Android Developers](https://developer.android.com/develop/ui/compose/state)
|
||||
- [When should I use derivedStateOf?](https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b)
|
||||
- [Advanced State and Side Effects](https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects)
|
||||
- AmethystMultiplatform codebase patterns (2025)
|
||||
34
.claude/skills/compose-expert/scripts/find-composables.sh
Executable file
34
.claude/skills/compose-expert/scripts/find-composables.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Find all @Composable functions in the codebase
|
||||
|
||||
set -e
|
||||
|
||||
# Default to current directory if no path provided
|
||||
SEARCH_PATH="${1:-.}"
|
||||
|
||||
echo "Searching for @Composable functions in: $SEARCH_PATH"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Find all @Composable functions with file paths and line numbers
|
||||
grep -r -n "@Composable" "$SEARCH_PATH" \
|
||||
--include="*.kt" \
|
||||
--exclude-dir=build \
|
||||
--exclude-dir=.gradle \
|
||||
| while IFS=: read -r file line content; do
|
||||
# Extract function name if possible
|
||||
if [[ $content =~ fun[[:space:]]+([a-zA-Z0-9_]+) ]]; then
|
||||
func_name="${BASH_REMATCH[1]}"
|
||||
echo "$file:$line - $func_name"
|
||||
else
|
||||
echo "$file:$line"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Total @Composable functions found:"
|
||||
grep -r "@Composable" "$SEARCH_PATH" \
|
||||
--include="*.kt" \
|
||||
--exclude-dir=build \
|
||||
--exclude-dir=.gradle \
|
||||
| wc -l
|
||||
748
.claude/skills/desktop-expert/SKILL.md
Normal file
748
.claude/skills/desktop-expert/SKILL.md
Normal file
@@ -0,0 +1,748 @@
|
||||
# Desktop Expert
|
||||
|
||||
Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
**Auto-invoke when:**
|
||||
- Working with `desktopApp/` module files
|
||||
- Using Desktop-only APIs: `Window`, `Tray`, `MenuBar`, `Dialog`
|
||||
- Implementing keyboard shortcuts, menu systems
|
||||
- Desktop navigation (NavigationRail, multi-window)
|
||||
- File system operations (file pickers, drag-drop)
|
||||
- OS-specific behavior (macOS, Windows, Linux)
|
||||
- Desktop UX patterns (keyboard-first, tooltips)
|
||||
|
||||
**Delegate to:**
|
||||
- **kotlin-multiplatform**: Shared code questions, `jvmMain` source set structure
|
||||
- **gradle-expert**: All `build.gradle.kts` issues, dependency conflicts
|
||||
- **compose-expert**: General Compose patterns, `@Composable` best practices, Material3
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
- Desktop-only Compose APIs
|
||||
- Window management, positioning, state
|
||||
- MenuBar + keyboard shortcuts (OS-specific)
|
||||
- System Tray integration
|
||||
- Desktop navigation patterns (NavigationRail)
|
||||
- File dialogs, Desktop.getDesktop()
|
||||
- OS conventions (macOS vs Windows vs Linux)
|
||||
- Desktop UX principles
|
||||
|
||||
**Out of scope:**
|
||||
- Build configuration → **gradle-expert**
|
||||
- Shared composables → **compose-expert**
|
||||
- KMP structure → **kotlin-multiplatform**
|
||||
|
||||
---
|
||||
|
||||
## 1. Desktop Entry Point
|
||||
|
||||
### application {} DSL
|
||||
|
||||
Desktop apps start with the `application {}` block:
|
||||
|
||||
```kotlin
|
||||
// desktopApp/src/jvmMain/kotlin/Main.kt
|
||||
fun main() = application {
|
||||
val windowState = rememberWindowState(
|
||||
width = 1200.dp,
|
||||
height = 800.dp,
|
||||
position = WindowPosition.Aligned(Alignment.Center)
|
||||
)
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = windowState,
|
||||
title = "Amethyst"
|
||||
) {
|
||||
MenuBar { /* ... */ }
|
||||
App()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- `application {}` is the root composable (JVM-only)
|
||||
- `Window()` creates the main window
|
||||
- `rememberWindowState()` manages size/position
|
||||
- `onCloseRequest` handles window close
|
||||
|
||||
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:87-138`
|
||||
|
||||
---
|
||||
|
||||
## 2. Window Management
|
||||
|
||||
### WindowState
|
||||
|
||||
```kotlin
|
||||
val windowState = rememberWindowState(
|
||||
width = 1200.dp,
|
||||
height = 800.dp,
|
||||
position = WindowPosition.Aligned(Alignment.Center)
|
||||
)
|
||||
|
||||
Window(
|
||||
state = windowState,
|
||||
title = "My App",
|
||||
resizable = true,
|
||||
onCloseRequest = ::exitApplication
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Windows
|
||||
|
||||
```kotlin
|
||||
fun main() = application {
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
|
||||
Window(onCloseRequest = ::exitApplication, title = "Main") {
|
||||
Button(onClick = { showSettings = true }) {
|
||||
Text("Open Settings")
|
||||
}
|
||||
}
|
||||
|
||||
if (showSettings) {
|
||||
Window(
|
||||
onCloseRequest = { showSettings = false },
|
||||
title = "Settings"
|
||||
) {
|
||||
// Settings UI
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:** Use state to control window visibility conditionally.
|
||||
|
||||
---
|
||||
|
||||
## 3. MenuBar System
|
||||
|
||||
### Basic MenuBar
|
||||
|
||||
```kotlin
|
||||
Window(onCloseRequest = ::exitApplication, title = "App") {
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item("New Note", onClick = { /* ... */ })
|
||||
Separator()
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
}
|
||||
Menu("Edit") {
|
||||
Item("Copy", onClick = { /* ... */ })
|
||||
Item("Paste", onClick = { /* ... */ })
|
||||
}
|
||||
}
|
||||
App()
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts (OS-Aware)
|
||||
|
||||
**Current issue:** Main.kt hardcodes `ctrl = true` (Main.kt:105, 111, 117, 122, 123).
|
||||
|
||||
**OS-specific shortcuts:**
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyShortcut
|
||||
|
||||
// Detect OS
|
||||
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item(
|
||||
"New Note",
|
||||
shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.N, meta = true) // Cmd+N on macOS
|
||||
} else {
|
||||
KeyShortcut(Key.N, ctrl = true) // Ctrl+N on Win/Linux
|
||||
},
|
||||
onClick = { /* ... */ }
|
||||
)
|
||||
Item(
|
||||
"Settings",
|
||||
shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.Comma, meta = true) // Cmd+, on macOS
|
||||
} else {
|
||||
KeyShortcut(Key.Comma, ctrl = true) // Ctrl+, on Win/Linux
|
||||
},
|
||||
onClick = { /* ... */ }
|
||||
)
|
||||
Separator()
|
||||
Item(
|
||||
"Quit",
|
||||
shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.Q, meta = true) // Cmd+Q on macOS
|
||||
} else {
|
||||
KeyShortcut(Key.Q, ctrl = true) // Ctrl+Q on Win/Linux
|
||||
},
|
||||
onClick = ::exitApplication
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Standard shortcuts:**
|
||||
|
||||
| Action | macOS | Windows/Linux |
|
||||
|--------|-------|---------------|
|
||||
| New | Cmd+N | Ctrl+N |
|
||||
| Open | Cmd+O | Ctrl+O |
|
||||
| Save | Cmd+S | Ctrl+S |
|
||||
| Quit | Cmd+Q | Ctrl+Q (Alt+F4) |
|
||||
| Settings | Cmd+, | Ctrl+, |
|
||||
| Copy | Cmd+C | Ctrl+C |
|
||||
| Paste | Cmd+V | Ctrl+V |
|
||||
| Undo | Cmd+Z | Ctrl+Z |
|
||||
|
||||
**See:** `references/keyboard-shortcuts.md` for full list.
|
||||
|
||||
---
|
||||
|
||||
## 4. System Tray
|
||||
|
||||
### Basic Tray
|
||||
|
||||
```kotlin
|
||||
application {
|
||||
var isVisible by remember { mutableStateOf(true) }
|
||||
|
||||
Tray(
|
||||
icon = painterResource("icon.png"),
|
||||
onAction = { isVisible = true },
|
||||
menu = {
|
||||
Item("Show", onClick = { isVisible = true })
|
||||
Separator()
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
}
|
||||
)
|
||||
|
||||
if (isVisible) {
|
||||
Window(
|
||||
onCloseRequest = { isVisible = false }, // Minimize to tray
|
||||
title = "App"
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:** Hide window to tray instead of closing.
|
||||
|
||||
**Current status:** Not implemented in Main.kt. Planned feature.
|
||||
|
||||
---
|
||||
|
||||
## 5. Desktop Navigation Patterns
|
||||
|
||||
### NavigationRail (Current Pattern)
|
||||
|
||||
Desktop uses **NavigationRail** (vertical sidebar) instead of Android's bottom navigation.
|
||||
|
||||
```kotlin
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// Sidebar
|
||||
NavigationRail(
|
||||
modifier = Modifier.width(80.dp).fillMaxHeight(),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Home, "Feed") },
|
||||
label = { Text("Feed") },
|
||||
selected = currentScreen == AppScreen.Feed,
|
||||
onClick = { currentScreen = AppScreen.Feed }
|
||||
)
|
||||
// More items...
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// Main content area
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
when (currentScreen) {
|
||||
AppScreen.Feed -> FeedScreen()
|
||||
// Other screens...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**See:** Main.kt:191-264
|
||||
|
||||
**Why NavigationRail?**
|
||||
- Desktop has horizontal space (1200+ dp width)
|
||||
- Vertical sidebar is standard desktop pattern
|
||||
- Always visible (no tabs hidden)
|
||||
- Icon + label both visible
|
||||
|
||||
**Android comparison:**
|
||||
- Android: `BottomNavigationBar` (horizontal, bottom)
|
||||
- Desktop: `NavigationRail` (vertical, left)
|
||||
|
||||
### Multi-Pane Layouts
|
||||
|
||||
Desktop can leverage wide screens:
|
||||
|
||||
```kotlin
|
||||
Row {
|
||||
// Left: Navigation
|
||||
NavigationRail { /* ... */ }
|
||||
|
||||
// Center: Main content
|
||||
Box(Modifier.weight(0.6f)) {
|
||||
FeedScreen()
|
||||
}
|
||||
|
||||
// Right: Details pane (desktop only)
|
||||
if (selectedNote != null) {
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(0.4f)) {
|
||||
NoteDetailPane(selectedNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**See:** `references/desktop-navigation.md`
|
||||
|
||||
---
|
||||
|
||||
## 6. File System Integration
|
||||
|
||||
### File Dialogs
|
||||
|
||||
```kotlin
|
||||
// File picker (load)
|
||||
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
|
||||
fileDialog.isVisible = true
|
||||
val filePath = fileDialog.file?.let { "${fileDialog.directory}$it" }
|
||||
|
||||
// File picker (save)
|
||||
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
|
||||
saveDialog.isVisible = true
|
||||
val savePath = saveDialog.file?.let { "${saveDialog.directory}$it" }
|
||||
```
|
||||
|
||||
**Note:** Compose Desktop doesn't have native file picker composable yet. Use AWT `FileDialog`.
|
||||
|
||||
### Open External URLs
|
||||
|
||||
```kotlin
|
||||
// jvmMain actual implementation
|
||||
actual fun openExternalUrl(url: String) {
|
||||
if (Desktop.isDesktopSupported()) {
|
||||
Desktop.getDesktop().browse(URI(url))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:** Define `expect` in `commonMain`, implement `actual` in `jvmMain`.
|
||||
|
||||
### Drag & Drop (Future)
|
||||
|
||||
```kotlin
|
||||
// Compose Desktop drag-drop (experimental)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.onExternalDrag(
|
||||
onDragStart = { /* ... */ },
|
||||
onDrag = { /* ... */ },
|
||||
onDragExit = { /* ... */ },
|
||||
onDrop = { state ->
|
||||
val dragData = state.dragData
|
||||
// Handle dropped files
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text("Drop files here")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. OS-Specific Conventions
|
||||
|
||||
### Platform Detection
|
||||
|
||||
```kotlin
|
||||
val osName = System.getProperty("os.name").lowercase()
|
||||
|
||||
val isMacOS = osName.contains("mac")
|
||||
val isWindows = osName.contains("win")
|
||||
val isLinux = osName.contains("nux") || osName.contains("nix")
|
||||
```
|
||||
|
||||
### Menu Bar Placement
|
||||
|
||||
| OS | Behavior |
|
||||
|----|----------|
|
||||
| **macOS** | System-wide menu bar at top of screen |
|
||||
| **Windows** | In-window menu bar |
|
||||
| **Linux** | Varies by desktop environment |
|
||||
|
||||
Compose Desktop `MenuBar` adapts automatically.
|
||||
|
||||
### Keyboard Modifier Keys
|
||||
|
||||
| Modifier | macOS | Windows/Linux |
|
||||
|----------|-------|---------------|
|
||||
| Primary | `meta = true` (Cmd) | `ctrl = true` |
|
||||
| Secondary | `ctrl = true` | `alt = true` |
|
||||
| Shift | `shift = true` | `shift = true` |
|
||||
|
||||
**Best practice:** Detect OS and use appropriate modifier.
|
||||
|
||||
### System Tray Behavior
|
||||
|
||||
| OS | Tray Location |
|
||||
|----|---------------|
|
||||
| **macOS** | Top-right menu bar |
|
||||
| **Windows** | Bottom-right taskbar |
|
||||
| **Linux** | Top panel (varies) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Desktop UX Principles
|
||||
|
||||
### Keyboard-First Design
|
||||
|
||||
**Every action should have:**
|
||||
1. Mouse/touch interaction
|
||||
2. Keyboard shortcut (if frequent)
|
||||
3. Tooltip showing shortcut
|
||||
|
||||
```kotlin
|
||||
IconButton(
|
||||
onClick = { /* refresh */ },
|
||||
modifier = Modifier.tooltipArea(
|
||||
tooltip = {
|
||||
Text("Refresh (${if (isMacOS) "Cmd" else "Ctrl"}+R)")
|
||||
}
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, "Refresh")
|
||||
}
|
||||
```
|
||||
|
||||
### Tooltip Best Practices
|
||||
|
||||
- Show keyboard shortcut in tooltip
|
||||
- Use native modifier name (Cmd vs Ctrl)
|
||||
- Brief description + shortcut
|
||||
|
||||
### Context Menus
|
||||
|
||||
Right-click should show context menu:
|
||||
|
||||
```kotlin
|
||||
// Future: Compose Desktop context menu API
|
||||
Box(
|
||||
modifier = Modifier.contextMenuArea(
|
||||
items = {
|
||||
listOf(
|
||||
ContextMenuItem("Copy") { /* ... */ },
|
||||
ContextMenuItem("Paste") { /* ... */ }
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
```
|
||||
|
||||
**Current:** Use popup or custom implementation.
|
||||
|
||||
### Window State Persistence
|
||||
|
||||
Save/restore window size/position:
|
||||
|
||||
```kotlin
|
||||
// Save on close
|
||||
windowState.size // DpSize
|
||||
windowState.position // WindowPosition
|
||||
|
||||
// Restore on launch
|
||||
val savedWidth = preferences.getInt("window.width", 1200)
|
||||
val savedHeight = preferences.getInt("window.height", 800)
|
||||
|
||||
val windowState = rememberWindowState(
|
||||
width = savedWidth.dp,
|
||||
height = savedHeight.dp
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Desktop Module Structure
|
||||
|
||||
```
|
||||
desktopApp/
|
||||
├── build.gradle.kts # Desktop-only build config
|
||||
└── src/
|
||||
└── jvmMain/
|
||||
├── kotlin/
|
||||
│ └── com/vitorpamplona/amethyst/desktop/
|
||||
│ ├── Main.kt # Entry point, Window, MenuBar
|
||||
│ ├── network/
|
||||
│ │ ├── DesktopHttpClient.kt
|
||||
│ │ └── DesktopRelayConnectionManager.kt
|
||||
│ └── ui/
|
||||
│ ├── FeedScreen.kt # Desktop screen layouts
|
||||
│ └── LoginScreen.kt
|
||||
└── resources/
|
||||
├── icon.icns # macOS icon
|
||||
├── icon.ico # Windows icon
|
||||
└── icon.png # Linux icon
|
||||
```
|
||||
|
||||
**Key files:**
|
||||
- `Main.kt:87-138` - `application {}`, `Window`, `MenuBar`
|
||||
- `Main.kt:183-264` - NavigationRail pattern
|
||||
- `build.gradle.kts:45-73` - Desktop packaging config
|
||||
|
||||
---
|
||||
|
||||
## 10. Packaging & Distribution
|
||||
|
||||
### Build Configuration
|
||||
|
||||
```kotlin
|
||||
// desktopApp/build.gradle.kts
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
|
||||
packageName = "Amethyst"
|
||||
packageVersion = "1.0.0"
|
||||
|
||||
macOS {
|
||||
bundleID = "com.vitorpamplona.amethyst.desktop"
|
||||
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
|
||||
}
|
||||
|
||||
windows {
|
||||
iconFile.set(project.file("src/jvmMain/resources/icon.ico"))
|
||||
menuGroup = "Amethyst"
|
||||
}
|
||||
|
||||
linux {
|
||||
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**See:** desktopApp/build.gradle.kts:45-73
|
||||
|
||||
### Gradle Tasks
|
||||
|
||||
```bash
|
||||
# Run desktop app
|
||||
./gradlew :desktopApp:run
|
||||
|
||||
# Package for distribution
|
||||
./gradlew :desktopApp:packageDmg # macOS
|
||||
./gradlew :desktopApp:packageMsi # Windows
|
||||
./gradlew :desktopApp:packageDeb # Linux
|
||||
```
|
||||
|
||||
**Delegate packaging issues to gradle-expert.**
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: OS-Aware Shortcuts Helper
|
||||
|
||||
```kotlin
|
||||
// commons/src/jvmMain/kotlin/shortcuts/ShortcutUtils.kt
|
||||
object DesktopShortcuts {
|
||||
private val isMacOS = System.getProperty("os.name")
|
||||
.lowercase().contains("mac")
|
||||
|
||||
fun primary(key: Key) = if (isMacOS) {
|
||||
KeyShortcut(key, meta = true)
|
||||
} else {
|
||||
KeyShortcut(key, ctrl = true)
|
||||
}
|
||||
|
||||
fun primaryShift(key: Key) = if (isMacOS) {
|
||||
KeyShortcut(key, meta = true, shift = true)
|
||||
} else {
|
||||
KeyShortcut(key, ctrl = true, shift = true)
|
||||
}
|
||||
|
||||
val modifierName = if (isMacOS) "Cmd" else "Ctrl"
|
||||
}
|
||||
|
||||
// Usage in MenuBar
|
||||
Item(
|
||||
"New Note",
|
||||
shortcut = DesktopShortcuts.primary(Key.N),
|
||||
onClick = { /* ... */ }
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern: Shared Composables, Platform Layouts
|
||||
|
||||
```kotlin
|
||||
// commons/commonMain - Shared NoteCard
|
||||
@Composable
|
||||
fun NoteCard(note: NoteDisplayData) {
|
||||
// Business logic, UI component (shared)
|
||||
}
|
||||
|
||||
// desktopApp/jvmMain - Desktop layout
|
||||
@Composable
|
||||
fun FeedScreen() {
|
||||
Column {
|
||||
FeedHeader(/* ... */) // Shared from commons
|
||||
LazyColumn {
|
||||
items(notes) { note ->
|
||||
NoteCard(note) // Shared composable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// amethyst/androidMain - Android layout
|
||||
@Composable
|
||||
fun FeedScreen() {
|
||||
Scaffold(
|
||||
bottomBar = { BottomNavigationBar() } // Android-specific
|
||||
) {
|
||||
LazyColumn {
|
||||
items(notes) { note ->
|
||||
NoteCard(note) // Same shared composable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Philosophy:** Share UI components (cards, buttons), keep navigation/layout platform-specific.
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
### Official Documentation
|
||||
- [Desktop-only API | Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html)
|
||||
- [Top-level windows management](https://kotlinlang.org/docs/multiplatform/compose-desktop-top-level-windows-management.html)
|
||||
- [Tray/MenuBar Tutorial](https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Tray_Notifications_MenuBar_new/README.md)
|
||||
|
||||
### Bundled References
|
||||
- `references/desktop-compose-apis.md` - Complete Desktop API catalog
|
||||
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
|
||||
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS
|
||||
- `references/os-detection.md` - Platform detection patterns
|
||||
|
||||
### Codebase Examples
|
||||
- Main.kt:87-138 - Window, MenuBar entry point
|
||||
- Main.kt:183-264 - NavigationRail pattern
|
||||
- FeedScreen.kt:49-136 - Desktop screen layout
|
||||
- LoginScreen.kt:44-97 - Centered desktop login
|
||||
|
||||
---
|
||||
|
||||
## Questions to Ask
|
||||
|
||||
When working on desktop features:
|
||||
|
||||
1. **Should this be shared or desktop-only?**
|
||||
- Business logic → Share in `commonMain`
|
||||
- Navigation/layout → Keep in `desktopApp/jvmMain`
|
||||
|
||||
2. **Does this need OS-specific behavior?**
|
||||
- Keyboard shortcuts → Yes (Cmd vs Ctrl)
|
||||
- File paths → Yes (separators)
|
||||
- Icons → Yes (per-OS formats)
|
||||
|
||||
3. **Is there a desktop UX convention?**
|
||||
- Check MenuBar standards
|
||||
- Consider keyboard-first design
|
||||
- Tooltips for all actions
|
||||
|
||||
4. **Does this need gradle-expert?**
|
||||
- Any `build.gradle.kts` changes → Delegate
|
||||
- Packaging/distribution issues → Delegate
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
❌ **Hardcoding Ctrl everywhere**
|
||||
```kotlin
|
||||
// Main.kt:105 - Current issue
|
||||
shortcut = KeyShortcut(Key.N, ctrl = true) // Wrong on macOS
|
||||
```
|
||||
|
||||
✅ **OS-aware shortcuts**
|
||||
```kotlin
|
||||
shortcut = DesktopShortcuts.primary(Key.N)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **Using Android navigation on Desktop**
|
||||
```kotlin
|
||||
Scaffold(bottomBar = { BottomNavigationBar() }) // Wrong for desktop
|
||||
```
|
||||
|
||||
✅ **NavigationRail for desktop**
|
||||
```kotlin
|
||||
Row {
|
||||
NavigationRail { /* ... */ }
|
||||
MainContent()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **No keyboard shortcuts**
|
||||
```kotlin
|
||||
IconButton(onClick = { refresh() }) {
|
||||
Icon(Icons.Default.Refresh, "Refresh")
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Shortcuts + tooltips**
|
||||
```kotlin
|
||||
IconButton(
|
||||
onClick = { refresh() },
|
||||
modifier = Modifier.tooltipArea("Refresh (Cmd+R)")
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, "Refresh")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
When implementing desktop features:
|
||||
|
||||
1. **Read** `references/desktop-compose-apis.md` for API catalog
|
||||
2. **Check** `references/keyboard-shortcuts.md` for standard shortcuts
|
||||
3. **Reference** Main.kt:87-264 for current patterns
|
||||
4. **Test** on all 3 platforms (macOS, Windows, Linux) if possible
|
||||
5. **Delegate** build issues to gradle-expert
|
||||
6. **Share** UI components via compose-expert, not desktop-expert
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2025-12-30
|
||||
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
|
||||
597
.claude/skills/desktop-expert/references/desktop-compose-apis.md
Normal file
597
.claude/skills/desktop-expert/references/desktop-compose-apis.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# Desktop Compose APIs Catalog
|
||||
|
||||
Complete reference for Compose Multiplatform Desktop-only APIs.
|
||||
|
||||
## Window Management
|
||||
|
||||
### application
|
||||
|
||||
Root entry point for desktop apps.
|
||||
|
||||
```kotlin
|
||||
fun main() = application {
|
||||
Window(onCloseRequest = ::exitApplication) {
|
||||
Text("Hello Desktop")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Window
|
||||
|
||||
Creates a window.
|
||||
|
||||
```kotlin
|
||||
Window(
|
||||
onCloseRequest: () -> Unit,
|
||||
state: WindowState = rememberWindowState(),
|
||||
visible: Boolean = true,
|
||||
title: String = "Untitled",
|
||||
icon: Painter? = null,
|
||||
undecorated: Boolean = false,
|
||||
transparent: Boolean = false,
|
||||
resizable: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
focusable: Boolean = true,
|
||||
alwaysOnTop: Boolean = false,
|
||||
onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
|
||||
onKeyEvent: ((KeyEvent) -> Boolean) = { false },
|
||||
content: @Composable FrameWindowScope.() -> Unit
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
val windowState = rememberWindowState(
|
||||
width = 1200.dp,
|
||||
height = 800.dp,
|
||||
position = WindowPosition.Aligned(Alignment.Center)
|
||||
)
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = windowState,
|
||||
title = "My App",
|
||||
resizable = true
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
```
|
||||
|
||||
### rememberWindowState
|
||||
|
||||
Manages window size and position.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun rememberWindowState(
|
||||
placement: WindowPlacement = WindowPlacement.Floating,
|
||||
isMinimized: Boolean = false,
|
||||
position: WindowPosition = WindowPosition.PlatformDefault,
|
||||
width: Dp = Dp.Unspecified,
|
||||
height: Dp = Dp.Unspecified
|
||||
): WindowState
|
||||
```
|
||||
|
||||
**WindowPlacement:**
|
||||
- `Floating` - Normal window
|
||||
- `Maximized` - Fullscreen
|
||||
- `Fullscreen` - Fullscreen without decorations
|
||||
|
||||
**WindowPosition:**
|
||||
- `PlatformDefault` - OS decides
|
||||
- `Aligned(alignment)` - Center, TopStart, etc.
|
||||
- `Absolute(x, y)` - Fixed position in pixels
|
||||
|
||||
### DialogWindow
|
||||
|
||||
Modal dialog.
|
||||
|
||||
```kotlin
|
||||
DialogWindow(
|
||||
onCloseRequest: () -> Unit,
|
||||
state: DialogState = rememberDialogState(),
|
||||
visible: Boolean = true,
|
||||
title: String = "Dialog",
|
||||
icon: Painter? = null,
|
||||
undecorated: Boolean = false,
|
||||
transparent: Boolean = false,
|
||||
resizable: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
focusable: Boolean = true,
|
||||
content: @Composable DialogWindowScope.() -> Unit
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDialog) {
|
||||
DialogWindow(
|
||||
onCloseRequest = { showDialog = false },
|
||||
title = "Confirm"
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("Are you sure?")
|
||||
Row {
|
||||
Button(onClick = { showDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(onClick = { /* confirm */ }) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MenuBar
|
||||
|
||||
### MenuBar
|
||||
|
||||
Native menu bar for windows.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FrameWindowScope.MenuBar(
|
||||
content: @Composable MenuBarScope.() -> Unit
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
Window(onCloseRequest = ::exitApplication) {
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item("New", onClick = { /* ... */ })
|
||||
Item("Open", onClick = { /* ... */ })
|
||||
Separator()
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
}
|
||||
Menu("Edit") {
|
||||
Item("Copy", onClick = { /* ... */ })
|
||||
Item("Paste", onClick = { /* ... */ })
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Menu
|
||||
|
||||
Top-level menu.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MenuBarScope.Menu(
|
||||
text: String,
|
||||
mnemonic: Char? = null,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable MenuScope.() -> Unit
|
||||
)
|
||||
```
|
||||
|
||||
### Item
|
||||
|
||||
Menu item.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MenuScope.Item(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
shortcut: KeyShortcut? = null,
|
||||
mnemonic: Char? = null,
|
||||
enabled: Boolean = true,
|
||||
icon: Painter? = null
|
||||
)
|
||||
```
|
||||
|
||||
**With keyboard shortcut:**
|
||||
```kotlin
|
||||
Item(
|
||||
text = "Save",
|
||||
onClick = { save() },
|
||||
shortcut = KeyShortcut(Key.S, ctrl = true),
|
||||
icon = painterResource("save.png")
|
||||
)
|
||||
```
|
||||
|
||||
### Separator
|
||||
|
||||
Menu separator line.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MenuScope.Separator()
|
||||
```
|
||||
|
||||
### CheckboxItem
|
||||
|
||||
Toggleable menu item.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MenuScope.CheckboxItem(
|
||||
text: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
shortcut: KeyShortcut? = null,
|
||||
mnemonic: Char? = null,
|
||||
enabled: Boolean = true
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
var darkMode by remember { mutableStateOf(false) }
|
||||
|
||||
Menu("View") {
|
||||
CheckboxItem(
|
||||
text = "Dark Mode",
|
||||
checked = darkMode,
|
||||
onCheckedChange = { darkMode = it },
|
||||
shortcut = KeyShortcut(Key.D, ctrl = true)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### RadioButtonItem
|
||||
|
||||
Radio button menu item.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MenuScope.RadioButtonItem(
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
shortcut: KeyShortcut? = null,
|
||||
mnemonic: Char? = null,
|
||||
enabled: Boolean = true
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## System Tray
|
||||
|
||||
### Tray
|
||||
|
||||
System tray icon with menu.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ApplicationScope.Tray(
|
||||
icon: Painter,
|
||||
state: TrayState = rememberTrayState(),
|
||||
tooltip: String? = null,
|
||||
onAction: () -> Unit = {},
|
||||
menu: @Composable MenuScope.() -> Unit = {}
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
application {
|
||||
var isVisible by remember { mutableStateOf(true) }
|
||||
|
||||
Tray(
|
||||
icon = painterResource("tray-icon.png"),
|
||||
tooltip = "My App",
|
||||
onAction = { isVisible = true },
|
||||
menu = {
|
||||
Item("Show Window", onClick = { isVisible = true })
|
||||
Separator()
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
}
|
||||
)
|
||||
|
||||
if (isVisible) {
|
||||
Window(
|
||||
onCloseRequest = { isVisible = false },
|
||||
title = "App"
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### rememberTrayState
|
||||
|
||||
Manages tray state.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun rememberTrayState(): TrayState
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
### Notification (via Tray)
|
||||
|
||||
Show desktop notifications through tray.
|
||||
|
||||
```kotlin
|
||||
val trayState = rememberTrayState()
|
||||
|
||||
Tray(
|
||||
icon = painterResource("icon.png"),
|
||||
state = trayState
|
||||
)
|
||||
|
||||
// Send notification
|
||||
LaunchedEffect(Unit) {
|
||||
trayState.sendNotification(
|
||||
Notification(
|
||||
title = "Message",
|
||||
message = "You have a new message",
|
||||
type = Notification.Type.Info
|
||||
)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Notification types:**
|
||||
- `Info` - Information
|
||||
- `Warning` - Warning
|
||||
- `Error` - Error
|
||||
|
||||
---
|
||||
|
||||
## Keyboard
|
||||
|
||||
### KeyShortcut
|
||||
|
||||
Keyboard shortcut definition.
|
||||
|
||||
```kotlin
|
||||
data class KeyShortcut(
|
||||
val key: Key,
|
||||
val ctrl: Boolean = false,
|
||||
val meta: Boolean = false,
|
||||
val alt: Boolean = false,
|
||||
val shift: Boolean = false
|
||||
)
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```kotlin
|
||||
// Ctrl+S (Windows/Linux)
|
||||
KeyShortcut(Key.S, ctrl = true)
|
||||
|
||||
// Cmd+S (macOS)
|
||||
KeyShortcut(Key.S, meta = true)
|
||||
|
||||
// Ctrl+Shift+N
|
||||
KeyShortcut(Key.N, ctrl = true, shift = true)
|
||||
|
||||
// Alt+F4
|
||||
KeyShortcut(Key.F4, alt = true)
|
||||
```
|
||||
|
||||
### onPreviewKeyEvent / onKeyEvent
|
||||
|
||||
Window-level keyboard handlers.
|
||||
|
||||
```kotlin
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
onPreviewKeyEvent = { event ->
|
||||
if (event.key == Key.Escape && event.type == KeyEventType.KeyDown) {
|
||||
// Handle Escape
|
||||
true // Consume event
|
||||
} else {
|
||||
false // Propagate
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mouse
|
||||
|
||||
### PointerMoveFilter (Deprecated, use Modifier.pointerInput)
|
||||
|
||||
```kotlin
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
// Handle mouse events
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Mouse cursor
|
||||
|
||||
```kotlin
|
||||
Box(
|
||||
modifier = Modifier.pointerHoverIcon(
|
||||
icon = PointerIcon(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR))
|
||||
)
|
||||
) {
|
||||
Text("Hover me")
|
||||
}
|
||||
```
|
||||
|
||||
**Cursor types:**
|
||||
- `DEFAULT_CURSOR`
|
||||
- `HAND_CURSOR`
|
||||
- `TEXT_CURSOR`
|
||||
- `CROSSHAIR_CURSOR`
|
||||
- `WAIT_CURSOR`
|
||||
- `MOVE_CURSOR`
|
||||
- `E_RESIZE_CURSOR`, `W_RESIZE_CURSOR`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Drag & Drop (Experimental)
|
||||
|
||||
### onExternalDrag
|
||||
|
||||
Handle drag-and-drop from external sources.
|
||||
|
||||
```kotlin
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(200.dp)
|
||||
.background(Color.LightGray)
|
||||
.onExternalDrag(
|
||||
onDragStart = { externalDragValue ->
|
||||
println("Drag started")
|
||||
},
|
||||
onDrag = { externalDragValue ->
|
||||
println("Dragging: ${externalDragValue.dragData}")
|
||||
},
|
||||
onDragExit = {
|
||||
println("Drag exited")
|
||||
},
|
||||
onDrop = { externalDragValue ->
|
||||
val dragData = externalDragValue.dragData
|
||||
when (dragData) {
|
||||
is DragData.FilesList -> {
|
||||
println("Files dropped: ${dragData.readFiles()}")
|
||||
}
|
||||
is DragData.Text -> {
|
||||
println("Text dropped: ${dragData.readText()}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text("Drop files here", Modifier.align(Alignment.Center))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
### painterResource
|
||||
|
||||
Load images from resources.
|
||||
|
||||
```kotlin
|
||||
val icon = painterResource("icon.png")
|
||||
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = "App icon"
|
||||
)
|
||||
```
|
||||
|
||||
**Resource location:** `src/jvmMain/resources/`
|
||||
|
||||
---
|
||||
|
||||
## Platform Integration
|
||||
|
||||
### Desktop.getDesktop() (AWT)
|
||||
|
||||
Access system desktop features (not Compose API, but commonly used).
|
||||
|
||||
```kotlin
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
|
||||
// Open URL in browser
|
||||
if (Desktop.isDesktopSupported()) {
|
||||
Desktop.getDesktop().browse(URI("https://example.com"))
|
||||
}
|
||||
|
||||
// Open file with default app
|
||||
Desktop.getDesktop().open(File("/path/to/file.pdf"))
|
||||
|
||||
// Open email client
|
||||
Desktop.getDesktop().mail(URI("mailto:user@example.com"))
|
||||
```
|
||||
|
||||
### FileDialog (AWT)
|
||||
|
||||
File picker dialogs.
|
||||
|
||||
```kotlin
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
|
||||
// Open file
|
||||
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
|
||||
fileDialog.isVisible = true
|
||||
val selectedFile = fileDialog.file
|
||||
val directory = fileDialog.directory
|
||||
|
||||
// Save file
|
||||
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
|
||||
saveDialog.file = "document.txt"
|
||||
saveDialog.isVisible = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SwingPanel (Interop)
|
||||
|
||||
Embed Swing components in Compose.
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.ui.awt.SwingPanel
|
||||
import javax.swing.JButton
|
||||
|
||||
SwingPanel(
|
||||
factory = {
|
||||
JButton("Swing Button").apply {
|
||||
addActionListener {
|
||||
println("Swing button clicked")
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(200.dp, 50.dp)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ComposePanel (Reverse Interop)
|
||||
|
||||
Embed Compose in Swing.
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.ui.awt.ComposePanel
|
||||
import javax.swing.JFrame
|
||||
|
||||
val frame = JFrame("Swing Frame")
|
||||
val composePanel = ComposePanel()
|
||||
|
||||
composePanel.setContent {
|
||||
Text("Compose in Swing")
|
||||
}
|
||||
|
||||
frame.contentPane.add(composePanel)
|
||||
frame.setSize(400, 300)
|
||||
frame.isVisible = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Version Requirements
|
||||
|
||||
- **Kotlin:** 2.0+
|
||||
- **Compose Multiplatform:** 1.7.0+
|
||||
- **JVM Target:** 11+ (recommend 21)
|
||||
|
||||
**See also:**
|
||||
- [Official Desktop API docs](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html)
|
||||
- [Compose Multiplatform repo](https://github.com/JetBrains/compose-multiplatform)
|
||||
464
.claude/skills/desktop-expert/references/desktop-navigation.md
Normal file
464
.claude/skills/desktop-expert/references/desktop-navigation.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# Desktop Navigation Patterns
|
||||
|
||||
Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
|
||||
|
||||
## Core Difference
|
||||
|
||||
| Platform | Pattern | Location | Rationale |
|
||||
|----------|---------|----------|-----------|
|
||||
| **Android** | Bottom Navigation Bar | Horizontal, bottom | Thumb reach on mobile |
|
||||
| **Desktop** | Navigation Rail | Vertical, left sidebar | Horizontal screen space |
|
||||
|
||||
---
|
||||
|
||||
## Desktop: NavigationRail
|
||||
|
||||
### Current Implementation
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:191-264`
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MainContent(
|
||||
currentScreen: AppScreen,
|
||||
onScreenChange: (AppScreen) -> Unit,
|
||||
// ...
|
||||
) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// LEFT: Vertical Sidebar (NavigationRail)
|
||||
NavigationRail(
|
||||
modifier = Modifier.width(80.dp).fillMaxHeight(),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Top navigation items
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Home, "Feed") },
|
||||
label = { Text("Feed") },
|
||||
selected = currentScreen == AppScreen.Feed,
|
||||
onClick = { onScreenChange(AppScreen.Feed) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Search, "Search") },
|
||||
label = { Text("Search") },
|
||||
selected = currentScreen == AppScreen.Search,
|
||||
onClick = { onScreenChange(AppScreen.Search) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Email, "Messages") },
|
||||
label = { Text("DMs") },
|
||||
selected = currentScreen == AppScreen.Messages,
|
||||
onClick = { onScreenChange(AppScreen.Messages) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Notifications, "Notifications") },
|
||||
label = { Text("Alerts") },
|
||||
selected = currentScreen == AppScreen.Notifications,
|
||||
onClick = { onScreenChange(AppScreen.Notifications) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Person, "Profile") },
|
||||
label = { Text("Profile") },
|
||||
selected = currentScreen == AppScreen.Profile,
|
||||
onClick = { onScreenChange(AppScreen.Profile) }
|
||||
)
|
||||
|
||||
// Push Settings to bottom
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Settings, "Settings") },
|
||||
label = { Text("Settings") },
|
||||
selected = currentScreen == AppScreen.Settings,
|
||||
onClick = { onScreenChange(AppScreen.Settings) }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// RIGHT: Main Content Area
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp)) {
|
||||
when (currentScreen) {
|
||||
AppScreen.Feed -> FeedScreen(relayManager)
|
||||
AppScreen.Search -> SearchPlaceholder()
|
||||
AppScreen.Messages -> MessagesPlaceholder()
|
||||
AppScreen.Notifications -> NotificationsPlaceholder()
|
||||
AppScreen.Profile -> ProfileScreen(account, accountManager)
|
||||
AppScreen.Settings -> RelaySettingsScreen(relayManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Structure
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ [Menu Bar: File, Edit, View, Help] │ ← MenuBar (OS-native)
|
||||
├──────┬─────────────────────────────────┤
|
||||
│ │ │
|
||||
│ [🏠] │ │
|
||||
│ Feed │ │
|
||||
│ │ │
|
||||
│ [🔍] │ Main Content Area │
|
||||
│Search│ (Feed, Messages, etc.) │
|
||||
│ │ │
|
||||
│ [✉️] │ │
|
||||
│ DMs │ │
|
||||
│ │ │
|
||||
│ [🔔] │ │
|
||||
│Alerts│ │
|
||||
│ │ │
|
||||
│ [👤] │ │
|
||||
│Profile │
|
||||
│ │ │
|
||||
│ ─ │ │
|
||||
│ [⚙️] │ │
|
||||
│Settings │
|
||||
│ │ │
|
||||
└──────┴─────────────────────────────────┘
|
||||
80dp Remaining width (weight=1f)
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Always visible:** All nav items visible at once
|
||||
2. **Icon + Label:** Both shown (not just icons)
|
||||
3. **Vertical list:** Natural reading order
|
||||
4. **Settings at bottom:** Separated by divider + Spacer.weight(1f)
|
||||
5. **80dp width:** Standard NavigationRail width
|
||||
|
||||
---
|
||||
|
||||
## Android: BottomNavigationBar (Future)
|
||||
|
||||
### Expected Implementation
|
||||
|
||||
**Location:** `amethyst/src/androidMain/kotlin/...` (not yet implemented)
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
currentScreen: AppScreen,
|
||||
onScreenChange: (AppScreen) -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Home, "Feed") },
|
||||
label = { Text("Feed") },
|
||||
selected = currentScreen == AppScreen.Feed,
|
||||
onClick = { onScreenChange(AppScreen.Feed) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Search, "Search") },
|
||||
label = { Text("Search") },
|
||||
selected = currentScreen == AppScreen.Search,
|
||||
onClick = { onScreenChange(AppScreen.Search) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Email, "Messages") },
|
||||
label = { Text("Messages") },
|
||||
selected = currentScreen == AppScreen.Messages,
|
||||
onClick = { onScreenChange(AppScreen.Messages) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Person, "Profile") },
|
||||
label = { Text("Profile") },
|
||||
selected = currentScreen == AppScreen.Profile,
|
||||
onClick = { onScreenChange(AppScreen.Profile) }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(Modifier.padding(paddingValues)) {
|
||||
when (currentScreen) {
|
||||
AppScreen.Feed -> FeedScreen()
|
||||
AppScreen.Search -> SearchScreen()
|
||||
AppScreen.Messages -> MessagesScreen()
|
||||
AppScreen.Profile -> ProfileScreen()
|
||||
// Settings accessed via Profile or overflow menu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
│ │
|
||||
│ Main Content Area │
|
||||
│ (Feed, Messages, etc.) │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [🏠] [🔍] [✉️] [👤] │ ← NavigationBar
|
||||
│ Feed Search DMs Profile │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Differences from Desktop
|
||||
|
||||
1. **Bottom placement:** Thumb reach
|
||||
2. **Horizontal layout:** Limited vertical space
|
||||
3. **Fewer items:** 3-5 primary destinations
|
||||
4. **Label optional:** Can hide on small screens
|
||||
5. **Settings hidden:** In profile or overflow
|
||||
|
||||
---
|
||||
|
||||
## Shared Navigation State
|
||||
|
||||
Both platforms use the same `AppScreen` enum from `commons`.
|
||||
|
||||
**File:** `commons/src/commonMain/kotlin/.../navigation/AppScreen.kt` (expected)
|
||||
|
||||
```kotlin
|
||||
// Shared navigation destinations
|
||||
enum class AppScreen {
|
||||
Feed,
|
||||
Search,
|
||||
Messages,
|
||||
Notifications,
|
||||
Profile,
|
||||
Settings
|
||||
}
|
||||
```
|
||||
|
||||
**State management (shared):**
|
||||
|
||||
```kotlin
|
||||
// commons/src/jvmAndroid/kotlin/.../navigation/NavigationViewModel.kt
|
||||
class NavigationViewModel : ViewModel() {
|
||||
private val _currentScreen = MutableStateFlow(AppScreen.Feed)
|
||||
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
|
||||
|
||||
fun navigateTo(screen: AppScreen) {
|
||||
_currentScreen.value = screen
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Pane Desktop Layout (Advanced)
|
||||
|
||||
Desktop can utilize horizontal space for multi-pane layouts.
|
||||
|
||||
### Two-Pane Layout
|
||||
|
||||
```kotlin
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// Left: NavigationRail (fixed 80dp)
|
||||
NavigationRail { /* ... */ }
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// Center: Main content (60% width)
|
||||
Box(Modifier.weight(0.6f)) {
|
||||
FeedScreen()
|
||||
}
|
||||
|
||||
// Right: Detail pane (40% width, conditional)
|
||||
if (selectedNote != null) {
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(0.4f)) {
|
||||
NoteDetailPane(selectedNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Layout:
|
||||
|
||||
```
|
||||
┌──────┬───────────────────┬─────────────┐
|
||||
│ │ │ │
|
||||
│ Nav │ Feed List │ Detail │
|
||||
│ Rail │ (60%) │ Pane │
|
||||
│ │ │ (40%) │
|
||||
│ │ │ │
|
||||
└──────┴───────────────────┴─────────────┘
|
||||
80dp weight(0.6f) weight(0.4f)
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Email: List + message detail
|
||||
- Notes: List + editor
|
||||
- Settings: Categories + options
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Navigation
|
||||
|
||||
Desktop should support keyboard navigation.
|
||||
|
||||
### Tab Navigation
|
||||
|
||||
```kotlin
|
||||
NavigationRail(
|
||||
modifier = Modifier.focusable()
|
||||
) {
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Home, "Feed") },
|
||||
label = { Text("Feed") },
|
||||
selected = currentScreen == AppScreen.Feed,
|
||||
onClick = { onScreenChange(AppScreen.Feed) },
|
||||
modifier = Modifier.focusable()
|
||||
)
|
||||
// More items...
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
```kotlin
|
||||
Window(
|
||||
onPreviewKeyEvent = { event ->
|
||||
when {
|
||||
event.key == Key.One && event.isCtrlPressed ->
|
||||
onScreenChange(AppScreen.Feed).also { true }
|
||||
event.key == Key.Two && event.isCtrlPressed ->
|
||||
onScreenChange(AppScreen.Search).also { true }
|
||||
event.key == Key.Three && event.isCtrlPressed ->
|
||||
onScreenChange(AppScreen.Messages).also { true }
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
```
|
||||
|
||||
**Standard:**
|
||||
- Ctrl+1: First nav item (Feed)
|
||||
- Ctrl+2: Second nav item (Search)
|
||||
- Ctrl+3: Third nav item (Messages)
|
||||
- Ctrl+Comma: Settings
|
||||
|
||||
---
|
||||
|
||||
## Navigation Transitions
|
||||
|
||||
### Desktop (Instant)
|
||||
|
||||
No fancy animations. Instant switch.
|
||||
|
||||
```kotlin
|
||||
Box {
|
||||
when (currentScreen) {
|
||||
AppScreen.Feed -> FeedScreen()
|
||||
AppScreen.Search -> SearchScreen()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Android (Animated, Future)
|
||||
|
||||
Can use Navigation Compose for transitions.
|
||||
|
||||
```kotlin
|
||||
NavHost(navController, startDestination = "feed") {
|
||||
composable("feed") { FeedScreen() }
|
||||
composable("search") { SearchScreen() }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Desktop NavigationRail
|
||||
|
||||
✅ **DO:**
|
||||
- Keep width 72-80dp
|
||||
- Show both icon and label
|
||||
- Use Spacer.weight(1f) for bottom items
|
||||
- Separate sections with HorizontalDivider
|
||||
- Limit to 5-7 primary items
|
||||
|
||||
❌ **DON'T:**
|
||||
- Use bottom navigation on desktop
|
||||
- Hide labels (plenty of space)
|
||||
- Make it collapsible (not standard)
|
||||
- Use hamburger menu (not desktop pattern)
|
||||
|
||||
### Android NavigationBar
|
||||
|
||||
✅ **DO:**
|
||||
- Limit to 3-5 items
|
||||
- Use bottom placement
|
||||
- Consider label visibility on small screens
|
||||
- Use standard icons
|
||||
|
||||
❌ **DON'T:**
|
||||
- Put more than 5 items
|
||||
- Use top placement (deprecated)
|
||||
- Put critical actions only in nav bar
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
When adding Android support:
|
||||
|
||||
1. **Extract shared state:** Move `AppScreen` to `commons/commonMain`
|
||||
2. **Platform layouts:** Keep `NavigationRail` in `desktopApp/jvmMain`, `NavigationBar` in `amethyst/androidMain`
|
||||
3. **Shared screens:** Composables in `commons/commonMain` (FeedScreen content)
|
||||
4. **Platform chrome:** Navigation containers in platform modules
|
||||
|
||||
**Example:**
|
||||
|
||||
```kotlin
|
||||
// commons/commonMain - Shared screen content
|
||||
@Composable
|
||||
fun FeedContent(notes: List<Note>) {
|
||||
LazyColumn {
|
||||
items(notes) { note ->
|
||||
NoteCard(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// desktopApp/jvmMain - Desktop wrapper
|
||||
@Composable
|
||||
fun FeedScreen() {
|
||||
Column {
|
||||
FeedHeader() // Desktop-specific header
|
||||
FeedContent(notes) // Shared content
|
||||
}
|
||||
}
|
||||
|
||||
// amethyst/androidMain - Android wrapper
|
||||
@Composable
|
||||
fun FeedScreen() {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar { Text("Feed") } }
|
||||
) {
|
||||
FeedContent(notes) // Same shared content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Current Desktop:** Main.kt:191-264
|
||||
- **Material3 NavigationRail:** [Material Design Docs](https://m3.material.io/components/navigation-rail)
|
||||
- **Material3 NavigationBar:** [Material Design Docs](https://m3.material.io/components/navigation-bar)
|
||||
400
.claude/skills/desktop-expert/references/keyboard-shortcuts.md
Normal file
400
.claude/skills/desktop-expert/references/keyboard-shortcuts.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# Keyboard Shortcuts Reference
|
||||
|
||||
Standard keyboard shortcuts for desktop applications across macOS, Windows, and Linux.
|
||||
|
||||
## Primary Modifier Keys
|
||||
|
||||
| Platform | Primary | Secondary | Tertiary |
|
||||
|----------|---------|-----------|----------|
|
||||
| **macOS** | Cmd (⌘) / `meta` | Option (⌥) / `alt` | Ctrl (⌃) / `ctrl` |
|
||||
| **Windows** | Ctrl / `ctrl` | Alt / `alt` | Win / `meta` |
|
||||
| **Linux** | Ctrl / `ctrl` | Alt / `alt` | Super / `meta` |
|
||||
|
||||
**In Compose Desktop:**
|
||||
|
||||
```kotlin
|
||||
// macOS
|
||||
KeyShortcut(Key.N, meta = true) // Cmd+N
|
||||
|
||||
// Windows/Linux
|
||||
KeyShortcut(Key.N, ctrl = true) // Ctrl+N
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Operations
|
||||
|
||||
| Action | macOS | Windows | Linux | Notes |
|
||||
|--------|-------|---------|-------|-------|
|
||||
| **New** | Cmd+N | Ctrl+N | Ctrl+N | Create new |
|
||||
| **Open** | Cmd+O | Ctrl+O | Ctrl+O | Open file |
|
||||
| **Save** | Cmd+S | Ctrl+S | Ctrl+S | Save current |
|
||||
| **Save As** | Cmd+Shift+S | Ctrl+Shift+S | Ctrl+Shift+S | Save with new name |
|
||||
| **Close** | Cmd+W | Ctrl+W | Ctrl+W | Close window/tab |
|
||||
| **Quit** | Cmd+Q | Ctrl+Q | Ctrl+Q | Exit app |
|
||||
| **Print** | Cmd+P | Ctrl+P | Ctrl+P | Print |
|
||||
|
||||
**Compose Implementation:**
|
||||
|
||||
```kotlin
|
||||
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item(
|
||||
"New Note",
|
||||
shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.N, meta = true)
|
||||
} else {
|
||||
KeyShortcut(Key.N, ctrl = true)
|
||||
},
|
||||
onClick = { createNewNote() }
|
||||
)
|
||||
Item(
|
||||
"Save",
|
||||
shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.S, meta = true)
|
||||
} else {
|
||||
KeyShortcut(Key.S, ctrl = true)
|
||||
},
|
||||
onClick = { save() }
|
||||
)
|
||||
Separator()
|
||||
Item(
|
||||
"Quit",
|
||||
shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.Q, meta = true)
|
||||
} else {
|
||||
KeyShortcut(Key.Q, ctrl = true)
|
||||
},
|
||||
onClick = ::exitApplication
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edit Operations
|
||||
|
||||
| Action | macOS | Windows | Linux | Notes |
|
||||
|--------|-------|---------|-------|-------|
|
||||
| **Undo** | Cmd+Z | Ctrl+Z | Ctrl+Z | Universal |
|
||||
| **Redo** | Cmd+Shift+Z | Ctrl+Y | Ctrl+Y | Windows/Linux use Y |
|
||||
| **Cut** | Cmd+X | Ctrl+X | Ctrl+X | Universal |
|
||||
| **Copy** | Cmd+C | Ctrl+C | Ctrl+C | Universal |
|
||||
| **Paste** | Cmd+V | Ctrl+V | Ctrl+V | Universal |
|
||||
| **Select All** | Cmd+A | Ctrl+A | Ctrl+A | Universal |
|
||||
| **Find** | Cmd+F | Ctrl+F | Ctrl+F | Search |
|
||||
| **Find Next** | Cmd+G | F3 | F3 | Next result |
|
||||
| **Replace** | Cmd+Option+F | Ctrl+H | Ctrl+H | Find & replace |
|
||||
|
||||
**Note:** Undo/Redo typically handled by text fields automatically.
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
| Action | macOS | Windows | Linux | Notes |
|
||||
|--------|-------|---------|-------|-------|
|
||||
| **Tab 1** | Cmd+1 | Ctrl+1 | Ctrl+1 | First tab/view |
|
||||
| **Tab 2** | Cmd+2 | Ctrl+2 | Ctrl+2 | Second tab/view |
|
||||
| **Tab 3** | Cmd+3 | Ctrl+3 | Ctrl+3 | Third tab/view |
|
||||
| **Next Tab** | Cmd+Option+→ | Ctrl+Tab | Ctrl+Tab | Cycle forward |
|
||||
| **Prev Tab** | Cmd+Option+← | Ctrl+Shift+Tab | Ctrl+Shift+Tab | Cycle back |
|
||||
| **Go Back** | Cmd+[ | Alt+← | Alt+← | Browser-style |
|
||||
| **Go Forward** | Cmd+] | Alt+→ | Alt+→ | Browser-style |
|
||||
|
||||
**Compose Implementation:**
|
||||
|
||||
```kotlin
|
||||
Window(
|
||||
onPreviewKeyEvent = { event ->
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
when {
|
||||
event.key == Key.One && event.isPrimaryPressed() -> {
|
||||
navigateTo(AppScreen.Feed)
|
||||
true
|
||||
}
|
||||
event.key == Key.Two && event.isPrimaryPressed() -> {
|
||||
navigateTo(AppScreen.Search)
|
||||
true
|
||||
}
|
||||
event.key == Key.Three && event.isPrimaryPressed() -> {
|
||||
navigateTo(AppScreen.Messages)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
|
||||
// Helper extension
|
||||
fun KeyEvent.isPrimaryPressed() = if (isMacOS) isMetaPressed else isCtrlPressed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Window Management
|
||||
|
||||
| Action | macOS | Windows | Linux | Notes |
|
||||
|--------|-------|---------|-------|-------|
|
||||
| **New Window** | Cmd+N | Ctrl+N | Ctrl+N | New instance |
|
||||
| **Close Window** | Cmd+W | Alt+F4 | Alt+F4 | Close current |
|
||||
| **Minimize** | Cmd+M | Win+Down | Super+Down | Minimize to dock/taskbar |
|
||||
| **Maximize** | Cmd+Ctrl+F | Win+Up | Super+Up | Fullscreen/maximize |
|
||||
| **Hide App** | Cmd+H | - | - | macOS only |
|
||||
| **Switch Window** | Cmd+` | Alt+Tab | Alt+Tab | Between app windows |
|
||||
|
||||
**Note:** Window management often handled by OS, not app shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## App-Specific (Amethyst)
|
||||
|
||||
### Nostr Actions
|
||||
|
||||
| Action | macOS | Windows | Linux | Description |
|
||||
|--------|-------|---------|-------|-------------|
|
||||
| **New Note** | Cmd+N | Ctrl+N | Ctrl+N | Compose new post |
|
||||
| **Refresh Feed** | Cmd+R | Ctrl+R | Ctrl+R | Reload timeline |
|
||||
| **Search** | Cmd+K | Ctrl+K | Ctrl+K | Quick search |
|
||||
| **DMs** | Cmd+Shift+M | Ctrl+Shift+M | Ctrl+Shift+M | Open messages |
|
||||
| **Settings** | Cmd+, | Ctrl+, | Ctrl+, | Open preferences |
|
||||
| **Notifications** | Cmd+Shift+N | Ctrl+Shift+N | Ctrl+Shift+N | View alerts |
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```kotlin
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item(
|
||||
"New Note",
|
||||
shortcut = DesktopShortcuts.primary(Key.N),
|
||||
onClick = { showComposeDialog() }
|
||||
)
|
||||
Item(
|
||||
"Settings",
|
||||
shortcut = DesktopShortcuts.primary(Key.Comma),
|
||||
onClick = { navigateTo(AppScreen.Settings) }
|
||||
)
|
||||
}
|
||||
Menu("View") {
|
||||
Item(
|
||||
"Refresh Feed",
|
||||
shortcut = DesktopShortcuts.primary(Key.R),
|
||||
onClick = { refreshFeed() }
|
||||
)
|
||||
Item(
|
||||
"Search",
|
||||
shortcut = DesktopShortcuts.primary(Key.K),
|
||||
onClick = { focusSearch() }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
| Action | macOS | Windows | Linux | Description |
|
||||
|--------|-------|---------|-------|-------------|
|
||||
| **Zoom In** | Cmd++ | Ctrl++ | Ctrl++ | Increase size |
|
||||
| **Zoom Out** | Cmd+- | Ctrl+- | Ctrl+- | Decrease size |
|
||||
| **Reset Zoom** | Cmd+0 | Ctrl+0 | Ctrl+0 | Default size |
|
||||
| **Help** | Cmd+? | F1 | F1 | Show help |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. OS-Aware Helper
|
||||
|
||||
Create a utility for OS detection:
|
||||
|
||||
```kotlin
|
||||
// commons/src/jvmMain/kotlin/utils/PlatformShortcuts.kt
|
||||
object DesktopShortcuts {
|
||||
private val isMacOS = System.getProperty("os.name")
|
||||
.lowercase()
|
||||
.contains("mac")
|
||||
|
||||
fun primary(key: Key) = if (isMacOS) {
|
||||
KeyShortcut(key, meta = true)
|
||||
} else {
|
||||
KeyShortcut(key, ctrl = true)
|
||||
}
|
||||
|
||||
fun primaryShift(key: Key) = if (isMacOS) {
|
||||
KeyShortcut(key, meta = true, shift = true)
|
||||
} else {
|
||||
KeyShortcut(key, ctrl = true, shift = true)
|
||||
}
|
||||
|
||||
fun primaryAlt(key: Key) = if (isMacOS) {
|
||||
KeyShortcut(key, meta = true, alt = true)
|
||||
} else {
|
||||
KeyShortcut(key, ctrl = true, alt = true)
|
||||
}
|
||||
|
||||
val modifierName = if (isMacOS) "Cmd" else "Ctrl"
|
||||
val secondaryName = if (isMacOS) "Option" else "Alt"
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
Item(
|
||||
"Save",
|
||||
shortcut = DesktopShortcuts.primary(Key.S),
|
||||
onClick = { save() }
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Show Shortcuts in Tooltips
|
||||
|
||||
```kotlin
|
||||
IconButton(
|
||||
onClick = { refresh() },
|
||||
modifier = Modifier.tooltipArea {
|
||||
Text("Refresh (${DesktopShortcuts.modifierName}+R)")
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, "Refresh")
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Shortcuts Menu
|
||||
|
||||
Provide a "Keyboard Shortcuts" help menu:
|
||||
|
||||
```kotlin
|
||||
Menu("Help") {
|
||||
Item("Keyboard Shortcuts", onClick = { showShortcutsDialog() })
|
||||
}
|
||||
|
||||
// Dialog content
|
||||
@Composable
|
||||
fun ShortcutsDialog() {
|
||||
Dialog(onDismissRequest = { /* close */ }) {
|
||||
Surface {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("Keyboard Shortcuts", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
ShortcutRow("New Note", "${DesktopShortcuts.modifierName}+N")
|
||||
ShortcutRow("Save", "${DesktopShortcuts.modifierName}+S")
|
||||
ShortcutRow("Search", "${DesktopShortcuts.modifierName}+K")
|
||||
ShortcutRow("Settings", "${DesktopShortcuts.modifierName}+,")
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShortcutRow(action: String, shortcut: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(action, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
shortcut,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Avoid Conflicts
|
||||
|
||||
**Check for OS-level shortcuts:**
|
||||
|
||||
| macOS Reserved | Description |
|
||||
|----------------|-------------|
|
||||
| Cmd+Tab | Switch apps |
|
||||
| Cmd+Space | Spotlight |
|
||||
| Cmd+H | Hide window |
|
||||
| Cmd+M | Minimize |
|
||||
| Cmd+Q | Quit |
|
||||
| Cmd+W | Close window |
|
||||
|
||||
**Windows Reserved:**
|
||||
|
||||
| Windows Reserved | Description |
|
||||
|-----------------|-------------|
|
||||
| Win+D | Show desktop |
|
||||
| Win+E | File Explorer |
|
||||
| Win+L | Lock screen |
|
||||
| Alt+Tab | Switch apps |
|
||||
| Alt+F4 | Close window |
|
||||
|
||||
**Don't override these unless critical.**
|
||||
|
||||
---
|
||||
|
||||
## Testing Shortcuts
|
||||
|
||||
```kotlin
|
||||
// Test OS detection
|
||||
@Test
|
||||
fun testOsDetection() {
|
||||
val osName = System.getProperty("os.name")
|
||||
println("OS: $osName")
|
||||
|
||||
val isMacOS = osName.lowercase().contains("mac")
|
||||
println("Is macOS: $isMacOS")
|
||||
|
||||
val shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.N, meta = true)
|
||||
} else {
|
||||
KeyShortcut(Key.N, ctrl = true)
|
||||
}
|
||||
|
||||
println("Primary modifier for New: $shortcut")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current Issues in Amethyst
|
||||
|
||||
**Main.kt:105-123** hardcodes `ctrl = true`:
|
||||
|
||||
```kotlin
|
||||
// ❌ WRONG: Hardcoded Ctrl (doesn't work on macOS)
|
||||
Item(
|
||||
"New Note",
|
||||
shortcut = KeyShortcut(Key.N, ctrl = true), // Should be Cmd on macOS
|
||||
onClick = { /* ... */ }
|
||||
)
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
|
||||
```kotlin
|
||||
// ✅ CORRECT: OS-aware
|
||||
Item(
|
||||
"New Note",
|
||||
shortcut = DesktopShortcuts.primary(Key.N),
|
||||
onClick = { /* ... */ }
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [macOS Keyboard Shortcuts](https://support.apple.com/en-us/102650)
|
||||
- [Windows Keyboard Shortcuts](https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec)
|
||||
- [GNOME Keyboard Shortcuts](https://help.gnome.org/users/gnome-help/stable/shell-keyboard-shortcuts.html)
|
||||
- [Material Design: Keyboard Shortcuts](https://m3.material.io/foundations/interaction/keyboard)
|
||||
- [Compose Desktop: Keyboard Events](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-keyboard.html)
|
||||
579
.claude/skills/desktop-expert/references/os-detection.md
Normal file
579
.claude/skills/desktop-expert/references/os-detection.md
Normal file
@@ -0,0 +1,579 @@
|
||||
# OS Detection & Platform-Specific Code
|
||||
|
||||
Patterns for detecting operating system and implementing platform-specific behavior in Compose Desktop.
|
||||
|
||||
## OS Detection
|
||||
|
||||
### Basic Detection
|
||||
|
||||
```kotlin
|
||||
val osName = System.getProperty("os.name").lowercase()
|
||||
|
||||
val isMacOS = osName.contains("mac")
|
||||
val isWindows = osName.contains("win")
|
||||
val isLinux = osName.contains("nux") || osName.contains("nix")
|
||||
```
|
||||
|
||||
### System Properties
|
||||
|
||||
```kotlin
|
||||
// OS name
|
||||
System.getProperty("os.name")
|
||||
// Examples: "Mac OS X", "Windows 10", "Linux"
|
||||
|
||||
// OS version
|
||||
System.getProperty("os.version")
|
||||
// Examples: "14.2.1", "10.0", "6.5.0-14-generic"
|
||||
|
||||
// OS architecture
|
||||
System.getProperty("os.arch")
|
||||
// Examples: "aarch64", "x86_64", "amd64"
|
||||
|
||||
// User home directory
|
||||
System.getProperty("user.home")
|
||||
// Examples: "/Users/username", "C:\Users\username", "/home/username"
|
||||
|
||||
// File separator
|
||||
System.getProperty("file.separator")
|
||||
// Examples: "/" (Unix), "\" (Windows)
|
||||
|
||||
// Path separator
|
||||
System.getProperty("path.separator")
|
||||
// Examples: ":" (Unix), ";" (Windows)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PlatformDetector Utility
|
||||
|
||||
Create a centralized utility for platform detection.
|
||||
|
||||
**File:** `commons/src/jvmMain/kotlin/utils/PlatformDetector.kt`
|
||||
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.commons.utils
|
||||
|
||||
object PlatformDetector {
|
||||
private val osName = System.getProperty("os.name").lowercase()
|
||||
|
||||
val isMacOS: Boolean = osName.contains("mac")
|
||||
val isWindows: Boolean = osName.contains("win")
|
||||
val isLinux: Boolean = osName.contains("nux") || osName.contains("nix")
|
||||
|
||||
val platform: Platform = when {
|
||||
isMacOS -> Platform.MacOS
|
||||
isWindows -> Platform.Windows
|
||||
isLinux -> Platform.Linux
|
||||
else -> Platform.Unknown
|
||||
}
|
||||
|
||||
enum class Platform {
|
||||
MacOS,
|
||||
Windows,
|
||||
Linux,
|
||||
Unknown
|
||||
}
|
||||
|
||||
// File paths
|
||||
val fileSeparator: String = System.getProperty("file.separator")
|
||||
val pathSeparator: String = System.getProperty("path.separator")
|
||||
|
||||
// User directories
|
||||
val userHome: String = System.getProperty("user.home")
|
||||
|
||||
val appDataDir: String = when (platform) {
|
||||
Platform.MacOS -> "$userHome/Library/Application Support"
|
||||
Platform.Windows -> System.getenv("APPDATA") ?: "$userHome\\AppData\\Roaming"
|
||||
Platform.Linux -> System.getenv("XDG_CONFIG_HOME") ?: "$userHome/.config"
|
||||
Platform.Unknown -> userHome
|
||||
}
|
||||
|
||||
// Modifier key names
|
||||
val primaryModifierName: String = if (isMacOS) "Cmd" else "Ctrl"
|
||||
val secondaryModifierName: String = if (isMacOS) "Option" else "Alt"
|
||||
|
||||
fun platformSpecific(
|
||||
macOS: () -> Unit = {},
|
||||
windows: () -> Unit = {},
|
||||
linux: () -> Unit = {},
|
||||
fallback: () -> Unit = {}
|
||||
) {
|
||||
when (platform) {
|
||||
Platform.MacOS -> macOS()
|
||||
Platform.Windows -> windows()
|
||||
Platform.Linux -> linux()
|
||||
Platform.Unknown -> fallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
// Simple check
|
||||
if (PlatformDetector.isMacOS) {
|
||||
// macOS-specific code
|
||||
}
|
||||
|
||||
// Pattern matching
|
||||
when (PlatformDetector.platform) {
|
||||
Platform.MacOS -> setupMacDock()
|
||||
Platform.Windows -> setupWindowsTray()
|
||||
Platform.Linux -> setupLinuxTray()
|
||||
Platform.Unknown -> showWarning()
|
||||
}
|
||||
|
||||
// Platform-specific execution
|
||||
PlatformDetector.platformSpecific(
|
||||
macOS = { setupMacMenuBar() },
|
||||
windows = { setupWindowsMenu() },
|
||||
linux = { setupLinuxMenu() }
|
||||
)
|
||||
|
||||
// File paths
|
||||
val configPath = "${PlatformDetector.appDataDir}${PlatformDetector.fileSeparator}amethyst"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific UI
|
||||
|
||||
### Keyboard Shortcuts Helper
|
||||
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.commons.utils
|
||||
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyShortcut
|
||||
|
||||
object DesktopShortcuts {
|
||||
private val isMacOS = PlatformDetector.isMacOS
|
||||
|
||||
fun primary(key: Key) = if (isMacOS) {
|
||||
KeyShortcut(key, meta = true)
|
||||
} else {
|
||||
KeyShortcut(key, ctrl = true)
|
||||
}
|
||||
|
||||
fun primaryShift(key: Key) = if (isMacOS) {
|
||||
KeyShortcut(key, meta = true, shift = true)
|
||||
} else {
|
||||
KeyShortcut(key, ctrl = true, shift = true)
|
||||
}
|
||||
|
||||
fun primaryAlt(key: Key) = if (isMacOS) {
|
||||
KeyShortcut(key, meta = true, alt = true)
|
||||
} else {
|
||||
KeyShortcut(key, ctrl = true, alt = true)
|
||||
}
|
||||
|
||||
val modifierName = PlatformDetector.primaryModifierName
|
||||
val secondaryName = PlatformDetector.secondaryModifierName
|
||||
|
||||
fun formatShortcut(key: String, withPrimary: Boolean = true): String {
|
||||
return if (withPrimary) "$modifierName+$key" else key
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File Paths Helper
|
||||
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.commons.utils
|
||||
|
||||
import java.io.File
|
||||
|
||||
object FilePaths {
|
||||
private val separator = PlatformDetector.fileSeparator
|
||||
|
||||
fun join(vararg parts: String): String {
|
||||
return parts.joinToString(separator)
|
||||
}
|
||||
|
||||
fun appConfig(appName: String): String {
|
||||
return join(PlatformDetector.appDataDir, appName)
|
||||
}
|
||||
|
||||
fun appCache(appName: String): String {
|
||||
return when (PlatformDetector.platform) {
|
||||
PlatformDetector.Platform.MacOS ->
|
||||
join(PlatformDetector.userHome, "Library", "Caches", appName)
|
||||
PlatformDetector.Platform.Windows ->
|
||||
join(System.getenv("LOCALAPPDATA") ?: "${PlatformDetector.userHome}\\AppData\\Local", appName)
|
||||
PlatformDetector.Platform.Linux ->
|
||||
join(System.getenv("XDG_CACHE_HOME") ?: "${PlatformDetector.userHome}/.cache", appName)
|
||||
else -> join(PlatformDetector.userHome, ".cache", appName)
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureDirectory(path: String): File {
|
||||
return File(path).apply {
|
||||
if (!exists()) {
|
||||
mkdirs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
val configDir = FilePaths.ensureDirectory(FilePaths.appConfig("amethyst"))
|
||||
val cacheDir = FilePaths.ensureDirectory(FilePaths.appCache("amethyst"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific Features
|
||||
|
||||
### Open External URL
|
||||
|
||||
```kotlin
|
||||
// commons/src/commonMain/kotlin/utils/ExternalUrl.kt
|
||||
expect fun openExternalUrl(url: String)
|
||||
|
||||
// commons/src/jvmMain/kotlin/utils/ExternalUrl.jvm.kt
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
|
||||
actual fun openExternalUrl(url: String) {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
Desktop.getDesktop().browse(URI(url))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File Picker
|
||||
|
||||
```kotlin
|
||||
// Platform-specific file picker
|
||||
fun showFilePicker(
|
||||
title: String = "Select file",
|
||||
mode: FilePickerMode = FilePickerMode.Load
|
||||
): String? {
|
||||
val fileDialog = java.awt.FileDialog(
|
||||
java.awt.Frame(),
|
||||
title,
|
||||
when (mode) {
|
||||
FilePickerMode.Load -> java.awt.FileDialog.LOAD
|
||||
FilePickerMode.Save -> java.awt.FileDialog.SAVE
|
||||
}
|
||||
)
|
||||
|
||||
// macOS-specific: Enable file selection features
|
||||
if (PlatformDetector.isMacOS) {
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", "false")
|
||||
}
|
||||
|
||||
fileDialog.isVisible = true
|
||||
|
||||
return fileDialog.file?.let { "${fileDialog.directory}$it" }
|
||||
}
|
||||
|
||||
enum class FilePickerMode {
|
||||
Load,
|
||||
Save
|
||||
}
|
||||
```
|
||||
|
||||
### Directory Picker (macOS)
|
||||
|
||||
```kotlin
|
||||
fun showDirectoryPicker(title: String = "Select directory"): String? {
|
||||
if (PlatformDetector.isMacOS) {
|
||||
// macOS-specific directory picker
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", "true")
|
||||
}
|
||||
|
||||
val fileDialog = java.awt.FileDialog(java.awt.Frame(), title, java.awt.FileDialog.LOAD)
|
||||
fileDialog.isVisible = true
|
||||
|
||||
if (PlatformDetector.isMacOS) {
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", "false")
|
||||
}
|
||||
|
||||
return fileDialog.directory
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Window Decorations
|
||||
|
||||
### macOS-Specific
|
||||
|
||||
```kotlin
|
||||
// Unified title bar (macOS Big Sur+)
|
||||
if (PlatformDetector.isMacOS) {
|
||||
Window(
|
||||
undecorated = false,
|
||||
transparent = true,
|
||||
// ...
|
||||
) {
|
||||
// Custom title bar
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Windows-Specific
|
||||
|
||||
```kotlin
|
||||
// Custom window chrome (Windows)
|
||||
if (PlatformDetector.isWindows) {
|
||||
Window(
|
||||
undecorated = true,
|
||||
// Custom decorations
|
||||
) {
|
||||
Column {
|
||||
// Custom title bar with min/max/close buttons
|
||||
WindowTitleBar()
|
||||
// Content
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## System Tray Icons
|
||||
|
||||
Different icon formats per OS:
|
||||
|
||||
```kotlin
|
||||
fun getTrayIcon(): Painter {
|
||||
return when (PlatformDetector.platform) {
|
||||
Platform.MacOS -> painterResource("tray-icon-mac.png") // Template icon
|
||||
Platform.Windows -> painterResource("tray-icon-win.ico")
|
||||
Platform.Linux -> painterResource("tray-icon-linux.png")
|
||||
else -> painterResource("tray-icon.png")
|
||||
}
|
||||
}
|
||||
|
||||
// macOS: Template icons (black/transparent)
|
||||
// Windows: ICO format, 16x16
|
||||
// Linux: PNG, typically 24x24
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Native Notifications
|
||||
|
||||
```kotlin
|
||||
// Platform-specific notification implementation
|
||||
fun sendNotification(title: String, message: String) {
|
||||
PlatformDetector.platformSpecific(
|
||||
macOS = {
|
||||
// macOS: Use NSUserNotification (via tray)
|
||||
trayState.sendNotification(
|
||||
Notification(title, message, Notification.Type.Info)
|
||||
)
|
||||
},
|
||||
windows = {
|
||||
// Windows: Use Windows toast notifications
|
||||
trayState.sendNotification(
|
||||
Notification(title, message, Notification.Type.Info)
|
||||
)
|
||||
},
|
||||
linux = {
|
||||
// Linux: Use libnotify (via tray)
|
||||
trayState.sendNotification(
|
||||
Notification(title, message, Notification.Type.Info)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Detection
|
||||
|
||||
```kotlin
|
||||
object ArchDetector {
|
||||
private val arch = System.getProperty("os.arch").lowercase()
|
||||
|
||||
val isArm: Boolean = arch.contains("aarch") || arch.contains("arm")
|
||||
val isX64: Boolean = arch.contains("x86_64") || arch.contains("amd64")
|
||||
val isX86: Boolean = arch.contains("x86") && !isX64
|
||||
|
||||
val architecture: Architecture = when {
|
||||
isArm -> Architecture.ARM
|
||||
isX64 -> Architecture.X64
|
||||
isX86 -> Architecture.X86
|
||||
else -> Architecture.Unknown
|
||||
}
|
||||
|
||||
enum class Architecture {
|
||||
ARM,
|
||||
X64,
|
||||
X86,
|
||||
Unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: Load correct native library
|
||||
fun loadNativeLib() {
|
||||
val libName = when {
|
||||
PlatformDetector.isMacOS && ArchDetector.isArm -> "libsecp256k1-macos-arm64"
|
||||
PlatformDetector.isMacOS && ArchDetector.isX64 -> "libsecp256k1-macos-x64"
|
||||
PlatformDetector.isWindows && ArchDetector.isX64 -> "libsecp256k1-win-x64"
|
||||
PlatformDetector.isLinux && ArchDetector.isX64 -> "libsecp256k1-linux-x64"
|
||||
else -> throw UnsupportedOperationException("Unsupported platform")
|
||||
}
|
||||
|
||||
System.loadLibrary(libName)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Platform Detection
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun testPlatformDetection() {
|
||||
println("OS: ${System.getProperty("os.name")}")
|
||||
println("Version: ${System.getProperty("os.version")}")
|
||||
println("Arch: ${System.getProperty("os.arch")}")
|
||||
println()
|
||||
println("Is macOS: ${PlatformDetector.isMacOS}")
|
||||
println("Is Windows: ${PlatformDetector.isWindows}")
|
||||
println("Is Linux: ${PlatformDetector.isLinux}")
|
||||
println("Platform: ${PlatformDetector.platform}")
|
||||
println()
|
||||
println("User home: ${PlatformDetector.userHome}")
|
||||
println("App data: ${PlatformDetector.appDataDir}")
|
||||
println("File separator: ${PlatformDetector.fileSeparator}")
|
||||
}
|
||||
|
||||
// Example output (macOS):
|
||||
// OS: Mac OS X
|
||||
// Version: 14.2.1
|
||||
// Arch: aarch64
|
||||
//
|
||||
// Is macOS: true
|
||||
// Is Windows: false
|
||||
// Is Linux: false
|
||||
// Platform: MacOS
|
||||
//
|
||||
// User home: /Users/username
|
||||
// App data: /Users/username/Library/Application Support
|
||||
// File separator: /
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Centralize Detection
|
||||
|
||||
✅ **DO:** Use PlatformDetector singleton
|
||||
```kotlin
|
||||
if (PlatformDetector.isMacOS) { /* ... */ }
|
||||
```
|
||||
|
||||
❌ **DON'T:** Repeat detection everywhere
|
||||
```kotlin
|
||||
if (System.getProperty("os.name").lowercase().contains("mac")) { /* ... */ }
|
||||
```
|
||||
|
||||
### 2. Use expect/actual for Platform APIs
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect fun openFile(path: String)
|
||||
|
||||
// jvmMain (Desktop)
|
||||
actual fun openFile(path: String) {
|
||||
Desktop.getDesktop().open(File(path))
|
||||
}
|
||||
|
||||
// androidMain
|
||||
actual fun openFile(path: String) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(path)))
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Graceful Degradation
|
||||
|
||||
```kotlin
|
||||
fun openBrowser(url: String) {
|
||||
try {
|
||||
if (Desktop.isDesktopSupported()) {
|
||||
Desktop.getDesktop().browse(URI(url))
|
||||
} else {
|
||||
// Fallback: Copy to clipboard
|
||||
Toolkit.getDefaultToolkit().systemClipboard.setContents(
|
||||
StringSelection(url),
|
||||
null
|
||||
)
|
||||
showMessage("URL copied to clipboard: $url")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Failed to open browser: ${e.message}")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Test on All Platforms
|
||||
|
||||
Always test platform-specific code on:
|
||||
- macOS (Intel + Apple Silicon if possible)
|
||||
- Windows (10/11)
|
||||
- Linux (Ubuntu/Fedora)
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Config File Location
|
||||
|
||||
```kotlin
|
||||
fun getConfigFile(filename: String): File {
|
||||
val configDir = when (PlatformDetector.platform) {
|
||||
Platform.MacOS ->
|
||||
File("${PlatformDetector.userHome}/Library/Application Support/Amethyst")
|
||||
Platform.Windows ->
|
||||
File("${System.getenv("APPDATA")}\\Amethyst")
|
||||
Platform.Linux ->
|
||||
File("${PlatformDetector.userHome}/.config/amethyst")
|
||||
else ->
|
||||
File("${PlatformDetector.userHome}/.amethyst")
|
||||
}
|
||||
|
||||
if (!configDir.exists()) {
|
||||
configDir.mkdirs()
|
||||
}
|
||||
|
||||
return File(configDir, filename)
|
||||
}
|
||||
|
||||
// Usage
|
||||
val settingsFile = getConfigFile("settings.json")
|
||||
```
|
||||
|
||||
### Pattern: Platform-Specific Resources
|
||||
|
||||
```kotlin
|
||||
fun getPlatformIcon(name: String): Painter {
|
||||
val extension = when (PlatformDetector.platform) {
|
||||
Platform.MacOS -> "icns"
|
||||
Platform.Windows -> "ico"
|
||||
else -> "png"
|
||||
}
|
||||
|
||||
return painterResource("$name.$extension")
|
||||
}
|
||||
|
||||
// Resources:
|
||||
// src/jvmMain/resources/app-icon.icns (macOS)
|
||||
// src/jvmMain/resources/app-icon.ico (Windows)
|
||||
// src/jvmMain/resources/app-icon.png (Linux)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [System Properties (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/System.html#getProperties())
|
||||
- [Desktop API (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/awt/Desktop.html)
|
||||
- [File System Standards (XDG)](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)
|
||||
96
.claude/skills/find-missing-translations/SKILL.md
Normal file
96
.claude/skills/find-missing-translations/SKILL.md
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: find-missing-translations
|
||||
description: Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language
|
||||
---
|
||||
|
||||
# Find Missing Translations
|
||||
|
||||
## Overview
|
||||
|
||||
Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Need to find untranslated strings for a specific locale
|
||||
- Preparing a batch of strings for a translator
|
||||
- Checking translation coverage after adding new features
|
||||
|
||||
## Target Locales
|
||||
|
||||
The default set of locales (unless the user specifies otherwise):
|
||||
|
||||
| Locale | Language | Directory |
|
||||
|--------|----------|-----------|
|
||||
| `cs-rCZ` | Czech | `values-cs-rCZ` |
|
||||
| `pt-rBR` | Brazilian Portuguese | `values-pt-rBR` |
|
||||
| `sv-rSE` | Swedish | `values-sv-rSE` |
|
||||
| `de-rDE` | German | `values-de-rDE` |
|
||||
|
||||
## Technique
|
||||
|
||||
### 1. Identify files
|
||||
|
||||
```
|
||||
Default: amethyst/src/main/res/values/strings.xml
|
||||
Target: amethyst/src/main/res/values-<locale>/strings.xml
|
||||
```
|
||||
|
||||
### 2. Find missing keys using cs-rCZ as reference
|
||||
|
||||
Always diff against `cs-rCZ` first — it is the most complete locale and serves as the reference. Any keys missing in `cs-rCZ` will also be missing in the other target locales.
|
||||
|
||||
```bash
|
||||
# Extract translatable keys from default (exclude translatable="false")
|
||||
comm -23 \
|
||||
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
|
||||
| grep -v 'translatable="false"' \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||||
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
|
||||
```
|
||||
|
||||
This gives the list of missing key names. Do NOT diff each locale separately — assume the same keys are missing in all target locales.
|
||||
|
||||
### 3. Get English values for missing keys
|
||||
|
||||
For each missing key, extract its English value:
|
||||
|
||||
```bash
|
||||
# For each missing key, extract the full line from default strings.xml
|
||||
while IFS= read -r key; do
|
||||
grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml
|
||||
done < <(comm -23 \
|
||||
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
|
||||
| grep -v 'translatable="false"' \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
|
||||
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
|
||||
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
|
||||
```
|
||||
|
||||
### 4. Present results and ask to translate
|
||||
|
||||
Output the missing entries as raw XML resource lines (copy-paste ready):
|
||||
|
||||
```xml
|
||||
<string name="attestation_valid">Valid</string>
|
||||
<string name="attestation_valid_from">Valid from %1$s</string>
|
||||
<string name="feed_group_lists">Lists</string>
|
||||
```
|
||||
|
||||
Also check `<string-array>` and `<plurals>` tags using the same approach if the project uses them.
|
||||
|
||||
**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?"
|
||||
|
||||
### 5. Adding translations (if approved)
|
||||
|
||||
When adding translated strings to locale files:
|
||||
|
||||
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
|
||||
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Forgetting `translatable="false"`** — these should never appear in locale files
|
||||
- **Not checking string-arrays/plurals** — only checking `<string>` misses other resource types
|
||||
- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere
|
||||
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
|
||||
548
.claude/skills/gradle-expert/SKILL.md
Normal file
548
.claude/skills/gradle-expert/SKILL.md
Normal file
@@ -0,0 +1,548 @@
|
||||
---
|
||||
name: gradle-expert
|
||||
description: Build optimization, dependency resolution, and multi-module KMP troubleshooting for AmethystMultiplatform. Use when working with: (1) Gradle build files (build.gradle.kts, settings.gradle), (2) Version catalog (libs.versions.toml), (3) Build errors and dependency conflicts, (4) Module dependencies and source sets, (5) Desktop packaging (DMG/MSI/DEB), (6) Build performance optimization, (7) Proguard/R8 configuration, (8) Common KMP + Android Gradle issues (Compose conflicts, secp256k1 JNI variants, source set problems).
|
||||
---
|
||||
|
||||
# Gradle Expert
|
||||
|
||||
Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
|
||||
|
||||
## Build Architecture Mental Model
|
||||
|
||||
Think of this project as **4 layers**:
|
||||
|
||||
```
|
||||
┌─────────────┬─────────────┐
|
||||
│ :amethyst │ :desktopApp │ ← Platform apps (navigation, layouts)
|
||||
│ (Android) │ (JVM) │
|
||||
└──────┬──────┴──────┬──────┘
|
||||
│ │
|
||||
└──────┬──────┘
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :commons │ ← Shared UI (KMP with jvmAndroid)
|
||||
│ (KMP UI) │
|
||||
└──────┬──────┘
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :quartz │ ← Core library (KMP: Android/JVM/iOS)
|
||||
│(KMP Library)│
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
**Key insight:** Dependencies flow DOWN. Lower modules never depend on upper modules. This enables code sharing without circular dependencies.
|
||||
|
||||
**The jvmAndroid pattern:** Unique to this project. A custom source set between commonMain and {androidMain, jvmMain} for JVM-specific code shared by Android and Desktop. Not standard KMP, but critical for this architecture.
|
||||
|
||||
## Version Catalog Philosophy
|
||||
|
||||
All dependencies centralized in `gradle/libs.versions.toml`. Think "single source of truth."
|
||||
|
||||
**Pattern:**
|
||||
```toml
|
||||
[versions]
|
||||
kotlin = "2.3.0"
|
||||
|
||||
[libraries]
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
|
||||
[plugins]
|
||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation(libs.okhttp) // Type-safe, IDE-autocompleted
|
||||
}
|
||||
```
|
||||
|
||||
**Critical alignments:**
|
||||
- **Kotlin ecosystem:** All Kotlin plugins MUST share same version
|
||||
- **Compose ecosystem:** Compose Multiplatform version → Kotlin version (check compatibility matrix)
|
||||
- **secp256k1 variants:** All three variants (common, jni-android, jni-jvm) MUST share same version
|
||||
|
||||
See [references/version-catalog-guide.md](references/version-catalog-guide.md) for comprehensive patterns.
|
||||
|
||||
## Common Build Tasks
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```bash
|
||||
# Full builds
|
||||
./gradlew build # All modules
|
||||
./gradlew clean build # Clean build
|
||||
|
||||
# Desktop
|
||||
./gradlew :desktopApp:run # Run desktop app
|
||||
./gradlew :desktopApp:packageDmg # macOS package
|
||||
|
||||
# Module-specific
|
||||
./gradlew :quartz:build # KMP library only
|
||||
./gradlew :commons:build # Shared UI only
|
||||
|
||||
# Analysis
|
||||
./gradlew dependencies # Dependency tree
|
||||
./gradlew build --scan # Online diagnostics
|
||||
```
|
||||
|
||||
See [references/build-commands.md](references/build-commands.md) for comprehensive command reference.
|
||||
|
||||
## Module Structure & Dependencies
|
||||
|
||||
### Dependency Flow
|
||||
|
||||
**Desktop build chain:**
|
||||
```
|
||||
:desktopApp → :commons (jvmMain) → :quartz (jvmMain → jvmAndroid → commonMain)
|
||||
```
|
||||
|
||||
**Android build chain:**
|
||||
```
|
||||
:amethyst → :commons (androidMain) → :quartz (androidMain → jvmAndroid → commonMain)
|
||||
```
|
||||
|
||||
**Key source set pattern (quartz & commons):**
|
||||
```
|
||||
commonMain # Truly cross-platform code
|
||||
│
|
||||
├─ jvmAndroid # JVM-specific, shared by Android + Desktop
|
||||
│ ├─ androidMain
|
||||
│ └─ jvmMain
|
||||
│
|
||||
└─ iosMain # iOS-specific (quartz only)
|
||||
```
|
||||
|
||||
**Dependency config types:**
|
||||
- Use `api` when types appear in module's public API or expect/actual declarations
|
||||
- Use `implementation` for internal implementation details
|
||||
- Example: quartz exposes secp256k1 (`api`), but hides okhttp (`implementation`)
|
||||
|
||||
See [references/dependency-graph.md](references/dependency-graph.md) for module visualization and transitive dependency flow.
|
||||
|
||||
## Critical Dependency Patterns
|
||||
|
||||
### 1. secp256k1 (Crypto Library)
|
||||
|
||||
**The problem:** KMP library with platform-specific JNI bindings. Wrong variant = runtime crash.
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
// commonMain - API only
|
||||
api(libs.secp256k1.kmp.common)
|
||||
|
||||
// androidMain - Android JNI
|
||||
api(libs.secp256k1.kmp.jni.android)
|
||||
|
||||
// jvmMain - Desktop JVM JNI
|
||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
||||
```
|
||||
|
||||
**Why api in androidMain?** Types leak to consumers (:amethyst).
|
||||
|
||||
**Common error:** Desktop using jni-android variant → `UnsatisfiedLinkError: no secp256k1jni in java.library.path`
|
||||
|
||||
**Fix:** Check source set dependencies. jvmMain must use jni-jvm, never jni-android.
|
||||
|
||||
### 2. JNA (for LibSodium Encryption)
|
||||
|
||||
**The problem:** Android needs AAR packaging, JVM needs JAR. Same library, different artifact types.
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
// androidMain
|
||||
implementation("com.goterl:lazysodium-android:5.2.0@aar") // @aar explicit
|
||||
implementation("net.java.dev.jna:jna:5.18.1@aar")
|
||||
|
||||
// jvmMain
|
||||
implementation(libs.lazysodium.java) // JAR implicit
|
||||
implementation(libs.jna)
|
||||
```
|
||||
|
||||
**Critical:** Never put JNA in jvmAndroid or commonMain. Platform-specific packaging only.
|
||||
|
||||
### 3. Compose Versions
|
||||
|
||||
**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes.
|
||||
|
||||
**Current project config:**
|
||||
```toml
|
||||
composeMultiplatform = "1.9.3" # Plugin + runtime
|
||||
composeBom = "2025.12.01" # AndroidX Compose BOM
|
||||
kotlin = "2.3.0"
|
||||
```
|
||||
|
||||
**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
|
||||
|
||||
**In KMP modules (quartz, commons):**
|
||||
```kotlin
|
||||
// ✅ Use Compose Multiplatform
|
||||
implementation(compose.ui)
|
||||
implementation(compose.material3)
|
||||
|
||||
// ❌ DON'T use AndroidX BOM in KMP modules
|
||||
// implementation(libs.androidx.compose.bom)
|
||||
```
|
||||
|
||||
**In Android-only modules (amethyst):**
|
||||
```kotlin
|
||||
// Can use AndroidX BOM
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
```
|
||||
|
||||
## Desktop Packaging Basics
|
||||
|
||||
**TargetFormat options:**
|
||||
```kotlin
|
||||
// In desktopApp/build.gradle.kts
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
|
||||
packageName = "Amethyst"
|
||||
packageVersion = "1.0.0"
|
||||
|
||||
macOS {
|
||||
bundleID = "com.vitorpamplona.amethyst.desktop"
|
||||
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Package tasks:**
|
||||
```bash
|
||||
./gradlew :desktopApp:packageDmg # macOS
|
||||
./gradlew :desktopApp:packageMsi # Windows
|
||||
./gradlew :desktopApp:packageDeb # Linux
|
||||
```
|
||||
|
||||
**Output locations:**
|
||||
- macOS: `desktopApp/build/compose/binaries/main/dmg/`
|
||||
- Windows: `desktopApp/build/compose/binaries/main/msi/`
|
||||
- Linux: `desktopApp/build/compose/binaries/main/deb/`
|
||||
|
||||
**Icon requirements:**
|
||||
- macOS: `.icns` (multi-resolution: 512, 256, 128, 32)
|
||||
- Windows: `.ico` (256, 128, 64, 32, 16)
|
||||
- Linux: `.png` (512x512)
|
||||
|
||||
**Common issues:**
|
||||
- Main class not found → Verify `mainClass = "...MainKt"` (Kotlin adds `Kt` suffix)
|
||||
- Native libs missing → Ensure secp256k1-kmp-jni-jvm in dependencies
|
||||
- Icon not found → Check file exists at path, use absolute path if needed
|
||||
|
||||
## Build Performance Optimization
|
||||
|
||||
**Add to `gradle.properties`:**
|
||||
```properties
|
||||
# Daemon (faster subsequent builds)
|
||||
org.gradle.daemon=true
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
|
||||
|
||||
# Parallel execution (multi-module speedup)
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=8
|
||||
|
||||
# Caching (incremental builds)
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
# Kotlin daemon
|
||||
kotlin.incremental=true
|
||||
kotlin.daemon.jvmargs=-Xmx2g
|
||||
```
|
||||
|
||||
**Impact:** Typically 30-50% faster builds after first run.
|
||||
|
||||
**Measure impact:**
|
||||
```bash
|
||||
./gradlew clean build --profile
|
||||
# Report: build/reports/profile/profile-<timestamp>.html
|
||||
```
|
||||
|
||||
**When to clean build:**
|
||||
- After changing version catalog
|
||||
- After adding/removing source sets
|
||||
- When seeing unexplained errors
|
||||
|
||||
**When NOT to clean:**
|
||||
- Regular development iteration
|
||||
- Small code changes
|
||||
- Incremental compilation works fine
|
||||
|
||||
Use script: `scripts/analyze-build-time.sh` for automated profiling.
|
||||
|
||||
## Troubleshooting: Practical Patterns
|
||||
|
||||
### Pattern 1: Version Conflict
|
||||
|
||||
**Symptom:** `Duplicate class` or `NoSuchMethodError`
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
./gradlew dependencyInsight --dependency <library-name>
|
||||
```
|
||||
|
||||
**Fix options:**
|
||||
1. Align versions in libs.versions.toml (preferred)
|
||||
2. Force resolution:
|
||||
```kotlin
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force(libs.okhttp.get().toString())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Source Set Issues
|
||||
|
||||
**Symptom:** `Unresolved reference` to JVM library in shared code
|
||||
|
||||
**Diagnosis:** Check source set hierarchy. JVM-only libs (jackson, okhttp) can't be in commonMain.
|
||||
|
||||
**Fix:** Move to jvmAndroid or platform-specific source set.
|
||||
|
||||
```kotlin
|
||||
// ❌ Wrong
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(libs.jackson.module.kotlin) // JVM-only!
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Correct
|
||||
val jvmAndroid = create("jvmAndroid") {
|
||||
dependsOn(commonMain.get())
|
||||
dependencies {
|
||||
api(libs.jackson.module.kotlin) // JVM code, shared by Android + Desktop
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Proguard Stripping Native Libs
|
||||
|
||||
**Symptom:** `NoClassDefFoundError` for secp256k1, JNA, or LibSodium in release builds
|
||||
|
||||
**Fix:** Update proguard rules in `quartz/proguard-rules.pro`:
|
||||
```proguard
|
||||
# Native libraries
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
-keep class com.goterl.lazysodium.** { *; }
|
||||
-keep class com.sun.jna.** { *; }
|
||||
|
||||
# Jackson (reflection-based)
|
||||
-keep class com.vitorpamplona.quartz.** { *; }
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Signature
|
||||
```
|
||||
|
||||
### Pattern 4: Compose Compiler Mismatch
|
||||
|
||||
**Symptom:** `IllegalStateException: Version mismatch: runtime 1.10.0 but compiler 1.9.0`
|
||||
|
||||
**Fix:** Update Compose Multiplatform version in libs.versions.toml to match Kotlin version compatibility.
|
||||
|
||||
Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
|
||||
|
||||
### Pattern 5: Wrong JVM Target
|
||||
|
||||
**Symptom:** `Unsupported class file major version 65`
|
||||
|
||||
**Fix:** Ensure Java 21 everywhere:
|
||||
```bash
|
||||
# Check current Java
|
||||
java -version # Should show 21
|
||||
|
||||
# Set JAVA_HOME
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
|
||||
|
||||
# Stop Gradle daemon to pick up new Java
|
||||
./gradlew --stop
|
||||
```
|
||||
|
||||
Verify all build files use JVM 21:
|
||||
```kotlin
|
||||
kotlin {
|
||||
jvm {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comprehensive Error Guide
|
||||
|
||||
For detailed troubleshooting of specific errors, see [references/common-errors.md](references/common-errors.md). Covers:
|
||||
- Compose version conflicts
|
||||
- secp256k1 JNI errors
|
||||
- Source set dependency issues
|
||||
- Proguard/R8 problems
|
||||
- Desktop packaging errors
|
||||
- Kotlin compilation errors
|
||||
- Dependency resolution failures
|
||||
- JVM/JDK version issues
|
||||
|
||||
Each error includes: symptom, cause, solution, verification steps.
|
||||
|
||||
## Quick Diagnostic Commands
|
||||
|
||||
```bash
|
||||
# Check dependencies for specific module
|
||||
./gradlew :quartz:dependencies
|
||||
|
||||
# Find specific library in dependency tree
|
||||
./gradlew dependencyInsight --dependency okhttp
|
||||
|
||||
# Build with detailed logging
|
||||
./gradlew build --info
|
||||
|
||||
# Generate interactive build scan (best diagnostics)
|
||||
./gradlew build --scan
|
||||
|
||||
# Profile build performance
|
||||
./gradlew clean build --profile
|
||||
|
||||
# Stop all Gradle daemons (fresh start)
|
||||
./gradlew --stop
|
||||
|
||||
# Check Gradle version
|
||||
./gradlew --version
|
||||
```
|
||||
|
||||
## Scripts & References
|
||||
|
||||
### Diagnostic Scripts
|
||||
- `scripts/analyze-build-time.sh` - Profile build performance, generate optimization report
|
||||
- `scripts/fix-dependency-conflicts.sh` - Diagnose common dependency conflicts, suggest fixes
|
||||
|
||||
### Reference Docs
|
||||
- `references/build-commands.md` - Comprehensive command reference for all tasks
|
||||
- `references/dependency-graph.md` - Module dependencies, source set hierarchy, transitive deps
|
||||
- `references/version-catalog-guide.md` - Version catalog patterns, usage, best practices
|
||||
- `references/common-errors.md` - Troubleshooting guide for frequent build issues
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
### Example 1: Adding New Dependency
|
||||
|
||||
**Task:** Add kotlinx.datetime to quartz
|
||||
|
||||
**Steps:**
|
||||
1. **Update version catalog** (gradle/libs.versions.toml):
|
||||
```toml
|
||||
[versions]
|
||||
kotlinxDatetime = "0.6.0"
|
||||
|
||||
[libraries]
|
||||
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
||||
```
|
||||
|
||||
2. **Add to build file** (quartz/build.gradle.kts):
|
||||
```kotlin
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.datetime) // KMP library, goes in commonMain
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Sync & verify:**
|
||||
```bash
|
||||
./gradlew :quartz:dependencies | grep datetime
|
||||
```
|
||||
|
||||
### Example 2: Fixing secp256k1 Error on Desktop
|
||||
|
||||
**Error:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path` when running desktop app
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1
|
||||
# Shows: secp256k1-kmp-jni-android ← WRONG!
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
```kotlin
|
||||
// In quartz/build.gradle.kts
|
||||
jvmMain {
|
||||
dependencies {
|
||||
// Change from:
|
||||
// implementation(libs.secp256k1.kmp.jni.android) ❌
|
||||
|
||||
// To:
|
||||
implementation(libs.secp256k1.kmp.jni.jvm) // ✅
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1
|
||||
# Now shows: secp256k1-kmp-jni-jvm ✅
|
||||
|
||||
./gradlew :desktopApp:run # Should work
|
||||
```
|
||||
|
||||
### Example 3: Optimizing Build Time
|
||||
|
||||
**Current:** Clean build takes 5 minutes
|
||||
|
||||
**Steps:**
|
||||
1. **Baseline measurement:**
|
||||
```bash
|
||||
./gradlew clean build --profile
|
||||
# Check: build/reports/profile/profile-*.html
|
||||
```
|
||||
|
||||
2. **Add optimizations** to gradle.properties:
|
||||
```properties
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.jvmargs=-Xmx4g
|
||||
kotlin.incremental=true
|
||||
```
|
||||
|
||||
3. **Re-measure:**
|
||||
```bash
|
||||
./gradlew clean build --profile
|
||||
```
|
||||
|
||||
**Expected improvement:** 30-50% faster on subsequent builds (incremental builds much faster).
|
||||
|
||||
## Delegation Patterns
|
||||
|
||||
**When to delegate to other skills:**
|
||||
- **Source set architecture** (jvmAndroid pattern, expect/actual) → Use `kotlin-multiplatform` skill
|
||||
- **Compose UI issues** (composables, state management) → Use `compose-expert` skill (when available)
|
||||
- **Kotlin language issues** (Flow, sealed classes, DSLs) → Use `kotlin-expert` skill
|
||||
- **Desktop-specific features** (Window management, MenuBar, tray) → Use `desktop-expert` skill
|
||||
|
||||
**This skill handles:** Build system, dependencies, versioning, module structure, packaging, performance.
|
||||
|
||||
## Core Principles for This Build System
|
||||
|
||||
1. **Centralize versions:** Never hardcode versions in build.gradle.kts. Always use libs.versions.toml.
|
||||
|
||||
2. **Respect source set hierarchy:** Dependencies flow downward. jvmAndroid depends on commonMain, never the reverse.
|
||||
|
||||
3. **Platform-specific variants matter:** secp256k1, JNA must use correct variant per platform. Check when errors occur.
|
||||
|
||||
4. **Clean builds are expensive:** Use incremental compilation. Only clean when truly needed (source set changes, version updates).
|
||||
|
||||
5. **Compose alignment is critical:** Compose Multiplatform version must match Kotlin version. Check compatibility matrix.
|
||||
|
||||
6. **Proguard for native libs:** All JNI libraries need explicit `-keep` rules in release builds.
|
||||
|
||||
7. **Java 21 everywhere:** All modules, all targets, consistent JVM version.
|
||||
214
.claude/skills/gradle-expert/references/build-commands.md
Normal file
214
.claude/skills/gradle-expert/references/build-commands.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Build Commands Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Core Build Tasks](#core-build-tasks)
|
||||
- [Module-Specific Builds](#module-specific-builds)
|
||||
- [Desktop Tasks](#desktop-tasks)
|
||||
- [Android Tasks](#android-tasks)
|
||||
- [Testing](#testing)
|
||||
- [Analysis & Diagnostics](#analysis--diagnostics)
|
||||
- [Performance Optimization](#performance-optimization)
|
||||
|
||||
## Core Build Tasks
|
||||
|
||||
### Full Project Build
|
||||
```bash
|
||||
./gradlew build # Build all modules
|
||||
./gradlew clean build # Clean build
|
||||
./gradlew assemble # Build without tests
|
||||
```
|
||||
|
||||
### Incremental Builds
|
||||
```bash
|
||||
./gradlew :quartz:build # Build only quartz module
|
||||
./gradlew :commons:build # Build only commons module
|
||||
./gradlew :desktopApp:build # Build only desktop app
|
||||
```
|
||||
|
||||
## Module-Specific Builds
|
||||
|
||||
### Quartz (KMP Library)
|
||||
```bash
|
||||
./gradlew :quartz:build # All targets
|
||||
./gradlew :quartz:compileKotlinJvm # JVM target only
|
||||
./gradlew :quartz:compileDebugKotlinAndroid # Android target only
|
||||
./gradlew :quartz:linkDebugFrameworkIosArm64 # iOS framework
|
||||
./gradlew :quartz:publishToMavenLocal # Publish locally
|
||||
```
|
||||
|
||||
### Commons (Shared UI)
|
||||
```bash
|
||||
./gradlew :commons:build # All targets
|
||||
./gradlew :commons:compileKotlinJvm # Desktop target
|
||||
./gradlew :commons:compileDebugKotlinAndroid # Android target
|
||||
```
|
||||
|
||||
## Desktop Tasks
|
||||
|
||||
### Run Desktop App
|
||||
```bash
|
||||
./gradlew :desktopApp:run # Run desktop app
|
||||
./gradlew :desktopApp:runDistributable # Run packaged version
|
||||
```
|
||||
|
||||
### Package Desktop App
|
||||
```bash
|
||||
./gradlew :desktopApp:createDistributable # Create runnable package
|
||||
./gradlew :desktopApp:packageDmg # macOS DMG
|
||||
./gradlew :desktopApp:packageMsi # Windows MSI
|
||||
./gradlew :desktopApp:packageDeb # Linux DEB
|
||||
```
|
||||
|
||||
### Distribution Location
|
||||
- macOS: `desktopApp/build/compose/binaries/main/dmg/`
|
||||
- Windows: `desktopApp/build/compose/binaries/main/msi/`
|
||||
- Linux: `desktopApp/build/compose/binaries/main/deb/`
|
||||
|
||||
## Android Tasks
|
||||
|
||||
### Compile & Assemble
|
||||
```bash
|
||||
./gradlew :amethyst:assembleDebug # Debug APK
|
||||
./gradlew :amethyst:assembleRelease # Release APK
|
||||
./gradlew :amethyst:bundleRelease # Release AAB
|
||||
```
|
||||
|
||||
### Install & Run
|
||||
```bash
|
||||
./gradlew :amethyst:installDebug # Install debug on device
|
||||
adb shell am start -n com.vitorpamplona.amethyst/.MainActivity
|
||||
```
|
||||
|
||||
### Proguard/R8
|
||||
```bash
|
||||
./gradlew :quartz:minifyReleaseWithR8 # Test R8 minification
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
```bash
|
||||
./gradlew test # All unit tests
|
||||
./gradlew :quartz:jvmTest # JVM unit tests
|
||||
./gradlew :quartz:testDebugUnitTest # Android unit tests
|
||||
./gradlew :commons:test # Commons tests
|
||||
```
|
||||
|
||||
### Android Instrumented Tests
|
||||
```bash
|
||||
./gradlew :quartz:connectedAndroidTest # Requires device/emulator
|
||||
```
|
||||
|
||||
### Test Reports
|
||||
```bash
|
||||
# Reports location: <module>/build/reports/tests/
|
||||
open quartz/build/reports/tests/jvmTest/index.html
|
||||
```
|
||||
|
||||
## Analysis & Diagnostics
|
||||
|
||||
### Dependency Analysis
|
||||
```bash
|
||||
./gradlew dependencies # All dependencies
|
||||
./gradlew :quartz:dependencies # Quartz dependencies
|
||||
./gradlew dependencyInsight --dependency okhttp # Specific dependency
|
||||
```
|
||||
|
||||
### Build Scan
|
||||
```bash
|
||||
./gradlew build --scan # Upload to scans.gradle.com
|
||||
```
|
||||
|
||||
### Performance Profiling
|
||||
```bash
|
||||
./gradlew build --profile # Generate profile report
|
||||
# Report: build/reports/profile/profile-<timestamp>.html
|
||||
```
|
||||
|
||||
### Task Dependencies
|
||||
```bash
|
||||
./gradlew :desktopApp:run --dry-run # Show task graph
|
||||
./gradlew :desktopApp:dependencies --scan # Visualize dependencies
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Configuration Cache
|
||||
```bash
|
||||
./gradlew build --configuration-cache # Enable config cache
|
||||
./gradlew build --configuration-cache-problems=warn
|
||||
```
|
||||
|
||||
### Build Cache
|
||||
```bash
|
||||
./gradlew build --build-cache # Enable build cache
|
||||
./gradlew cleanBuildCache # Clear build cache
|
||||
```
|
||||
|
||||
### Parallel Execution
|
||||
```bash
|
||||
./gradlew build --parallel --max-workers=8 # Parallel with 8 workers
|
||||
```
|
||||
|
||||
### Daemon Management
|
||||
```bash
|
||||
./gradlew --stop # Stop Gradle daemon
|
||||
./gradlew --status # Daemon status
|
||||
```
|
||||
|
||||
### Incremental Compilation
|
||||
```bash
|
||||
# Already enabled by default in Kotlin, but can verify:
|
||||
./gradlew :quartz:compileKotlinJvm --info | grep "Incremental"
|
||||
```
|
||||
|
||||
## gradle.properties Optimizations
|
||||
|
||||
Add to `gradle.properties` for faster builds:
|
||||
|
||||
```properties
|
||||
# Daemon
|
||||
org.gradle.daemon=true
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
|
||||
|
||||
# Parallel
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=8
|
||||
|
||||
# Caching
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
# Kotlin
|
||||
kotlin.incremental=true
|
||||
kotlin.daemon.jvmargs=-Xmx2g
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Full Desktop Build & Run
|
||||
```bash
|
||||
./gradlew :desktopApp:clean :desktopApp:run
|
||||
```
|
||||
|
||||
### Quick Desktop Iteration
|
||||
```bash
|
||||
# No clean - incremental compilation
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
|
||||
### Android Release Build
|
||||
```bash
|
||||
./gradlew :amethyst:clean :amethyst:bundleRelease
|
||||
```
|
||||
|
||||
### Test All KMP Targets
|
||||
```bash
|
||||
./gradlew :quartz:test :quartz:testDebugUnitTest
|
||||
```
|
||||
|
||||
### Publish Quartz Locally for Testing
|
||||
```bash
|
||||
./gradlew :quartz:publishToMavenLocal
|
||||
# Then update version in consumer project to test
|
||||
```
|
||||
643
.claude/skills/gradle-expert/references/common-errors.md
Normal file
643
.claude/skills/gradle-expert/references/common-errors.md
Normal file
@@ -0,0 +1,643 @@
|
||||
# Common Build Errors & Solutions
|
||||
|
||||
## Table of Contents
|
||||
- [Compose Version Conflicts](#compose-version-conflicts)
|
||||
- [secp256k1 JNI Errors](#secp256k1-jni-errors)
|
||||
- [Source Set Dependency Issues](#source-set-dependency-issues)
|
||||
- [Proguard/R8 Issues](#proguardr8-issues)
|
||||
- [Desktop Packaging Errors](#desktop-packaging-errors)
|
||||
- [Kotlin Compilation Errors](#kotlin-compilation-errors)
|
||||
- [Dependency Resolution Failures](#dependency-resolution-failures)
|
||||
- [JVM/JDK Version Issues](#jvmjdk-version-issues)
|
||||
|
||||
---
|
||||
|
||||
## Compose Version Conflicts
|
||||
|
||||
### Error 1: Compose Runtime Mismatch
|
||||
|
||||
```
|
||||
java.lang.IllegalStateException: Version mismatch: Compose runtime is 1.10.0 but compiler is 1.9.0
|
||||
```
|
||||
|
||||
**Cause:** Compose Compiler plugin version doesn't match Compose Runtime
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In gradle/libs.versions.toml
|
||||
composeMultiplatform = "1.9.3" // Must align with Kotlin version
|
||||
kotlin = "2.3.0"
|
||||
|
||||
// Check compatibility matrix:
|
||||
// https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
./gradlew :commons:dependencies | grep compose
|
||||
```
|
||||
|
||||
### Error 2: AndroidX Compose BOM Conflict
|
||||
|
||||
```
|
||||
Duplicate class androidx.compose.ui.platform.AndroidCompositionLocalMap found in modules...
|
||||
```
|
||||
|
||||
**Cause:** Both Compose Multiplatform and AndroidX Compose BOM providing same classes
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In commons/build.gradle.kts (KMP module)
|
||||
// Use Compose Multiplatform, NOT AndroidX BOM
|
||||
dependencies {
|
||||
implementation(compose.ui) // ✅ Compose Multiplatform
|
||||
implementation(compose.material3)
|
||||
|
||||
// Don't use in KMP modules:
|
||||
// implementation(libs.androidx.compose.bom) // ❌ Android-only
|
||||
}
|
||||
|
||||
// In amethyst/build.gradle.kts (Android-only module)
|
||||
// Can use AndroidX BOM
|
||||
dependencies {
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
implementation(libs.androidx.ui)
|
||||
}
|
||||
```
|
||||
|
||||
### Error 3: Material3 WindowSizeClass Not Found
|
||||
|
||||
```
|
||||
Unresolved reference: WindowSizeClass
|
||||
```
|
||||
|
||||
**Cause:** Using Android's WindowSizeClass in shared KMP code
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// Don't use in commonMain or jvmAndroid:
|
||||
// import androidx.compose.material3.windowsizeclass.WindowSizeClass // ❌
|
||||
|
||||
// Use in androidMain only, or create expect/actual:
|
||||
// commonMain
|
||||
expect class WindowSizeClassAdapter
|
||||
|
||||
// androidMain
|
||||
actual typealias WindowSizeClassAdapter = androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
|
||||
// jvmMain (desktop)
|
||||
actual class WindowSizeClassAdapter { /* Custom impl */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## secp256k1 JNI Errors
|
||||
|
||||
### Error 1: JNI Library Not Found (Desktop)
|
||||
|
||||
```
|
||||
java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path
|
||||
```
|
||||
|
||||
**Cause:** Desktop using wrong secp256k1 variant (Android JNI instead of JVM JNI)
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In quartz/build.gradle.kts
|
||||
sourceSets {
|
||||
jvmMain {
|
||||
dependencies {
|
||||
// ✅ Correct - JVM variant
|
||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
||||
|
||||
// ❌ Wrong - Android variant
|
||||
// implementation(libs.secp256k1.kmp.jni.android)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
./gradlew :quartz:dependencies --configuration jvmRuntimeClasspath | grep secp256k1
|
||||
# Should show: secp256k1-kmp-jni-jvm, NOT jni-android
|
||||
```
|
||||
|
||||
### Error 2: Version Mismatch Between Variants
|
||||
|
||||
```
|
||||
java.lang.NoSuchMethodError: fr.acinq.secp256k1.Secp256k1.sign
|
||||
```
|
||||
|
||||
**Cause:** Common, Android, and JVM variants have different versions
|
||||
|
||||
**Solution:**
|
||||
```toml
|
||||
# In gradle/libs.versions.toml
|
||||
# All three MUST use same version
|
||||
secp256k1KmpJniAndroid = "0.22.0"
|
||||
|
||||
[libraries]
|
||||
secp256k1-kmp-common = { ..., version.ref = "secp256k1KmpJniAndroid" }
|
||||
secp256k1-kmp-jni-android = { ..., version.ref = "secp256k1KmpJniAndroid" }
|
||||
secp256k1-kmp-jni-jvm = { ..., version.ref = "secp256k1KmpJniAndroid" }
|
||||
```
|
||||
|
||||
### Error 3: Android JNI Not Loaded
|
||||
|
||||
```
|
||||
java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader couldn't find "libsecp256k1jni.so"
|
||||
```
|
||||
|
||||
**Cause:** Proguard stripping JNI classes
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
# In quartz/proguard-rules.pro
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Source Set Dependency Issues
|
||||
|
||||
### Error 1: jvmAndroid Defined After androidMain
|
||||
|
||||
```
|
||||
Could not get unknown property 'jvmAndroid' for source set container
|
||||
```
|
||||
|
||||
**Cause:** Source sets must be defined in dependency order
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// ✅ Correct order
|
||||
sourceSets {
|
||||
commonMain { }
|
||||
|
||||
// Define jvmAndroid BEFORE androidMain and jvmMain
|
||||
val jvmAndroid = create("jvmAndroid") {
|
||||
dependsOn(commonMain.get())
|
||||
}
|
||||
|
||||
androidMain {
|
||||
dependsOn(jvmAndroid) // Now jvmAndroid exists
|
||||
}
|
||||
|
||||
jvmMain {
|
||||
dependsOn(jvmAndroid)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error 2: Dependency in Wrong Source Set
|
||||
|
||||
```
|
||||
Unresolved reference: ObjectMapper (Jackson)
|
||||
```
|
||||
|
||||
**Cause:** JVM-only library in commonMain
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
sourceSets {
|
||||
commonMain {
|
||||
// ❌ Jackson is JVM-only, can't use here
|
||||
// implementation(libs.jackson.module.kotlin)
|
||||
}
|
||||
|
||||
val jvmAndroid = create("jvmAndroid") {
|
||||
dependsOn(commonMain.get())
|
||||
// ✅ Jackson in jvmAndroid (shared JVM code)
|
||||
api(libs.jackson.module.kotlin)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error 3: Platform-Specific Code in Shared Source Set
|
||||
|
||||
```
|
||||
java.lang.NoClassDefFoundError: android.content.Context
|
||||
```
|
||||
|
||||
**Cause:** Android-specific API in jvmAndroid or commonMain
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// Use expect/actual pattern
|
||||
|
||||
// commonMain
|
||||
expect class PlatformContext
|
||||
|
||||
// androidMain
|
||||
actual typealias PlatformContext = android.content.Context
|
||||
|
||||
// jvmMain
|
||||
actual class PlatformContext {
|
||||
// Custom desktop implementation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proguard/R8 Issues
|
||||
|
||||
### Error 1: Native Library Classes Stripped
|
||||
|
||||
```
|
||||
java.lang.NoClassDefFoundError: com.goterl.lazysodium.Sodium
|
||||
```
|
||||
|
||||
**Cause:** R8/Proguard removing JNA/LibSodium classes
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
# In quartz/proguard-rules.pro
|
||||
-keep class com.goterl.lazysodium.** { *; }
|
||||
-keep class com.sun.jna.** { *; }
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
```
|
||||
|
||||
### Error 2: Reflection-Based Libraries Broken
|
||||
|
||||
```
|
||||
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of ...
|
||||
```
|
||||
|
||||
**Cause:** Jackson uses reflection, R8 strips class metadata
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
# Preserve reflection metadata
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Signature
|
||||
-keepattributes InnerClasses
|
||||
|
||||
# Keep all Quartz event classes
|
||||
-keep class com.vitorpamplona.quartz.** { *; }
|
||||
```
|
||||
|
||||
### Error 3: Enum Values Missing
|
||||
|
||||
```
|
||||
java.lang.IllegalArgumentException: No enum constant ...
|
||||
```
|
||||
|
||||
**Cause:** R8 obfuscating enum names
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
# Keep all enums
|
||||
-keep enum ** { *; }
|
||||
-keepnames class ** { *; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Desktop Packaging Errors
|
||||
|
||||
### Error 1: Icon Not Found
|
||||
|
||||
```
|
||||
FAILURE: Build failed with an exception.
|
||||
* What went wrong: Cannot find icon file: src/jvmMain/resources/icon.icns
|
||||
```
|
||||
|
||||
**Cause:** Icon file missing or wrong path
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In desktopApp/build.gradle.kts
|
||||
nativeDistributions {
|
||||
macOS {
|
||||
// Ensure file exists at this path
|
||||
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
|
||||
}
|
||||
|
||||
// Check file exists:
|
||||
// ls -la desktopApp/src/jvmMain/resources/
|
||||
}
|
||||
```
|
||||
|
||||
**Icon Requirements:**
|
||||
- macOS: `.icns` (512x512, 256x256, 128x128, 32x32)
|
||||
- Windows: `.ico` (256x256, 128x128, 64x64, 32x32, 16x16)
|
||||
- Linux: `.png` (512x512 recommended)
|
||||
|
||||
### Error 2: Main Class Not Found
|
||||
|
||||
```
|
||||
Error: Could not find or load main class com.vitorpamplona.amethyst.desktop.MainKt
|
||||
```
|
||||
|
||||
**Cause:** Wrong mainClass path or Main.kt doesn't have main()
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In desktopApp/build.gradle.kts
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
|
||||
// ^^^^
|
||||
// Kotlin compiler adds "Kt" suffix
|
||||
}
|
||||
}
|
||||
|
||||
// In src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
|
||||
fun main() = application {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Error 3: Native Library Missing in Package
|
||||
|
||||
```
|
||||
java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path
|
||||
```
|
||||
|
||||
**Cause:** Native libraries not bundled in distribution
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// Native libs are automatically included via dependencies
|
||||
// Verify secp256k1-kmp-jni-jvm is in dependencies:
|
||||
dependencies {
|
||||
implementation(libs.secp256k1.kmp.jni.jvm) // ✅ Includes native libs
|
||||
}
|
||||
|
||||
// Test packaged app:
|
||||
./gradlew :desktopApp:createDistributable
|
||||
# Run from: desktopApp/build/compose/binaries/main/app/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kotlin Compilation Errors
|
||||
|
||||
### Error 1: Expect/Actual Mismatch
|
||||
|
||||
```
|
||||
'actual' declaration has no corresponding expected declaration
|
||||
```
|
||||
|
||||
**Cause:** Signature mismatch or missing expect
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// commonMain - expect declaration
|
||||
expect class CryptoProvider {
|
||||
fun sign(message: ByteArray, privateKey: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
// androidMain & jvmMain - actual must match EXACTLY
|
||||
actual class CryptoProvider {
|
||||
actual fun sign(message: ByteArray, privateKey: ByteArray): ByteArray {
|
||||
// Implementation
|
||||
}
|
||||
}
|
||||
|
||||
// Common mistakes:
|
||||
// - Different parameter names ❌
|
||||
// - Different return types ❌
|
||||
// - Missing 'actual' modifier ❌
|
||||
```
|
||||
|
||||
### Error 2: Target JVM Version Mismatch
|
||||
|
||||
```
|
||||
Compilation failed: module was compiled with an incompatible version of Kotlin
|
||||
```
|
||||
|
||||
**Cause:** Different JVM targets across modules
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// Ensure ALL modules use same JVM target
|
||||
|
||||
// In quartz/build.gradle.kts
|
||||
kotlin {
|
||||
jvm {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21) // ✅ Java 21
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In android {} block
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
```
|
||||
|
||||
### Error 3: Compose Compiler Plugin Missing
|
||||
|
||||
```
|
||||
This declaration needs opt-in. Please use @OptIn(ComposeApi::class) or @Composable
|
||||
```
|
||||
|
||||
**Cause:** Compose compiler plugin not applied
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In build.gradle.kts
|
||||
plugins {
|
||||
alias(libs.plugins.jetbrainsComposeCompiler) // ✅ Add this
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Resolution Failures
|
||||
|
||||
### Error 1: Repository Not Found
|
||||
|
||||
```
|
||||
Could not find com.github.vitorpamplona.compose-richtext:richtext-ui:f92ef49c9d
|
||||
```
|
||||
|
||||
**Cause:** Jitpack or custom Maven repository not configured
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In settings.gradle
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = "https://jitpack.io" } // ✅ Add Jitpack
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error 2: Gradle Version Too Old
|
||||
|
||||
```
|
||||
Version catalogs are not supported in this version of Gradle
|
||||
```
|
||||
|
||||
**Cause:** Gradle < 7.0
|
||||
|
||||
**Solution:**
|
||||
```properties
|
||||
# In gradle/wrapper/gradle-wrapper.properties
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
```
|
||||
|
||||
Then: `./gradlew wrapper --gradle-version=8.9`
|
||||
|
||||
### Error 3: Dependency Variant Not Found
|
||||
|
||||
```
|
||||
No matching variant of fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0 was found
|
||||
```
|
||||
|
||||
**Cause:** Wrong dependency configuration for target
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In androidMain (Android library module)
|
||||
dependencies {
|
||||
// For AAR packaging
|
||||
implementation("net.java.dev.jna:jna:5.18.1@aar") // ✅ Specify @aar
|
||||
|
||||
// secp256k1 works without @aar (auto-detects)
|
||||
api(libs.secp256k1.kmp.jni.android)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JVM/JDK Version Issues
|
||||
|
||||
### Error 1: Unsupported Class File Version
|
||||
|
||||
```
|
||||
Unsupported class file major version 65
|
||||
```
|
||||
|
||||
**Cause:** Compiled with Java 21, running with older Java
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check Java version
|
||||
java -version # Should show 21
|
||||
|
||||
# Set JAVA_HOME if needed
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
|
||||
|
||||
# Or in gradle.properties
|
||||
org.gradle.java.home=/path/to/jdk-21
|
||||
```
|
||||
|
||||
### Error 2: JVM Toolchain Not Found
|
||||
|
||||
```
|
||||
No matching toolchain found for requested JvmVersion
|
||||
```
|
||||
|
||||
**Cause:** Java 21 not installed or not detected
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install openjdk@21
|
||||
|
||||
# Ubuntu
|
||||
sudo apt install openjdk-21-jdk
|
||||
|
||||
# Set JAVA_HOME
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
|
||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk # Linux
|
||||
|
||||
# Verify
|
||||
./gradlew -version
|
||||
```
|
||||
|
||||
### Error 3: Gradle Daemon Using Wrong Java
|
||||
|
||||
```
|
||||
Daemon will be stopped at the end of the build because JVM version has changed
|
||||
```
|
||||
|
||||
**Cause:** Daemon started with different Java version
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Stop all daemons
|
||||
./gradlew --stop
|
||||
|
||||
# Start with correct JAVA_HOME
|
||||
export JAVA_HOME=/path/to/jdk-21
|
||||
./gradlew build
|
||||
|
||||
# Or set in gradle.properties permanently
|
||||
org.gradle.java.home=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## General Troubleshooting Steps
|
||||
|
||||
### Step 1: Clean Build
|
||||
```bash
|
||||
./gradlew clean
|
||||
./gradlew --stop # Stop daemon
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
### Step 2: Check Dependencies
|
||||
```bash
|
||||
./gradlew :moduleName:dependencies
|
||||
./gradlew dependencyInsight --dependency libraryName
|
||||
```
|
||||
|
||||
### Step 3: Enable Debug Logging
|
||||
```bash
|
||||
./gradlew build --info # Info logging
|
||||
./gradlew build --debug # Debug logging (verbose)
|
||||
./gradlew build --stacktrace
|
||||
```
|
||||
|
||||
### Step 4: Invalidate Caches
|
||||
```bash
|
||||
# Clear Gradle cache
|
||||
rm -rf ~/.gradle/caches/
|
||||
|
||||
# Clear build outputs
|
||||
./gradlew clean
|
||||
|
||||
# Clear Gradle wrapper cache
|
||||
rm -rf ~/.gradle/wrapper/
|
||||
```
|
||||
|
||||
### Step 5: Build Scan
|
||||
```bash
|
||||
./gradlew build --scan
|
||||
# Opens interactive diagnostics in browser
|
||||
```
|
||||
|
||||
## Quick Reference: Error Keywords → Solution
|
||||
|
||||
| Error Keyword | Likely Cause | Quick Fix |
|
||||
|---------------|--------------|-----------|
|
||||
| `UnsatisfiedLinkError` | Wrong JNI variant | Check secp256k1/JNA variants by platform |
|
||||
| `IllegalStateException` (Compose) | Version mismatch | Align Compose Multiplatform + Kotlin versions |
|
||||
| `NoClassDefFoundError` | Proguard stripping | Add `-keep` rule for class |
|
||||
| `Unresolved reference` | Wrong source set | Move to appropriate source set (jvmAndroid) |
|
||||
| `Duplicate class` | BOM conflict | Remove AndroidX BOM from KMP modules |
|
||||
| `Version mismatch` | Plugin/runtime version mismatch | Update libs.versions.toml |
|
||||
| `No matching variant` | Repository or packaging issue | Add repository or @aar suffix |
|
||||
| `Could not find` (dependency) | Missing repository | Add maven/jitpack to repositories |
|
||||
| `Unsupported class file` | Java version mismatch | Update JAVA_HOME to Java 21 |
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
1. **Check Build Scan**: `./gradlew build --scan` for detailed diagnostics
|
||||
2. **Gradle Forums**: https://discuss.gradle.org/
|
||||
3. **Kotlin Slack**: #multiplatform channel
|
||||
4. **Stack Overflow**: Tags `gradle`, `kotlin-multiplatform`, `compose-multiplatform`
|
||||
265
.claude/skills/gradle-expert/references/dependency-graph.md
Normal file
265
.claude/skills/gradle-expert/references/dependency-graph.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Module Dependency Graph
|
||||
|
||||
## Visual Hierarchy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Root Project │
|
||||
│ (Amethyst) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┬────────────┐
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐
|
||||
│ :amethyst │ │ :desktopApp │ │ :benchmark │ │:ammolite │
|
||||
│ (Android) │ │ (JVM) │ │ (Android) │ │ (Support)│
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └──────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
└────────────────┼────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :commons │
|
||||
│ (KMP UI) │
|
||||
│ │
|
||||
│ jvmAndroid │
|
||||
│ / \ │
|
||||
│ jvm android│
|
||||
└─────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :quartz │
|
||||
│(KMP Library)│
|
||||
│ │
|
||||
│ commonMain │
|
||||
│ │ │
|
||||
│ jvmAndroid │
|
||||
│ / | \ │
|
||||
│jvm and ios │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Module Details
|
||||
|
||||
### :quartz (KMP Nostr Library)
|
||||
**Type:** Kotlin Multiplatform Library
|
||||
**Targets:** JVM, Android, iOS (iosArm64, iosSimulatorArm64)
|
||||
**Dependencies:**
|
||||
- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable
|
||||
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain
|
||||
|
||||
**Role:** Core Nostr protocol implementation, shared across all platforms
|
||||
|
||||
### :commons (Shared UI Components)
|
||||
**Type:** Kotlin Multiplatform Library
|
||||
**Targets:** JVM, Android
|
||||
**Dependencies:**
|
||||
- Module: `:quartz`
|
||||
- External: Compose Multiplatform, Material3, kotlinx.collections.immutable
|
||||
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}
|
||||
|
||||
**Role:** Shared Compose UI components for Desktop and Android
|
||||
|
||||
### :desktopApp (Desktop Application)
|
||||
**Type:** JVM Application
|
||||
**Targets:** JVM (Desktop)
|
||||
**Dependencies:**
|
||||
- Modules: `:commons`, `:quartz`
|
||||
- External: Compose Desktop, kotlinx.coroutines.swing
|
||||
|
||||
**Role:** Desktop-specific navigation, layouts, and entry point
|
||||
|
||||
### :amethyst (Android Application)
|
||||
**Type:** Android Application
|
||||
**Targets:** Android
|
||||
**Dependencies:**
|
||||
- Modules: `:commons`, `:quartz`, `:ammolite`
|
||||
- External: Android SDK, AndroidX, Firebase, Tor
|
||||
|
||||
**Role:** Android-specific navigation, layouts, and entry point
|
||||
|
||||
### :benchmark (Android Benchmark)
|
||||
**Type:** Android Library
|
||||
**Targets:** Android
|
||||
**Dependencies:**
|
||||
- Modules: `:commons`, `:quartz`
|
||||
- External: AndroidX Benchmark
|
||||
|
||||
**Role:** Performance benchmarking for Android builds
|
||||
|
||||
### :ammolite (Support Module)
|
||||
**Type:** Android Library
|
||||
**Targets:** Android
|
||||
**Dependencies:** Android-specific utilities
|
||||
|
||||
**Role:** Android support utilities for amethyst
|
||||
|
||||
## Dependency Flow Patterns
|
||||
|
||||
### Desktop Build Chain
|
||||
```
|
||||
:desktopApp → :commons (jvmMain) → :quartz (jvmMain)
|
||||
↓
|
||||
jvmAndroid
|
||||
↓
|
||||
commonMain
|
||||
```
|
||||
|
||||
### Android Build Chain
|
||||
```
|
||||
:amethyst → :commons (androidMain) → :quartz (androidMain)
|
||||
↓ ↓
|
||||
:ammolite jvmAndroid
|
||||
↓
|
||||
commonMain
|
||||
```
|
||||
|
||||
## Source Set Dependencies
|
||||
|
||||
### :quartz Source Sets
|
||||
```
|
||||
commonMain (base)
|
||||
├─ jvmAndroid (shared JVM code)
|
||||
│ ├─ androidMain (Android platform)
|
||||
│ └─ jvmMain (Desktop platform)
|
||||
└─ iosMain (iOS platform)
|
||||
├─ iosArm64Main
|
||||
└─ iosSimulatorArm64Main
|
||||
```
|
||||
|
||||
**Key Dependencies per Source Set:**
|
||||
- **commonMain**: secp256k1-kmp, kotlinx.coroutines, collection, immutable collections
|
||||
- **jvmAndroid**: jackson, okhttp, url-detector, rfc3986
|
||||
- **androidMain**: secp256k1-kmp-jni-android, lazysodium-android, jna (aar)
|
||||
- **jvmMain**: secp256k1-kmp-jni-jvm, lazysodium-java, jna (jar)
|
||||
|
||||
### :commons Source Sets
|
||||
```
|
||||
commonMain (base UI)
|
||||
└─ jvmAndroid (shared JVM UI)
|
||||
├─ androidMain (Android UI utilities)
|
||||
└─ jvmMain (Desktop UI utilities)
|
||||
```
|
||||
|
||||
**Key Dependencies per Source Set:**
|
||||
- **commonMain**: Compose Multiplatform, Material3, :quartz
|
||||
- **jvmAndroid**: url-detector
|
||||
- **androidMain**: AndroidX Compose tooling
|
||||
- **jvmMain**: Compose Desktop
|
||||
|
||||
## Critical Dependency Patterns
|
||||
|
||||
### 1. secp256k1 Variants
|
||||
```kotlin
|
||||
// commonMain - API only
|
||||
api(libs.secp256k1.kmp.common)
|
||||
|
||||
// androidMain - JNI Android
|
||||
api(libs.secp256k1.kmp.jni.android)
|
||||
|
||||
// jvmMain - JNI JVM
|
||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
||||
```
|
||||
**Why:** Different JNI bindings for Android vs Desktop JVM
|
||||
|
||||
### 2. JNA Variants (for LibSodium)
|
||||
```kotlin
|
||||
// androidMain
|
||||
implementation("com.goterl:lazysodium-android:5.2.0@aar")
|
||||
implementation("net.java.dev.jna:jna:5.18.1@aar")
|
||||
|
||||
// jvmMain
|
||||
implementation(libs.lazysodium.java)
|
||||
implementation(libs.jna) // JAR variant
|
||||
```
|
||||
**Why:** Android needs AAR packaging, JVM needs JAR
|
||||
|
||||
### 3. Compose Alignment
|
||||
```kotlin
|
||||
// commons/build.gradle.kts
|
||||
implementation(compose.ui) // Compose Multiplatform BOM
|
||||
implementation(compose.material3)
|
||||
|
||||
// Version catalog alignment
|
||||
composeMultiplatform = "1.9.3"
|
||||
composeBom = "2025.12.01" // AndroidX Compose
|
||||
```
|
||||
**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align
|
||||
|
||||
## Dependency Configuration Types
|
||||
|
||||
### API vs Implementation
|
||||
|
||||
**Use `api` when:**
|
||||
- Dependency types appear in module's public API
|
||||
- Used in expect/actual declarations visible to consumers
|
||||
- Example: `secp256k1-kmp-common` in quartz (public types)
|
||||
|
||||
**Use `implementation` when:**
|
||||
- Internal implementation detail
|
||||
- Not exposed to module consumers
|
||||
- Example: `okhttp` in quartz (internal network client)
|
||||
|
||||
### Example from quartz
|
||||
```kotlin
|
||||
// Public API - exposed to consumers
|
||||
api(libs.secp256k1.kmp.common)
|
||||
api(libs.jackson.module.kotlin) // Event serialization public
|
||||
|
||||
// Internal implementation
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
```
|
||||
|
||||
## Transitive Dependency Impact
|
||||
|
||||
### When :desktopApp depends on :commons
|
||||
- Gets `:quartz` transitively (via :commons)
|
||||
- Gets `secp256k1-kmp-jvm` transitively (via :quartz jvmMain)
|
||||
- Does NOT get Android-specific dependencies (scoped to androidMain)
|
||||
|
||||
### When :amethyst depends on :commons
|
||||
- Gets `:quartz` transitively (via :commons)
|
||||
- Gets `secp256k1-kmp-jni-android` transitively (via :quartz androidMain)
|
||||
- Does NOT get JVM/Desktop-specific dependencies (scoped to jvmMain)
|
||||
|
||||
## Verifying Dependencies
|
||||
|
||||
### Check Module Dependencies
|
||||
```bash
|
||||
./gradlew :desktopApp:dependencies
|
||||
./gradlew :amethyst:dependencies
|
||||
```
|
||||
|
||||
### Check Specific Library
|
||||
```bash
|
||||
./gradlew dependencyInsight --dependency secp256k1
|
||||
./gradlew dependencyInsight --dependency compose-ui
|
||||
```
|
||||
|
||||
### Visualize with Build Scan
|
||||
```bash
|
||||
./gradlew :desktopApp:dependencies --scan
|
||||
# Opens interactive dependency graph in browser
|
||||
```
|
||||
|
||||
## Common Dependency Issues
|
||||
|
||||
### Issue 1: Wrong secp256k1 Variant in Desktop
|
||||
**Symptom:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path`
|
||||
**Cause:** Desktop using Android JNI variant
|
||||
**Fix:** Ensure jvmMain uses `secp256k1-kmp-jni-jvm`
|
||||
|
||||
### Issue 2: Compose Version Mismatch
|
||||
**Symptom:** `IllegalStateException: Version mismatch`
|
||||
**Cause:** Compose Multiplatform plugin vs runtime version mismatch
|
||||
**Fix:** Align `composeMultiplatform` version in libs.versions.toml with Kotlin plugin
|
||||
|
||||
### Issue 3: Duplicate JNA Classes
|
||||
**Symptom:** `DuplicateClassException: com.sun.jna.Native`
|
||||
**Cause:** Both JAR and AAR JNA variants in classpath
|
||||
**Fix:** Use AAR (@aar) in androidMain, JAR in jvmMain (never in shared source sets)
|
||||
422
.claude/skills/gradle-expert/references/version-catalog-guide.md
Normal file
422
.claude/skills/gradle-expert/references/version-catalog-guide.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# Version Catalog Guide
|
||||
|
||||
## Overview
|
||||
|
||||
AmethystMultiplatform uses Gradle's version catalog (`gradle/libs.versions.toml`) to centralize dependency management. This ensures version consistency across all modules and simplifies updates.
|
||||
|
||||
## Structure
|
||||
|
||||
### sections
|
||||
```toml
|
||||
[versions] # Version numbers (referenced by libraries and plugins)
|
||||
[libraries] # Library dependencies
|
||||
[plugins] # Gradle plugins
|
||||
```
|
||||
|
||||
## Version References
|
||||
|
||||
### Defining Versions
|
||||
```toml
|
||||
[versions]
|
||||
kotlin = "2.3.0"
|
||||
composeMultiplatform = "1.9.3"
|
||||
okhttp = "5.3.2"
|
||||
```
|
||||
|
||||
### Special Patterns
|
||||
|
||||
#### Android SDK Versions
|
||||
```toml
|
||||
android-compileSdk = "36"
|
||||
android-minSdk = "26"
|
||||
android-targetSdk = "36"
|
||||
```
|
||||
**Access in build.gradle.kts:**
|
||||
```kotlin
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
```
|
||||
|
||||
#### Version Suffixes (Git Commits)
|
||||
```toml
|
||||
androidKotlinGeohash = "b481c6a64e" # Jitpack commit hash
|
||||
markdown = "f92ef49c9d"
|
||||
```
|
||||
**Why:** For GitHub dependencies via Jitpack that don't have semantic versions
|
||||
|
||||
## Library Declarations
|
||||
|
||||
### Basic Pattern
|
||||
```toml
|
||||
[libraries]
|
||||
library-name = { group = "...", name = "...", version.ref = "..." }
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
#### Version Reference
|
||||
```toml
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
```
|
||||
|
||||
#### Module Reference (for multi-artifact libs)
|
||||
```toml
|
||||
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" }
|
||||
```
|
||||
|
||||
#### Without Group (shorthand)
|
||||
```toml
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
```
|
||||
**Note:** Inherits version from BOM (compose-bom)
|
||||
|
||||
### BOMs (Bill of Materials)
|
||||
|
||||
#### AndroidX Compose BOM
|
||||
```toml
|
||||
[versions]
|
||||
composeBom = "2025.12.01"
|
||||
|
||||
[libraries]
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
```
|
||||
|
||||
**Usage in build.gradle.kts:**
|
||||
```kotlin
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
implementation(libs.androidx.ui) // Version from BOM
|
||||
implementation(libs.androidx.material3) // Version from BOM
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- All AndroidX Compose artifacts use compatible versions
|
||||
- Update single BOM version, not individual libraries
|
||||
- Prevents version conflicts
|
||||
|
||||
### Platform-Specific Variants
|
||||
|
||||
#### secp256k1 (KMP crypto library)
|
||||
```toml
|
||||
secp256k1KmpJniAndroid = "0.22.0"
|
||||
|
||||
[libraries]
|
||||
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
|
||||
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
|
||||
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
|
||||
```
|
||||
|
||||
**Critical:** All three variants MUST share the same version
|
||||
|
||||
#### JNA (for LibSodium)
|
||||
```toml
|
||||
jna = "5.18.1"
|
||||
|
||||
[libraries]
|
||||
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
|
||||
```
|
||||
|
||||
**Usage in build.gradle.kts:**
|
||||
```kotlin
|
||||
// androidMain - AAR packaging
|
||||
implementation("net.java.dev.jna:jna:5.18.1@aar")
|
||||
|
||||
// jvmMain - JAR packaging
|
||||
implementation(libs.jna)
|
||||
```
|
||||
|
||||
**Why:** Android needs AAR, JVM needs JAR (different artifact types)
|
||||
|
||||
## Plugin Declarations
|
||||
|
||||
### Basic Pattern
|
||||
```toml
|
||||
[plugins]
|
||||
plugin-id = { id = "...", version.ref = "..." }
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
#### Kotlin Plugins
|
||||
```toml
|
||||
[versions]
|
||||
kotlin = "2.3.0"
|
||||
|
||||
[plugins]
|
||||
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' }
|
||||
```
|
||||
|
||||
**Critical:** All Kotlin plugins MUST use the same Kotlin version
|
||||
|
||||
#### Android Gradle Plugin
|
||||
```toml
|
||||
[versions]
|
||||
agp = "8.13.2"
|
||||
|
||||
[plugins]
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
||||
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
||||
```
|
||||
|
||||
#### Compose Multiplatform
|
||||
```toml
|
||||
[versions]
|
||||
composeMultiplatform = "1.9.3"
|
||||
|
||||
[plugins]
|
||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||
```
|
||||
|
||||
### Plugin Application
|
||||
|
||||
```kotlin
|
||||
// In build.gradle.kts
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidLibrary)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
}
|
||||
```
|
||||
|
||||
## Usage in Build Files
|
||||
|
||||
### Accessing Versions
|
||||
```kotlin
|
||||
// Direct version access
|
||||
val kotlinVersion = libs.versions.kotlin.get()
|
||||
val minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
```
|
||||
|
||||
### Accessing Libraries
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
api(libs.secp256k1.kmp.common)
|
||||
implementation(libs.okhttp)
|
||||
}
|
||||
```
|
||||
|
||||
### Accessing Plugins
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidLibrary)
|
||||
}
|
||||
```
|
||||
|
||||
## Version Catalog Benefits
|
||||
|
||||
### 1. Centralized Version Management
|
||||
Update once, applies everywhere:
|
||||
```toml
|
||||
# Change one line
|
||||
kotlin = "2.3.0" → "2.4.0"
|
||||
|
||||
# Affects all usages
|
||||
- kotlinMultiplatform plugin
|
||||
- jetbrainsKotlinAndroid plugin
|
||||
- kotlin-stdlib
|
||||
- All Kotlin-related dependencies
|
||||
```
|
||||
|
||||
### 2. Type-Safe Accessors
|
||||
```kotlin
|
||||
// Compile-time checked
|
||||
implementation(libs.okhttp) // ✅ IDE autocomplete
|
||||
|
||||
// vs string-based (error-prone)
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2") // ❌ No autocomplete
|
||||
```
|
||||
|
||||
### 3. Dependency Consistency
|
||||
```kotlin
|
||||
// All modules reference same catalog
|
||||
:quartz → libs.okhttp
|
||||
:commons → libs.okhttp
|
||||
:desktopApp → libs.okhttp
|
||||
|
||||
// Same version everywhere
|
||||
```
|
||||
|
||||
### 4. Gradle Sync Improvements
|
||||
- Faster IDE sync (pre-parsed catalog)
|
||||
- Better dependency resolution
|
||||
- Clearer error messages
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### GitHub Dependencies (Jitpack)
|
||||
```toml
|
||||
[versions]
|
||||
markdown = "f92ef49c9d" # Git commit hash
|
||||
|
||||
[libraries]
|
||||
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
|
||||
```
|
||||
|
||||
**Repository config** (in settings.gradle):
|
||||
```kotlin
|
||||
repositories {
|
||||
maven { url = "https://jitpack.io" }
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Artifact Libraries
|
||||
```toml
|
||||
[versions]
|
||||
media3 = "1.9.0"
|
||||
|
||||
[libraries]
|
||||
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
||||
```
|
||||
|
||||
**Why:** All media3 artifacts share same version for compatibility
|
||||
|
||||
### Test Dependencies
|
||||
```toml
|
||||
[libraries]
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
|
||||
```
|
||||
|
||||
## Version Update Strategy
|
||||
|
||||
### Check for Updates
|
||||
```bash
|
||||
# Using Gradle Versions Plugin (if installed)
|
||||
./gradlew dependencyUpdates
|
||||
|
||||
# Manual check
|
||||
# Browse to Maven Central for specific library
|
||||
```
|
||||
|
||||
### Update Process
|
||||
1. **Update version in catalog**
|
||||
```toml
|
||||
okhttp = "5.3.2" → "5.4.0"
|
||||
```
|
||||
|
||||
2. **Test locally**
|
||||
```bash
|
||||
./gradlew clean build
|
||||
```
|
||||
|
||||
3. **Check for breaking changes**
|
||||
- Review library changelog
|
||||
- Run full test suite
|
||||
|
||||
4. **Commit with clear message**
|
||||
```
|
||||
chore: update okhttp 5.3.2 → 5.4.0
|
||||
```
|
||||
|
||||
### Critical Version Alignments
|
||||
|
||||
#### Kotlin Ecosystem
|
||||
```toml
|
||||
kotlin = "2.3.0"
|
||||
kotlinxCoroutinesCore = "1.10.2"
|
||||
kotlinxSerialization = "1.9.0"
|
||||
```
|
||||
**Rule:** Kotlin version must be compatible with kotlinx libraries
|
||||
|
||||
#### Compose Ecosystem
|
||||
```toml
|
||||
composeMultiplatform = "1.9.3"
|
||||
composeBom = "2025.12.01"
|
||||
kotlin = "2.3.0"
|
||||
```
|
||||
**Rule:** Compose Multiplatform → Kotlin version (see compatibility matrix)
|
||||
|
||||
#### AGP & Gradle
|
||||
```toml
|
||||
agp = "8.13.2"
|
||||
# Requires Gradle 8.9+
|
||||
```
|
||||
**Rule:** AGP version dictates minimum Gradle version
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue 1: Unresolved Reference
|
||||
**Error:** `Unresolved reference: libs`
|
||||
|
||||
**Cause:** Gradle version < 7.0 (version catalogs not supported)
|
||||
|
||||
**Fix:** Upgrade Gradle in `gradle/wrapper/gradle-wrapper.properties`
|
||||
|
||||
### Issue 2: Library Not Found
|
||||
**Error:** `Could not find com.example:library:1.0.0`
|
||||
|
||||
**Cause:** Repository not configured or typo in catalog
|
||||
|
||||
**Fix:**
|
||||
1. Check repository in settings.gradle
|
||||
2. Verify group/name/version in libs.versions.toml
|
||||
|
||||
### Issue 3: Version Conflict
|
||||
**Error:** `Conflict with dependency ... and ...`
|
||||
|
||||
**Cause:** Different versions of same library via transitive dependencies
|
||||
|
||||
**Fix:**
|
||||
```kotlin
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force(libs.okhttp.get().toString())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Naming Conventions
|
||||
```toml
|
||||
# Hyphen-separated, hierarchical
|
||||
androidx-compose-ui
|
||||
androidx-compose-material3
|
||||
kotlinx-coroutines-core
|
||||
|
||||
# Platform suffixes
|
||||
secp256k1-kmp-jni-android
|
||||
secp256k1-kmp-jni-jvm
|
||||
```
|
||||
|
||||
### 2. Group Related Dependencies
|
||||
```toml
|
||||
# Camera APIs together
|
||||
androidx-camera-core
|
||||
androidx-camera-camera2
|
||||
androidx-camera-view
|
||||
```
|
||||
|
||||
### 3. Document Special Cases
|
||||
```toml
|
||||
# JNA requires @aar for Android (see build.gradle.kts)
|
||||
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
|
||||
```
|
||||
|
||||
### 4. Keep BOMs Updated
|
||||
```toml
|
||||
# Update BOM, individual libs follow
|
||||
composeBom = "2025.12.01" # Latest stable
|
||||
```
|
||||
|
||||
### 5. Test Version Updates
|
||||
```bash
|
||||
# Before committing
|
||||
./gradlew :quartz:test
|
||||
./gradlew :commons:test
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
45
.claude/skills/gradle-expert/scripts/analyze-build-time.sh
Normal file
45
.claude/skills/gradle-expert/scripts/analyze-build-time.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Analyze Gradle build performance and generate report
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_ROOT="${1:-.}"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "🔍 Analyzing Gradle build performance..."
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Clean build for accurate timing
|
||||
echo "Running clean build with --profile..."
|
||||
./gradlew clean build --profile --scan
|
||||
|
||||
# Find the latest profile report
|
||||
PROFILE_REPORT=$(find build/reports/profile -name "*.html" -type f -printf '%T@ %p\n' | sort -n | tail -1 | cut -f2- -d" ")
|
||||
|
||||
if [ -n "$PROFILE_REPORT" ]; then
|
||||
echo ""
|
||||
echo "✅ Profile report generated: $PROFILE_REPORT"
|
||||
echo ""
|
||||
echo "📊 Build Performance Summary:"
|
||||
echo "----------------------------"
|
||||
|
||||
# Extract key metrics if available
|
||||
if command -v jq &> /dev/null && [ -f "build/reports/profile/profile.json" ]; then
|
||||
jq -r '.buildTime, .taskExecutionTime' build/reports/profile/profile.json
|
||||
else
|
||||
echo "Open the HTML report for detailed analysis:"
|
||||
echo "file://$PWD/$PROFILE_REPORT"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Profile report not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "💡 Build optimization tips:"
|
||||
echo "- Enable Gradle daemon: org.gradle.daemon=true"
|
||||
echo "- Parallel execution: org.gradle.parallel=true"
|
||||
echo "- Configuration cache: org.gradle.configuration-cache=true"
|
||||
echo "- Build cache: org.gradle.caching=true"
|
||||
echo ""
|
||||
echo "Add these to gradle.properties for faster builds"
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# Diagnose and suggest fixes for common dependency conflicts
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_ROOT="${1:-.}"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "🔍 Analyzing dependency conflicts..."
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Run dependency report
|
||||
echo "Generating dependency insight report..."
|
||||
./gradlew dependencies --configuration runtimeClasspath > /tmp/gradle-dependencies.txt 2>&1 || true
|
||||
|
||||
# Check for common conflict patterns
|
||||
echo ""
|
||||
echo "🔎 Checking for common issues:"
|
||||
echo "------------------------------"
|
||||
|
||||
# Check 1: Compose version conflicts
|
||||
if grep -q "compose" /tmp/gradle-dependencies.txt; then
|
||||
echo "✓ Compose dependencies found"
|
||||
echo " Tip: Ensure Compose Multiplatform and AndroidX Compose versions align"
|
||||
echo " Current project uses:"
|
||||
echo " - Compose Multiplatform BOM"
|
||||
echo " - AndroidX Compose BOM"
|
||||
fi
|
||||
|
||||
# Check 2: secp256k1 variants
|
||||
if grep -q "secp256k1" /tmp/gradle-dependencies.txt; then
|
||||
echo "✓ secp256k1 dependencies found"
|
||||
echo " Ensure correct variant:"
|
||||
echo " - Android: secp256k1-kmp-jni-android"
|
||||
echo " - JVM/Desktop: secp256k1-kmp-jni-jvm"
|
||||
echo " - Common: secp256k1-kmp (transitive)"
|
||||
fi
|
||||
|
||||
# Check 3: Kotlin version alignment
|
||||
KOTLIN_VERSION=$(grep "kotlin =" gradle/libs.versions.toml | cut -d'"' -f2)
|
||||
echo "✓ Kotlin version: $KOTLIN_VERSION"
|
||||
echo " All Kotlin plugins should use the same version"
|
||||
|
||||
# Check 4: Multiple versions of same library
|
||||
echo ""
|
||||
echo "🔍 Checking for version conflicts..."
|
||||
./gradlew dependencyInsight --configuration runtimeClasspath --dependency okhttp || true
|
||||
|
||||
echo ""
|
||||
echo "💡 Common fixes:"
|
||||
echo "---------------"
|
||||
echo "1. Compose conflicts:"
|
||||
echo " - Align compose-multiplatform plugin version with runtime"
|
||||
echo " - Use BOM for AndroidX Compose to enforce consistency"
|
||||
echo ""
|
||||
echo "2. secp256k1 conflicts:"
|
||||
echo " - Use 'api' instead of 'implementation' in source sets"
|
||||
echo " - Ensure androidMain uses jni-android, jvmMain uses jni-jvm"
|
||||
echo ""
|
||||
echo "3. Kotlin version conflicts:"
|
||||
echo " - Update all kotlin plugins to same version in libs.versions.toml"
|
||||
echo " - Check for transitive Kotlin dependencies"
|
||||
echo ""
|
||||
echo "Run './gradlew dependencyInsight --dependency <name>' for specific conflicts"
|
||||
419
.claude/skills/kotlin-coroutines/SKILL.md
Normal file
419
.claude/skills/kotlin-coroutines/SKILL.md
Normal file
@@ -0,0 +1,419 @@
|
||||
---
|
||||
name: kotlin-coroutines
|
||||
description: Advanced Kotlin coroutines patterns for AmethystMultiplatform. Use when working with: (1) Structured concurrency (supervisorScope, coroutineScope), (2) Advanced Flow operators (flatMapLatest, combine, merge, shareIn, stateIn), (3) Channels and callbackFlow, (4) Dispatcher management and context switching, (5) Exception handling (CoroutineExceptionHandler, SupervisorJob), (6) Testing async code (runTest, Turbine), (7) Nostr relay connection pools and subscriptions, (8) Backpressure handling in event streams. Delegates to kotlin-expert for basic StateFlow/SharedFlow patterns. Complements nostr-expert for relay communication.
|
||||
---
|
||||
|
||||
# Kotlin Coroutines - Advanced Async Patterns
|
||||
|
||||
Expert guidance for complex async operations in Amethyst: relay pools, event streams, structured concurrency, and testing.
|
||||
|
||||
## Mental Model
|
||||
|
||||
```
|
||||
Async Architecture in Amethyst:
|
||||
|
||||
Relay Pool (supervisorScope)
|
||||
├── Relay 1 (launch) → callbackFlow → Events
|
||||
├── Relay 2 (launch) → callbackFlow → Events
|
||||
└── Relay 3 (launch) → callbackFlow → Events
|
||||
↓
|
||||
merge() → distinctBy(id) → shareIn
|
||||
↓
|
||||
Multiple Collectors (ViewModels, Services)
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
- **supervisorScope** - Children fail independently
|
||||
- **callbackFlow** - Bridge callbacks to Flow
|
||||
- **shareIn/stateIn** - Hot flows from cold
|
||||
- **Backpressure** - buffer(), conflate(), DROP_OLDEST
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use for **advanced** async patterns:
|
||||
- Multi-relay subscriptions with supervisorScope
|
||||
- Complex Flow operators (flatMapLatest, combine, merge)
|
||||
- callbackFlow for Android callbacks (connectivity, location)
|
||||
- Backpressure handling in high-frequency streams
|
||||
- Exception handling with CoroutineExceptionHandler
|
||||
- Testing coroutines with runTest and Turbine
|
||||
|
||||
**Delegate to kotlin-expert for:**
|
||||
- Basic StateFlow/SharedFlow patterns
|
||||
- Simple viewModelScope.launch
|
||||
- MutableStateFlow → asStateFlow()
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Pattern: callbackFlow for Relay Subscriptions
|
||||
|
||||
```kotlin
|
||||
// Real pattern from NostrClientStaticReqAsStateFlow.kt
|
||||
fun INostrClient.reqAsFlow(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>,
|
||||
): Flow<List<Event>> = callbackFlow {
|
||||
val subId = RandomInstance.randomChars(10)
|
||||
var hasBeenLive = false
|
||||
val eventIds = mutableSetOf<HexKey>()
|
||||
var currentEvents = listOf<Event>()
|
||||
|
||||
val listener = object : IRequestListener {
|
||||
override fun onEvent(event: Event, ...) {
|
||||
if (event.id !in eventIds) {
|
||||
currentEvents = if (hasBeenLive) {
|
||||
// After EOSE: prepend
|
||||
listOf(event) + currentEvents
|
||||
} else {
|
||||
// Before EOSE: append
|
||||
currentEvents + event
|
||||
}
|
||||
eventIds.add(event.id)
|
||||
trySend(currentEvents)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEose(...) {
|
||||
hasBeenLive = true
|
||||
}
|
||||
}
|
||||
|
||||
openReqSubscription(subId, mapOf(relay to filters), listener)
|
||||
|
||||
awaitClose { close(subId) }
|
||||
}
|
||||
```
|
||||
|
||||
**Key techniques:**
|
||||
1. Deduplication with Set
|
||||
2. EOSE handling (append → prepend strategy)
|
||||
3. trySend (non-blocking from callback)
|
||||
4. awaitClose for cleanup
|
||||
|
||||
### Pattern: Structured Concurrency for Relays
|
||||
|
||||
```kotlin
|
||||
suspend fun connectToRelays(relays: List<Relay>) = supervisorScope {
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
try {
|
||||
relay.connect()
|
||||
relay.subscribe(filters).collect { event ->
|
||||
eventChannel.send(event)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e("Relay", "Connection failed: ${relay.url}", e)
|
||||
// Other relays continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why supervisorScope:**
|
||||
- One relay failure doesn't cancel others
|
||||
- All cancelled together when scope cancelled
|
||||
- Proper cleanup guaranteed
|
||||
|
||||
### Pattern: Exception Handling for Services
|
||||
|
||||
```kotlin
|
||||
// Real pattern from PushNotificationReceiverService.kt
|
||||
class MyService : Service() {
|
||||
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Log.e("Service", "Caught: ${throwable.message}", throwable)
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(
|
||||
Dispatchers.IO + SupervisorJob() + exceptionHandler
|
||||
)
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern benefits:**
|
||||
- SupervisorJob: children fail independently
|
||||
- ExceptionHandler: log instead of crash
|
||||
- Scoped lifecycle: cancel all on destroy
|
||||
|
||||
### Pattern: Network Connectivity as Flow
|
||||
|
||||
```kotlin
|
||||
// Real pattern from ConnectivityFlow.kt
|
||||
val status = callbackFlow {
|
||||
val networkCallback = object : NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(ConnectivityStatus.Active(...))
|
||||
}
|
||||
override fun onLost(network: Network) {
|
||||
trySend(ConnectivityStatus.Off)
|
||||
}
|
||||
}
|
||||
|
||||
connectivityManager.registerCallback(networkCallback)
|
||||
|
||||
// Initial state
|
||||
activeNetwork?.let { trySend(ConnectivityStatus.Active(...)) }
|
||||
|
||||
awaitClose {
|
||||
connectivityManager.unregisterCallback(networkCallback)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce(200) // Stabilize flapping
|
||||
.flowOn(Dispatchers.IO)
|
||||
```
|
||||
|
||||
**Key patterns:**
|
||||
1. Emit initial state immediately
|
||||
2. Register callback in flow body
|
||||
3. Cleanup in awaitClose
|
||||
4. Stabilize with debounce + distinctUntilChanged
|
||||
|
||||
### Pattern: Merge Events from Multiple Relays
|
||||
|
||||
```kotlin
|
||||
fun observeFromRelays(
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
filters: List<Filter>
|
||||
): Flow<Event> =
|
||||
relays.map { relay ->
|
||||
client.reqAsFlow(relay, filters)
|
||||
.flatMapConcat { it.asFlow() }
|
||||
}.merge()
|
||||
.distinctBy { it.id }
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
- Each relay: `Flow<List<Event>>`
|
||||
- flatMapConcat: flatten to `Flow<Event>`
|
||||
- merge(): combine all relays
|
||||
- distinctBy: deduplicate across relays
|
||||
|
||||
## Advanced Operators
|
||||
|
||||
For comprehensive coverage of Flow operators:
|
||||
- **flatMapLatest, combine, zip, merge** → See [advanced-flow-operators.md](references/advanced-flow-operators.md)
|
||||
- **shareIn, stateIn** → Conversion to hot flows
|
||||
- **buffer, conflate** → Backpressure strategies
|
||||
- **debounce, sample** → Rate limiting
|
||||
|
||||
### Quick Reference
|
||||
|
||||
| Operator | Use Case | Example |
|
||||
|----------|----------|---------|
|
||||
| **flatMapLatest** | Cancel previous, switch to new | Search (cancel old query) |
|
||||
| **combine** | Latest from ALL flows | combine(account, settings, connectivity) |
|
||||
| **merge** | Single stream from multiple | merge(relay1, relay2, relay3) |
|
||||
| **shareIn** | Multiple collectors, single upstream | Share expensive computation |
|
||||
| **stateIn** | StateFlow from Flow | ViewModel state |
|
||||
| **buffer(DROP_OLDEST)** | High-frequency streams | Real-time event feed |
|
||||
| **conflate** | Latest only | UI updates |
|
||||
| **debounce** | Wait for quiet period | Search input |
|
||||
|
||||
## Nostr Relay Patterns
|
||||
|
||||
For complete relay-specific patterns:
|
||||
→ See [relay-patterns.md](references/relay-patterns.md)
|
||||
|
||||
Covers:
|
||||
- Multi-relay subscription management
|
||||
- Connection lifecycle and reconnection
|
||||
- Event deduplication strategies
|
||||
- Backpressure for high-frequency events
|
||||
- EOSE handling patterns
|
||||
|
||||
## Testing
|
||||
|
||||
For comprehensive testing patterns:
|
||||
→ See [testing-coroutines.md](references/testing-coroutines.md)
|
||||
|
||||
**Quick testing pattern:**
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `relay subscription receives events`() = runTest {
|
||||
val client = FakeNostrClient()
|
||||
|
||||
client.reqAsFlow(relay, filters).test {
|
||||
assertEquals(emptyList(), awaitItem())
|
||||
|
||||
client.sendEvent(event1)
|
||||
assertEquals(listOf(event1), awaitItem())
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Testing tools:**
|
||||
- `runTest` - Virtual time, auto cleanup
|
||||
- Turbine `.test {}` - Flow assertions
|
||||
- `advanceTimeBy()` - Control time
|
||||
- Fake implementations over mocks
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario: Implement New Relay Feature
|
||||
|
||||
**Steps:**
|
||||
1. callbackFlow for subscription
|
||||
2. Deduplication (Set of event IDs)
|
||||
3. awaitClose for cleanup
|
||||
4. Test with FakeNostrClient
|
||||
|
||||
**Example:** Add subscription for specific event kind
|
||||
|
||||
```kotlin
|
||||
fun observeKind(kind: Int): Flow<Event> = callbackFlow {
|
||||
val listener = object : IRequestListener {
|
||||
override fun onEvent(event: Event, ...) {
|
||||
if (event.kind == kind) {
|
||||
trySend(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
client.subscribe(listener)
|
||||
awaitClose { client.unsubscribe(listener) }
|
||||
}
|
||||
```
|
||||
|
||||
### Scenario: Handle Network Connectivity Changes
|
||||
|
||||
**Steps:**
|
||||
1. callbackFlow for connectivity
|
||||
2. flatMapLatest to reconnect
|
||||
3. debounce to stabilize
|
||||
4. Exception handling for failures
|
||||
|
||||
**Example:** Reconnect relays on connectivity
|
||||
|
||||
```kotlin
|
||||
connectivityFlow
|
||||
.flatMapLatest { status ->
|
||||
when (status) {
|
||||
Active -> relayPool.observeEvents()
|
||||
else -> emptyFlow()
|
||||
}
|
||||
}
|
||||
.catch { e -> Log.e("Error", e) }
|
||||
.collect { event -> handleEvent(event) }
|
||||
```
|
||||
|
||||
### Scenario: Optimize Multi-Collector Performance
|
||||
|
||||
**Steps:**
|
||||
1. Use shareIn for expensive upstream
|
||||
2. Configure SharingStarted strategy
|
||||
3. Set replay buffer size
|
||||
4. Test with multiple collectors
|
||||
|
||||
**Example:** Share relay subscription
|
||||
|
||||
```kotlin
|
||||
val events: SharedFlow<Event> = client
|
||||
.reqAsFlow(relay, filters)
|
||||
.flatMapConcat { it.asFlow() }
|
||||
.shareIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
replay = 0
|
||||
)
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
❌ **Using GlobalScope**
|
||||
```kotlin
|
||||
GlobalScope.launch { /* Leaks, no structured concurrency */ }
|
||||
```
|
||||
|
||||
✅ **Use scoped coroutines**
|
||||
```kotlin
|
||||
viewModelScope.launch { /* Cancelled with ViewModel */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **Forgetting awaitClose**
|
||||
```kotlin
|
||||
callbackFlow {
|
||||
registerCallback()
|
||||
// Missing cleanup!
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Always cleanup**
|
||||
```kotlin
|
||||
callbackFlow {
|
||||
registerCallback()
|
||||
awaitClose { unregisterCallback() }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **Blocking in Flow**
|
||||
```kotlin
|
||||
flow.map { Thread.sleep(1000); process(it) }
|
||||
```
|
||||
|
||||
✅ **Suspend, don't block**
|
||||
```kotlin
|
||||
flow.map { delay(1000); process(it) }.flowOn(Dispatchers.Default)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **Ignoring backpressure**
|
||||
```kotlin
|
||||
fastProducer.collect { slowConsumer(it) } // Blocks producer!
|
||||
```
|
||||
|
||||
✅ **Handle backpressure**
|
||||
```kotlin
|
||||
fastProducer
|
||||
.buffer(64, BufferOverflow.DROP_OLDEST)
|
||||
.collect { slowConsumer(it) }
|
||||
```
|
||||
|
||||
## Delegation
|
||||
|
||||
**Use kotlin-expert for:**
|
||||
- Basic StateFlow/SharedFlow patterns
|
||||
- viewModelScope.launch usage
|
||||
- Simple MutableStateFlow → asStateFlow()
|
||||
|
||||
**Use nostr-expert for:**
|
||||
- Nostr protocol details (NIPs, event structure)
|
||||
- Event creation and signing
|
||||
- Cryptographic operations
|
||||
|
||||
**This skill provides:**
|
||||
- Advanced async patterns
|
||||
- Structured concurrency
|
||||
- Complex Flow operators
|
||||
- Testing strategies
|
||||
- Relay-specific async patterns
|
||||
|
||||
## Resources
|
||||
|
||||
- **references/advanced-flow-operators.md** - All Flow operators with examples
|
||||
- **references/relay-patterns.md** - Nostr relay async patterns from codebase
|
||||
- **references/testing-coroutines.md** - Complete testing guide
|
||||
|
||||
## Quick Decision Tree
|
||||
|
||||
```
|
||||
Need async operation?
|
||||
├─ Simple ViewModel state update → kotlin-expert (StateFlow)
|
||||
├─ Android callback → This skill (callbackFlow)
|
||||
├─ Multiple concurrent operations → This skill (supervisorScope)
|
||||
├─ Complex Flow transformation → This skill (references/advanced-flow-operators.md)
|
||||
├─ Relay subscription → This skill (references/relay-patterns.md)
|
||||
└─ Testing async code → This skill (references/testing-coroutines.md)
|
||||
```
|
||||
@@ -0,0 +1,309 @@
|
||||
# Advanced Flow Operators
|
||||
|
||||
Comprehensive guide to Flow operators for complex async patterns in Amethyst.
|
||||
|
||||
## Transformation Operators
|
||||
|
||||
### flatMapLatest - Cancel Previous, Switch to New
|
||||
|
||||
**Use when:** Latest value matters, previous operations should cancel
|
||||
|
||||
```kotlin
|
||||
// User types in search box → cancel previous search
|
||||
searchQuery
|
||||
.flatMapLatest { query ->
|
||||
repository.search(query) // Cancels previous search
|
||||
}
|
||||
.collect { results -> updateUI(results) }
|
||||
```
|
||||
|
||||
**Amethyst pattern:**
|
||||
```kotlin
|
||||
// Switch relays based on latest account
|
||||
accountFlow
|
||||
.flatMapLatest { account ->
|
||||
relayPool.observeEvents(account.relays)
|
||||
}
|
||||
```
|
||||
|
||||
### flatMapConcat - Sequential Processing
|
||||
|
||||
**Use when:** Order matters, process one at a time
|
||||
|
||||
```kotlin
|
||||
eventIds
|
||||
.flatMapConcat { id ->
|
||||
repository.fetchEvent(id)
|
||||
}
|
||||
.collect { event -> process(event) }
|
||||
```
|
||||
|
||||
### flatMapMerge - Concurrent Processing
|
||||
|
||||
**Use when:** Process multiple simultaneously, order doesn't matter
|
||||
|
||||
```kotlin
|
||||
relays
|
||||
.flatMapMerge(concurrency = 10) { relay ->
|
||||
relay.subscribe(filters)
|
||||
}
|
||||
.collect { event -> handleEvent(event) }
|
||||
```
|
||||
|
||||
## Combination Operators
|
||||
|
||||
### combine - Latest from Multiple Flows
|
||||
|
||||
**Use when:** Need latest value from ALL flows
|
||||
|
||||
```kotlin
|
||||
combine(
|
||||
accountFlow,
|
||||
settingsFlow,
|
||||
connectivityFlow
|
||||
) { account, settings, connectivity ->
|
||||
AppState(account, settings, connectivity)
|
||||
}.collect { state -> render(state) }
|
||||
```
|
||||
|
||||
**Pattern:** Re-emits whenever ANY source emits
|
||||
|
||||
### zip - Pair Values in Order
|
||||
|
||||
**Use when:** Need corresponding values from flows
|
||||
|
||||
```kotlin
|
||||
zip(requestFlow, responseFlow) { req, res ->
|
||||
Pair(req, res)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:** Waits for BOTH to emit before pairing
|
||||
|
||||
### merge - Combine Multiple Flows
|
||||
|
||||
**Use when:** Treat multiple flows as single stream
|
||||
|
||||
```kotlin
|
||||
merge(
|
||||
relay1.events,
|
||||
relay2.events,
|
||||
relay3.events
|
||||
).collect { event -> handleEvent(event) }
|
||||
```
|
||||
|
||||
## Backpressure & Buffering
|
||||
|
||||
### shareIn - Hot Flow from Cold
|
||||
|
||||
**Use when:** Multiple collectors should share single upstream
|
||||
|
||||
```kotlin
|
||||
val sharedEvents = repository.observeEvents()
|
||||
.shareIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
replay = 0
|
||||
)
|
||||
|
||||
// Multiple collectors share same upstream
|
||||
sharedEvents.collect { /* collector 1 */ }
|
||||
sharedEvents.collect { /* collector 2 */ }
|
||||
```
|
||||
|
||||
**SharingStarted strategies:**
|
||||
- `Eagerly` - Start immediately, never stop
|
||||
- `Lazily` - Start on first subscriber, never stop
|
||||
- `WhileSubscribed(stopTimeout)` - Stop after last unsubscribe + timeout
|
||||
|
||||
### stateIn - StateFlow from Cold Flow
|
||||
|
||||
**Use when:** Convert Flow to StateFlow (always has value)
|
||||
|
||||
```kotlin
|
||||
val uiState: StateFlow<UiState> = repository.observeData()
|
||||
.map { data -> UiState.Success(data) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = UiState.Loading
|
||||
)
|
||||
```
|
||||
|
||||
**Amethyst pattern:**
|
||||
```kotlin
|
||||
// Connectivity status as StateFlow
|
||||
val connectivity: StateFlow<ConnectivityStatus> =
|
||||
connectivityFlow.status
|
||||
.stateIn(
|
||||
scope = serviceScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = ConnectivityStatus.Off
|
||||
)
|
||||
```
|
||||
|
||||
### buffer - Control Backpressure
|
||||
|
||||
**Use when:** Producer faster than consumer
|
||||
|
||||
```kotlin
|
||||
eventFlow
|
||||
.buffer(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
.collect { event -> slowProcessor(event) }
|
||||
```
|
||||
|
||||
**Strategies:**
|
||||
- `SUSPEND` - Slow down producer (default)
|
||||
- `DROP_OLDEST` - Drop oldest in buffer
|
||||
- `DROP_LATEST` - Drop newest emission
|
||||
|
||||
### conflate - Keep Only Latest
|
||||
|
||||
**Use when:** Only latest value matters, skip intermediate
|
||||
|
||||
```kotlin
|
||||
locationFlow
|
||||
.conflate() // Skip intermediate locations
|
||||
.collect { location -> updateMap(location) }
|
||||
```
|
||||
|
||||
## Debouncing & Throttling
|
||||
|
||||
### debounce - Wait for Quiet Period
|
||||
|
||||
**Use when:** Wait for user to stop typing
|
||||
|
||||
```kotlin
|
||||
searchQuery
|
||||
.debounce(300) // Wait 300ms after last emission
|
||||
.flatMapLatest { query -> search(query) }
|
||||
```
|
||||
|
||||
**Amethyst pattern:**
|
||||
```kotlin
|
||||
// ConnectivityFlow.kt:87
|
||||
connectivityFlow
|
||||
.distinctUntilChanged()
|
||||
.debounce(200) // Wait 200ms for network to stabilize
|
||||
.flowOn(Dispatchers.IO)
|
||||
```
|
||||
|
||||
### sample - Periodic Sampling
|
||||
|
||||
**Use when:** Rate-limit high-frequency emissions
|
||||
|
||||
```kotlin
|
||||
sensorData
|
||||
.sample(1000) // Sample every 1 second
|
||||
.collect { data -> process(data) }
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### catch - Handle Upstream Errors
|
||||
|
||||
**Use when:** Graceful degradation needed
|
||||
|
||||
```kotlin
|
||||
repository.fetchData()
|
||||
.catch { e ->
|
||||
Log.e("Error", e)
|
||||
emit(emptyList()) // Fallback value
|
||||
}
|
||||
.collect { data -> updateUI(data) }
|
||||
```
|
||||
|
||||
**Pattern:** Only catches UPSTREAM errors, not in collect block
|
||||
|
||||
### retry/retryWhen - Automatic Retry
|
||||
|
||||
```kotlin
|
||||
relayConnection
|
||||
.retry(3) { cause ->
|
||||
cause is IOException // Only retry on network errors
|
||||
}
|
||||
```
|
||||
|
||||
## Context Switching
|
||||
|
||||
### flowOn - Change Upstream Dispatcher
|
||||
|
||||
**Use when:** Offload work from current context
|
||||
|
||||
```kotlin
|
||||
repository.fetchData()
|
||||
.map { heavyProcessing(it) }
|
||||
.flowOn(Dispatchers.Default) // Heavy work on Default
|
||||
.collect { updateUI(it) } // Collect on Main
|
||||
```
|
||||
|
||||
**Critical:** Only affects UPSTREAM operators
|
||||
|
||||
**Amethyst pattern:**
|
||||
```kotlin
|
||||
// ConnectivityFlow.kt:87
|
||||
callbackFlow { /* ... */ }
|
||||
.distinctUntilChanged()
|
||||
.debounce(200)
|
||||
.flowOn(Dispatchers.IO) // All upstream on IO
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Multi-Relay Subscription
|
||||
|
||||
```kotlin
|
||||
fun observeFromMultipleRelays(relays: List<Relay>, filters: List<Filter>): Flow<Event> =
|
||||
relays.map { relay ->
|
||||
relay.subscribe(filters)
|
||||
}.merge()
|
||||
.distinctBy { it.id }
|
||||
```
|
||||
|
||||
### Pattern: Load + Cache + Observe
|
||||
|
||||
```kotlin
|
||||
fun observeWithCache(id: String): Flow<Data> = flow {
|
||||
// Emit cached value immediately
|
||||
cache[id]?.let { emit(it) }
|
||||
|
||||
// Then observe updates
|
||||
emitAll(repository.observe(id))
|
||||
}.distinctUntilChanged()
|
||||
```
|
||||
|
||||
### Pattern: Retry with Exponential Backoff
|
||||
|
||||
```kotlin
|
||||
fun <T> Flow<T>.retryWithBackoff(
|
||||
maxRetries: Int = 3,
|
||||
initialDelay: Long = 1000
|
||||
): Flow<T> = retryWhen { cause, attempt ->
|
||||
if (attempt >= maxRetries || cause !is IOException) {
|
||||
false
|
||||
} else {
|
||||
delay(initialDelay * (1L shl attempt.toInt()))
|
||||
true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use shareIn for expensive operations**
|
||||
- Compute once, share with multiple collectors
|
||||
|
||||
2. **Choose right backpressure strategy**
|
||||
- UI updates: `conflate()` or `DROP_OLDEST`
|
||||
- Events: `buffer()` with appropriate size
|
||||
|
||||
3. **flowOn placement matters**
|
||||
- Place after expensive operators to offload them
|
||||
|
||||
4. **Avoid unnecessary emissions**
|
||||
- Use `distinctUntilChanged()` when appropriate
|
||||
- Consider `debounce()` for high-frequency sources
|
||||
|
||||
5. **StateFlow vs SharedFlow**
|
||||
- StateFlow: Always has value, conflates
|
||||
- SharedFlow: Optional replay, configurable buffering
|
||||
480
.claude/skills/kotlin-coroutines/references/relay-patterns.md
Normal file
480
.claude/skills/kotlin-coroutines/references/relay-patterns.md
Normal file
@@ -0,0 +1,480 @@
|
||||
# Nostr Relay Async Patterns
|
||||
|
||||
Proven coroutine patterns for Nostr relay connections, subscriptions, and event streaming in Amethyst.
|
||||
|
||||
## Core Pattern: callbackFlow for Relay Subscriptions
|
||||
|
||||
### Pattern: Subscription as Flow
|
||||
|
||||
**Real implementation from NostrClientStaticReqAsStateFlow.kt:**
|
||||
|
||||
```kotlin
|
||||
fun INostrClient.reqAsFlow(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>,
|
||||
): Flow<List<Event>> =
|
||||
callbackFlow {
|
||||
val subId = RandomInstance.randomChars(10)
|
||||
var hasBeenLive = false
|
||||
val eventIds = mutableSetOf<HexKey>()
|
||||
var currentEvents = listOf<Event>()
|
||||
|
||||
val listener = object : IRequestListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (event.id !in eventIds) {
|
||||
if (hasBeenLive) {
|
||||
// After EOSE: prepend new events
|
||||
val list = ArrayList<Event>(1 + currentEvents.size)
|
||||
list.add(event)
|
||||
list.addAll(currentEvents)
|
||||
currentEvents = list
|
||||
} else {
|
||||
// Before EOSE: append events
|
||||
currentEvents = currentEvents + event
|
||||
}
|
||||
eventIds.add(event.id)
|
||||
trySend(currentEvents)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
hasBeenLive = true
|
||||
}
|
||||
}
|
||||
|
||||
openReqSubscription(subId, mapOf(relay to filters), listener)
|
||||
|
||||
awaitClose {
|
||||
close(subId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key techniques:**
|
||||
1. **callbackFlow** - Bridge callback API to Flow
|
||||
2. **Deduplication** - `eventIds` set prevents duplicates
|
||||
3. **EOSE handling** - Changes insertion strategy (append → prepend)
|
||||
4. **awaitClose** - Cleanup when flow cancelled
|
||||
5. **trySend** - Non-blocking emission from callback
|
||||
|
||||
## Multi-Relay Patterns
|
||||
|
||||
### Pattern: Merge Events from Multiple Relays
|
||||
|
||||
```kotlin
|
||||
fun observeFromRelays(
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
filters: List<Filter>
|
||||
): Flow<Event> =
|
||||
relays.map { relay ->
|
||||
client.reqAsFlow(relay, filters)
|
||||
.flatMapConcat { it.asFlow() }
|
||||
}.merge()
|
||||
.distinctBy { it.id }
|
||||
```
|
||||
|
||||
**Explanation:**
|
||||
- Each relay produces `Flow<List<Event>>`
|
||||
- `flatMapConcat` flattens to `Flow<Event>`
|
||||
- `merge()` combines all relay flows
|
||||
- `distinctBy` deduplicates across relays
|
||||
|
||||
### Pattern: Concurrent Relay Operations with supervisorScope
|
||||
|
||||
```kotlin
|
||||
suspend fun subscribeToRelays(
|
||||
relays: List<Relay>,
|
||||
filters: List<Filter>
|
||||
) = supervisorScope {
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
relay.subscribe(filters).collect { event ->
|
||||
eventChannel.send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why supervisorScope:**
|
||||
- If one relay fails, others continue
|
||||
- All children cancelled when scope cancelled
|
||||
- Structured concurrency maintained
|
||||
|
||||
## Backpressure Handling
|
||||
|
||||
### Pattern: Buffer with Drop Strategy
|
||||
|
||||
**For high-frequency event streams:**
|
||||
|
||||
```kotlin
|
||||
relayFlow
|
||||
.buffer(
|
||||
capacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
.collect { event -> processEvent(event) }
|
||||
```
|
||||
|
||||
**Strategy selection:**
|
||||
- `DROP_OLDEST` - For real-time feeds (lose old events OK)
|
||||
- `DROP_LATEST` - For priority queues (lose new events OK)
|
||||
- `SUSPEND` - For critical events (slow down producer)
|
||||
|
||||
### Pattern: Conflate for UI Updates
|
||||
|
||||
```kotlin
|
||||
val uiEvents: Flow<UiEvent> = relayEvents
|
||||
.map { event -> toUiEvent(event) }
|
||||
.conflate() // Skip intermediate, show latest
|
||||
.flowOn(Dispatchers.Default)
|
||||
```
|
||||
|
||||
## Connection Management
|
||||
|
||||
### Pattern: Network Connectivity as Flow
|
||||
|
||||
**Real implementation from ConnectivityFlow.kt:**
|
||||
|
||||
```kotlin
|
||||
@OptIn(FlowPreview::class)
|
||||
val status = callbackFlow {
|
||||
trySend(ConnectivityStatus.StartingService)
|
||||
|
||||
val connectivityManager = context.getConnectivityManager()
|
||||
|
||||
val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
connectivityManager.getNetworkCapabilities(network)?.let {
|
||||
trySend(ConnectivityStatus.Active(
|
||||
network.networkHandle,
|
||||
it.isMeteredOrMobileData()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
val isMobile = networkCapabilities.isMeteredOrMobileData()
|
||||
trySend(ConnectivityStatus.Active(
|
||||
network.networkHandle,
|
||||
isMobile
|
||||
))
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(ConnectivityStatus.Off)
|
||||
}
|
||||
}
|
||||
|
||||
connectivityManager.registerDefaultNetworkCallback(networkCallback)
|
||||
|
||||
// Send initial state
|
||||
connectivityManager.activeNetwork?.let { network ->
|
||||
connectivityManager.getNetworkCapabilities(network)?.let {
|
||||
trySend(ConnectivityStatus.Active(
|
||||
network.networkHandle,
|
||||
it.isMeteredOrMobileData()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
trySend(ConnectivityStatus.Off)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce(200) // Stabilize rapid changes
|
||||
.flowOn(Dispatchers.IO)
|
||||
```
|
||||
|
||||
**Key patterns:**
|
||||
1. **Initial state** - Emit current connectivity immediately
|
||||
2. **Callback registration** - Register listener in flow body
|
||||
3. **Cleanup** - Unregister in `awaitClose`
|
||||
4. **Stabilization** - `debounce(200)` prevents flapping
|
||||
5. **Deduplication** - `distinctUntilChanged()` skips redundant updates
|
||||
|
||||
### Pattern: Reconnect on Connectivity Change
|
||||
|
||||
```kotlin
|
||||
connectivityFlow
|
||||
.flatMapLatest { status ->
|
||||
when (status) {
|
||||
is ConnectivityStatus.Active -> {
|
||||
relayPool.connectAll()
|
||||
relayPool.observeEvents()
|
||||
}
|
||||
else -> emptyFlow()
|
||||
}
|
||||
}
|
||||
.collect { event -> handleEvent(event) }
|
||||
```
|
||||
|
||||
## Exception Handling in Async Operations
|
||||
|
||||
### Pattern: CoroutineExceptionHandler + SupervisorJob
|
||||
|
||||
**Real implementation from PushNotificationReceiverService.kt:**
|
||||
|
||||
```kotlin
|
||||
class PushNotificationReceiverService : FirebaseMessagingService() {
|
||||
// Catch all uncaught exceptions
|
||||
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
|
||||
}
|
||||
|
||||
// Children fail independently, handler catches all
|
||||
private val scope = CoroutineScope(
|
||||
Dispatchers.IO + SupervisorJob() + exceptionHandler
|
||||
)
|
||||
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
parseMessage(remoteMessage.data)?.let { receiveIfNew(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why this pattern:**
|
||||
- **SupervisorJob** - One failure doesn't cancel others
|
||||
- **ExceptionHandler** - Log exceptions, don't crash
|
||||
- **Scoped lifecycle** - Cancel all on destroy
|
||||
|
||||
### Pattern: Retry with Backoff for Relay Connections
|
||||
|
||||
```kotlin
|
||||
fun connectWithRetry(relay: Relay): Flow<ConnectionStatus> = flow {
|
||||
var attempt = 0
|
||||
val maxRetries = 5
|
||||
val baseDelay = 1000L
|
||||
|
||||
while (attempt < maxRetries) {
|
||||
try {
|
||||
emit(ConnectionStatus.Connecting)
|
||||
relay.connect()
|
||||
emit(ConnectionStatus.Connected)
|
||||
return@flow
|
||||
} catch (e: Exception) {
|
||||
attempt++
|
||||
emit(ConnectionStatus.Error(e, attempt))
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
val delay = baseDelay * (1L shl attempt) // Exponential backoff
|
||||
delay(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
emit(ConnectionStatus.Failed)
|
||||
}
|
||||
```
|
||||
|
||||
## Subscription Lifecycle
|
||||
|
||||
### Pattern: Auto-Cleanup Subscription
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ObserveRelayEvents(
|
||||
filters: List<Filter>,
|
||||
onEvent: (Event) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DisposableEffect(filters) {
|
||||
val job = scope.launch {
|
||||
relayClient.reqAsFlow(filters).collect { events ->
|
||||
events.forEach { onEvent(it) }
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
job.cancel() // Cancels flow, triggers awaitClose
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lifecycle:**
|
||||
1. Composable enters → subscribe
|
||||
2. filters change → cancel + re-subscribe
|
||||
3. Composable leaves → cancel + cleanup
|
||||
|
||||
### Pattern: Multiple Concurrent Subscriptions
|
||||
|
||||
```kotlin
|
||||
fun observeMultipleFeeds(
|
||||
account: Account
|
||||
): Flow<Event> = channelFlow {
|
||||
supervisorScope {
|
||||
// Home feed
|
||||
launch {
|
||||
client.reqAsFlow(filters = homeFeedFilters)
|
||||
.collect { events -> events.forEach { send(it) } }
|
||||
}
|
||||
|
||||
// Notifications
|
||||
launch {
|
||||
client.reqAsFlow(filters = notificationFilters)
|
||||
.collect { events -> events.forEach { send(it) } }
|
||||
}
|
||||
|
||||
// DMs
|
||||
launch {
|
||||
client.reqAsFlow(filters = dmFilters)
|
||||
.collect { events -> events.forEach { send(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- All subscriptions run concurrently
|
||||
- One failure doesn't affect others (supervisorScope)
|
||||
- Single output channel for all events
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Pattern: Shared Upstream for Multiple Collectors
|
||||
|
||||
```kotlin
|
||||
class RelayViewModel(private val client: INostrClient) : ViewModel() {
|
||||
val events: SharedFlow<Event> = client
|
||||
.reqAsFlow(relay, filters)
|
||||
.flatMapConcat { it.asFlow() }
|
||||
.shareIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
replay = 0
|
||||
)
|
||||
}
|
||||
|
||||
// Multiple collectors share single relay subscription
|
||||
events.collect { /* UI 1 */ }
|
||||
events.collect { /* UI 2 */ }
|
||||
```
|
||||
|
||||
### Pattern: Event Deduplication Cache
|
||||
|
||||
```kotlin
|
||||
class EventCache {
|
||||
private val seen = mutableSetOf<HexKey>()
|
||||
|
||||
fun filterNew(events: List<Event>): List<Event> =
|
||||
events.filter { event ->
|
||||
if (event.id in seen) {
|
||||
false
|
||||
} else {
|
||||
seen.add(event.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deduplicatedEvents = relayEvents
|
||||
.map { events -> cache.filterNew(events) }
|
||||
.filter { it.isNotEmpty() }
|
||||
```
|
||||
|
||||
## Testing Relay Flows
|
||||
|
||||
### Pattern: Test with Fake Relay
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `subscription receives events`() = runTest {
|
||||
val fakeRelay = FakeRelay()
|
||||
val client = NostrClient(fakeRelay)
|
||||
|
||||
val events = mutableListOf<Event>()
|
||||
val job = launch {
|
||||
client.reqAsFlow(relay, filters).collect { list ->
|
||||
events.addAll(list)
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate relay responses
|
||||
fakeRelay.sendEvent(testEvent1)
|
||||
advanceTimeBy(100)
|
||||
fakeRelay.sendEvent(testEvent2)
|
||||
advanceTimeBy(100)
|
||||
|
||||
assertEquals(2, events.size)
|
||||
job.cancel()
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### ❌ Forgetting awaitClose
|
||||
|
||||
```kotlin
|
||||
// BAD: Subscription never cleaned up
|
||||
callbackFlow {
|
||||
relay.subscribe(listener)
|
||||
// Missing awaitClose!
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// GOOD: Proper cleanup
|
||||
callbackFlow {
|
||||
relay.subscribe(listener)
|
||||
awaitClose {
|
||||
relay.unsubscribe(listener)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Using GlobalScope
|
||||
|
||||
```kotlin
|
||||
// BAD: Unstructured, leaks
|
||||
GlobalScope.launch {
|
||||
relay.connect()
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// GOOD: Scoped to lifecycle
|
||||
viewModelScope.launch {
|
||||
relay.connect()
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Blocking in Flow Operators
|
||||
|
||||
```kotlin
|
||||
// BAD: Blocks collector
|
||||
flow.map { event ->
|
||||
Thread.sleep(1000) // Blocks!
|
||||
process(event)
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// GOOD: Use flowOn to offload
|
||||
flow
|
||||
.map { event ->
|
||||
delay(1000) // Suspends, doesn't block
|
||||
process(event)
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
```
|
||||
@@ -0,0 +1,493 @@
|
||||
# Testing Coroutines
|
||||
|
||||
Comprehensive guide for testing async code with runTest, Turbine, and best practices.
|
||||
|
||||
## runTest - Standard Testing
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test suspend function`() = runTest {
|
||||
val result = repository.fetchData()
|
||||
assertEquals(expected, result)
|
||||
}
|
||||
```
|
||||
|
||||
**What runTest does:**
|
||||
- Skips delays automatically
|
||||
- Provides TestScope
|
||||
- Advances virtual time
|
||||
- Waits for all coroutines to complete
|
||||
|
||||
### Testing StateFlow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `stateflow updates correctly`() = runTest {
|
||||
val viewModel = MyViewModel()
|
||||
|
||||
// Initial state
|
||||
assertEquals(UiState.Loading, viewModel.state.value)
|
||||
|
||||
// Trigger action
|
||||
viewModel.loadData()
|
||||
advanceUntilIdle() // Run all pending coroutines
|
||||
|
||||
// Verify final state
|
||||
assertEquals(UiState.Success(data), viewModel.state.value)
|
||||
}
|
||||
```
|
||||
|
||||
### Testing with Time Control
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `debounce works correctly`() = runTest {
|
||||
val viewModel = SearchViewModel()
|
||||
|
||||
viewModel.search("a")
|
||||
advanceTimeBy(100) // 100ms passed
|
||||
|
||||
viewModel.search("ab")
|
||||
advanceTimeBy(100)
|
||||
|
||||
viewModel.search("abc")
|
||||
advanceTimeBy(300) // Debounce completes
|
||||
|
||||
// Only "abc" should have triggered search
|
||||
assertEquals(listOf("abc"), viewModel.searchQueries)
|
||||
}
|
||||
```
|
||||
|
||||
**Time control functions:**
|
||||
- `advanceTimeBy(millis)` - Move virtual time forward
|
||||
- `advanceUntilIdle()` - Run all pending work
|
||||
- `runCurrent()` - Run currently scheduled tasks only
|
||||
|
||||
## Turbine - Flow Testing Library
|
||||
|
||||
### Basic Collection Testing
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `flow emits expected values`() = runTest {
|
||||
repository.observeData().test {
|
||||
assertEquals(Item1, awaitItem())
|
||||
assertEquals(Item2, awaitItem())
|
||||
assertEquals(Item3, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Flow Transformations
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `map transforms correctly`() = runTest {
|
||||
val source = flowOf(1, 2, 3)
|
||||
|
||||
source
|
||||
.map { it * 2 }
|
||||
.test {
|
||||
assertEquals(2, awaitItem())
|
||||
assertEquals(4, awaitItem())
|
||||
assertEquals(6, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Relay Subscriptions
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `relay subscription receives events`() = runTest {
|
||||
val fakeClient = FakeNostrClient()
|
||||
|
||||
fakeClient.reqAsFlow(relay, filters).test {
|
||||
// Initially empty
|
||||
assertEquals(emptyList(), awaitItem())
|
||||
|
||||
// Send event
|
||||
fakeClient.sendEvent(event1)
|
||||
assertEquals(listOf(event1), awaitItem())
|
||||
|
||||
// Send another
|
||||
fakeClient.sendEvent(event2)
|
||||
assertEquals(listOf(event1, event2), awaitItem())
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Error Handling
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `catch handles errors gracefully`() = runTest {
|
||||
val errorFlow = flow {
|
||||
emit(1)
|
||||
throw IOException("Network error")
|
||||
}.catch { emit(-1) } // Fallback value
|
||||
|
||||
errorFlow.test {
|
||||
assertEquals(1, awaitItem())
|
||||
assertEquals(-1, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing StateFlow with Turbine
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `stateflow emits updates`() = runTest {
|
||||
val viewModel = MyViewModel()
|
||||
|
||||
viewModel.state.test {
|
||||
// Skip initial value
|
||||
assertEquals(UiState.Loading, awaitItem())
|
||||
|
||||
// Trigger update
|
||||
viewModel.loadData()
|
||||
assertEquals(UiState.Success(data), awaitItem())
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Turbine assertions:**
|
||||
- `awaitItem()` - Get next emission or fail
|
||||
- `awaitComplete()` - Verify flow completed
|
||||
- `awaitError()` - Verify flow threw exception
|
||||
- `expectNoEvents()` - Assert no emissions in timeframe
|
||||
- `cancelAndIgnoreRemainingEvents()` - Stop test
|
||||
|
||||
## Testing Patterns for Amethyst
|
||||
|
||||
### Pattern: Test Relay Connection Flow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `reconnects on connectivity change`() = runTest {
|
||||
val connectivityFlow = MutableStateFlow(ConnectivityStatus.Off)
|
||||
val relayPool = FakeRelayPool()
|
||||
|
||||
connectivityFlow
|
||||
.flatMapLatest { status ->
|
||||
when (status) {
|
||||
is ConnectivityStatus.Active -> relayPool.connectAll()
|
||||
else -> emptyFlow()
|
||||
}
|
||||
}
|
||||
.test {
|
||||
// Initially offline
|
||||
expectNoEvents()
|
||||
|
||||
// Go online
|
||||
connectivityFlow.value = ConnectivityStatus.Active(1L, false)
|
||||
assertTrue(relayPool.connected)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Test Event Deduplication
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `deduplicates events across relays`() = runTest {
|
||||
val relay1 = FakeRelay()
|
||||
val relay2 = FakeRelay()
|
||||
|
||||
merge(relay1.events, relay2.events)
|
||||
.distinctBy { it.id }
|
||||
.test {
|
||||
// Both relays send same event
|
||||
relay1.send(event1)
|
||||
relay2.send(event1)
|
||||
|
||||
// Only one emission
|
||||
assertEquals(event1, awaitItem())
|
||||
expectNoEvents()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Test Backpressure Handling
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `drops oldest events when buffer full`() = runTest {
|
||||
val fastProducer = flow {
|
||||
repeat(100) { emit(it) }
|
||||
}
|
||||
|
||||
fastProducer
|
||||
.buffer(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
.test {
|
||||
// Slow consumer
|
||||
delay(100)
|
||||
|
||||
// Should have dropped oldest, kept newest
|
||||
val items = mutableListOf<Int>()
|
||||
repeat(10) {
|
||||
items.add(awaitItem())
|
||||
}
|
||||
|
||||
// Newest items present
|
||||
assertTrue(90 in items)
|
||||
assertTrue(99 in items)
|
||||
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Test Concurrent Subscriptions
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `multiple subscriptions run concurrently`() = runTest {
|
||||
val client = FakeNostrClient()
|
||||
|
||||
val feed1 = async { client.reqAsFlow(relay1, filters1).first() }
|
||||
val feed2 = async { client.reqAsFlow(relay2, filters2).first() }
|
||||
|
||||
client.sendTo(relay1, event1)
|
||||
client.sendTo(relay2, event2)
|
||||
|
||||
assertEquals(listOf(event1), feed1.await())
|
||||
assertEquals(listOf(event2), feed2.await())
|
||||
}
|
||||
```
|
||||
|
||||
## Fakes and Mocks
|
||||
|
||||
### Fake NostrClient
|
||||
|
||||
```kotlin
|
||||
class FakeNostrClient : INostrClient {
|
||||
private val subscriptions = mutableMapOf<String, MutableSharedFlow<Event>>()
|
||||
|
||||
override fun reqAsFlow(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>
|
||||
): Flow<List<Event>> = callbackFlow {
|
||||
val subId = RandomInstance.randomChars(10)
|
||||
val flow = MutableSharedFlow<Event>()
|
||||
subscriptions[subId] = flow
|
||||
|
||||
val events = mutableListOf<Event>()
|
||||
flow.collect { event ->
|
||||
events.add(event)
|
||||
send(events.toList())
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
subscriptions.remove(subId)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendEvent(event: Event) {
|
||||
subscriptions.values.forEach { it.tryEmit(event) }
|
||||
}
|
||||
|
||||
fun sendTo(relay: NormalizedRelayUrl, event: Event) {
|
||||
subscriptions[relay.url]?.tryEmit(event)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fake Relay Pool
|
||||
|
||||
```kotlin
|
||||
class FakeRelayPool {
|
||||
var connected = false
|
||||
private val _events = MutableSharedFlow<Event>()
|
||||
val events: SharedFlow<Event> = _events.asSharedFlow()
|
||||
|
||||
fun connectAll(): Flow<Unit> = flow {
|
||||
connected = true
|
||||
emit(Unit)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
connected = false
|
||||
}
|
||||
|
||||
suspend fun sendEvent(event: Event) {
|
||||
_events.emit(event)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Exception Handling
|
||||
|
||||
### Test CoroutineExceptionHandler
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `exception handler catches errors`() = runTest {
|
||||
val errors = mutableListOf<Throwable>()
|
||||
|
||||
val handler = CoroutineExceptionHandler { _, throwable ->
|
||||
errors.add(throwable)
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(
|
||||
Dispatchers.Unconfined + SupervisorJob() + handler
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
throw IOException("Test error")
|
||||
}
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, errors.size)
|
||||
assertTrue(errors[0] is IOException)
|
||||
}
|
||||
```
|
||||
|
||||
### Test Retry Logic
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `retries failed connections`() = runTest {
|
||||
var attempts = 0
|
||||
val maxRetries = 3
|
||||
|
||||
flow {
|
||||
attempts++
|
||||
if (attempts < maxRetries) {
|
||||
throw IOException("Connection failed")
|
||||
}
|
||||
emit("Success")
|
||||
}
|
||||
.retry(maxRetries)
|
||||
.test {
|
||||
assertEquals("Success", awaitItem())
|
||||
awaitComplete()
|
||||
assertEquals(3, attempts)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Testing Patterns
|
||||
|
||||
### Pattern: Verify No Emissions After Cancellation
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `no emissions after cancellation`() = runTest {
|
||||
val flow = flow {
|
||||
emit(1)
|
||||
delay(1000)
|
||||
emit(2) // Should not emit
|
||||
}
|
||||
|
||||
flow.test {
|
||||
assertEquals(1, awaitItem())
|
||||
cancel()
|
||||
|
||||
// Verify no more emissions
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Test Time-Based Operations
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `periodic emission works`() = runTest {
|
||||
flow {
|
||||
repeat(3) {
|
||||
emit(it)
|
||||
delay(1000)
|
||||
}
|
||||
}.test {
|
||||
assertEquals(0, awaitItem())
|
||||
|
||||
advanceTimeBy(1000)
|
||||
assertEquals(1, awaitItem())
|
||||
|
||||
advanceTimeBy(1000)
|
||||
assertEquals(2, awaitItem())
|
||||
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Test Hot Flow Conversion
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `shareIn creates hot flow`() = runTest {
|
||||
var emissions = 0
|
||||
val source = flow {
|
||||
repeat(3) {
|
||||
emissions++
|
||||
emit(it)
|
||||
}
|
||||
}
|
||||
|
||||
val shared = source.shareIn(
|
||||
scope = this,
|
||||
started = SharingStarted.Eagerly,
|
||||
replay = 1
|
||||
)
|
||||
|
||||
// First collector
|
||||
shared.take(2).collect()
|
||||
assertEquals(2, emissions) // Emitted 0, 1
|
||||
|
||||
// Second collector - shares upstream
|
||||
shared.take(1).collect()
|
||||
assertEquals(3, emissions) // Only emitted 2, not restarted
|
||||
|
||||
cancel()
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use runTest for all coroutine tests**
|
||||
- Provides virtual time
|
||||
- Automatic cleanup
|
||||
|
||||
2. **Use Turbine for Flow testing**
|
||||
- Clearer assertions
|
||||
- Better error messages
|
||||
|
||||
3. **Test both success and error paths**
|
||||
- Normal flow
|
||||
- Exception handling
|
||||
- Edge cases
|
||||
|
||||
4. **Control virtual time explicitly**
|
||||
- Don't rely on real delays
|
||||
- Use `advanceTimeBy()` and `advanceUntilIdle()`
|
||||
|
||||
5. **Create fakes, not mocks**
|
||||
- Simpler to maintain
|
||||
- More realistic behavior
|
||||
- Easier to debug
|
||||
|
||||
6. **Test cancellation behavior**
|
||||
- Verify cleanup happens
|
||||
- Check no emissions after cancel
|
||||
|
||||
7. **Test concurrent operations**
|
||||
- Use `async` to spawn concurrent work
|
||||
- Verify independence with SupervisorJob
|
||||
811
.claude/skills/kotlin-expert/SKILL.md
Normal file
811
.claude/skills/kotlin-expert/SKILL.md
Normal file
@@ -0,0 +1,811 @@
|
||||
---
|
||||
name: kotlin-expert
|
||||
description: Advanced Kotlin patterns for AmethystMultiplatform. Flow state management (StateFlow/SharedFlow), sealed hierarchies (classes vs interfaces), immutability (@Immutable, data classes), DSL builders (type-safe fluent APIs), inline functions (reified generics, performance). Use when working with: (1) State management patterns (StateFlow/SharedFlow/MutableStateFlow), (2) Sealed classes or sealed interfaces, (3) @Immutable annotations for Compose, (4) DSL builders with lambda receivers, (5) inline/reified functions, (6) Kotlin performance optimization. Complements kotlin-coroutines agent (async patterns) - this skill focuses on Amethyst-specific Kotlin idioms.
|
||||
---
|
||||
|
||||
# Kotlin Expert
|
||||
|
||||
Advanced Kotlin patterns for AmethystMultiplatform. Covers Flow state management, sealed hierarchies, immutability, DSL builders, and inline functions with real codebase examples.
|
||||
|
||||
## Mental Model
|
||||
|
||||
**Kotlin in Amethyst:**
|
||||
|
||||
```
|
||||
State Management (Hot Flows)
|
||||
├── StateFlow<T> # Single value, always has value, replays to new subscribers
|
||||
├── SharedFlow<T> # Event stream, configurable replay, multiple subscribers
|
||||
└── MutableStateFlow<T> # Private mutable, public via .asStateFlow()
|
||||
|
||||
Type Safety (Sealed Hierarchies)
|
||||
├── sealed class # State variants with data (AccountState.LoggedIn/LoggedOut)
|
||||
└── sealed interface # Generic result types (SignerResult<T>)
|
||||
|
||||
Compose Performance (@Immutable)
|
||||
├── @Immutable # 173+ event classes - prevents recomposition
|
||||
└── data class # Structural equality, copy(), immutable by convention
|
||||
|
||||
DSL Patterns
|
||||
├── Builder classes # Fluent APIs (TagArrayBuilder)
|
||||
├── Lambda receivers # inline fun tagArray { ... }
|
||||
└── Method chaining # return this
|
||||
|
||||
Performance
|
||||
├── inline fun # Eliminate lambda overhead
|
||||
├── reified type params # Runtime type info (OptimizedJsonMapper)
|
||||
└── value class # Zero-cost wrappers (NOT USED yet in Amethyst)
|
||||
```
|
||||
|
||||
**Delegation:**
|
||||
- **kotlin-coroutines agent**: Deep async (structured concurrency, channels, operators)
|
||||
- **kotlin-multiplatform skill**: expect/actual, source sets
|
||||
- **This skill**: Amethyst Kotlin idioms, state patterns, type safety
|
||||
|
||||
---
|
||||
|
||||
## 1. Flow State Management
|
||||
|
||||
### StateFlow: State that Changes
|
||||
|
||||
**Mental model:** StateFlow is a "hot" observable state holder. Always has a value, new collectors immediately get current state.
|
||||
|
||||
**Amethyst pattern:**
|
||||
|
||||
```kotlin
|
||||
// AccountManager.kt:48-50
|
||||
class AccountManager {
|
||||
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
|
||||
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
|
||||
|
||||
fun login(key: String) {
|
||||
_accountState.value = AccountState.LoggedIn(...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
1. **Private mutable, public immutable**: `_accountState` (MutableStateFlow) private, `accountState` (StateFlow) public
|
||||
2. **Always has value**: Initial value required (`LoggedOut`)
|
||||
3. **Single value**: Replays ONE most recent value to new subscribers
|
||||
4. **Hot**: Stays in memory, all collectors share same instance
|
||||
|
||||
**See:** AccountManager.kt:48-50, RelayConnectionManager.kt:49-52
|
||||
|
||||
### SharedFlow: Event Streams
|
||||
|
||||
**Mental model:** SharedFlow is a "hot" broadcast stream for events. Configurable replay buffer, doesn't require initial value.
|
||||
|
||||
**Amethyst pattern:**
|
||||
|
||||
```kotlin
|
||||
// RelayConnectionManager.kt:52-53
|
||||
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
|
||||
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
|
||||
```
|
||||
|
||||
**When to use StateFlow vs SharedFlow:**
|
||||
|
||||
| Scenario | Use StateFlow | Use SharedFlow |
|
||||
|----------|---------------|----------------|
|
||||
| **UI state** | ✅ Current screen data, login status | ❌ |
|
||||
| **One-time events** | ❌ | ✅ Navigation, snackbars, toasts |
|
||||
| **Always has value** | ✅ | ❌ Optional |
|
||||
| **Replay count** | 1 (latest only) | Configurable (0, 1, n) |
|
||||
| **Backpressure** | Conflates (drops old) | Configurable buffer |
|
||||
|
||||
**Best practice:**
|
||||
```kotlin
|
||||
// State: Use StateFlow
|
||||
private val _uiState = MutableStateFlow(UiState.Loading)
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
|
||||
// Events: Use SharedFlow
|
||||
private val _navigationEvents = MutableSharedFlow<NavEvent>(replay = 0)
|
||||
val navigationEvents: SharedFlow<NavEvent> = _navigationEvents.asSharedFlow()
|
||||
```
|
||||
|
||||
### Flow Anti-Patterns
|
||||
|
||||
❌ **Exposing mutable state:**
|
||||
```kotlin
|
||||
val accountState: MutableStateFlow<AccountState> // BAD: Can be mutated externally
|
||||
```
|
||||
|
||||
✅ **Expose immutable:**
|
||||
```kotlin
|
||||
val accountState: StateFlow<AccountState> = _accountState.asStateFlow() // GOOD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **SharedFlow for state:**
|
||||
```kotlin
|
||||
val loginState = MutableSharedFlow<LoginState>() // BAD: State might get lost
|
||||
```
|
||||
|
||||
✅ **StateFlow for state:**
|
||||
```kotlin
|
||||
val loginState = MutableStateFlow(LoginState.LoggedOut) // GOOD: Always has value
|
||||
```
|
||||
|
||||
**See:** `references/flow-patterns.md` for comprehensive examples.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sealed Hierarchies
|
||||
|
||||
### Sealed Classes: State Variants
|
||||
|
||||
**Mental model:** Sealed classes represent a closed set of variants that share common data/behavior.
|
||||
|
||||
**Amethyst pattern:**
|
||||
|
||||
```kotlin
|
||||
// AccountManager.kt:36-46
|
||||
sealed class AccountState {
|
||||
data object LoggedOut : AccountState()
|
||||
|
||||
data class LoggedIn(
|
||||
val signer: NostrSigner,
|
||||
val pubKeyHex: String,
|
||||
val npub: String,
|
||||
val nsec: String?,
|
||||
val isReadOnly: Boolean
|
||||
) : AccountState()
|
||||
}
|
||||
|
||||
// Usage
|
||||
when (state) {
|
||||
is AccountState.LoggedOut -> showLogin()
|
||||
is AccountState.LoggedIn -> showFeed(state.pubKeyHex)
|
||||
} // Exhaustive - compiler enforces all cases
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
1. **Closed hierarchy**: All subclasses known at compile-time
|
||||
2. **Exhaustive when**: Compiler ensures all cases handled
|
||||
3. **Shared data**: Sealed class can hold common properties
|
||||
4. **Single inheritance**: Subclass can't extend another class
|
||||
|
||||
**When to use:**
|
||||
- Modeling UI states (Loading, Success, Error)
|
||||
- Login states (LoggedOut, LoggedIn)
|
||||
- Result types with different data per variant
|
||||
|
||||
### Sealed Interfaces: Generic Result Types
|
||||
|
||||
**Mental model:** Sealed interfaces for contracts with multiple implementations that need generics or multiple inheritance.
|
||||
|
||||
**Amethyst pattern:**
|
||||
|
||||
```kotlin
|
||||
// SignerResult.kt:25-46
|
||||
sealed interface SignerResult<T : IResult> {
|
||||
sealed interface RequestAddressed<T : IResult> : SignerResult<T> {
|
||||
class Successful<T : IResult>(val result: T) : RequestAddressed<T>
|
||||
class Rejected<T : IResult> : RequestAddressed<T>
|
||||
class TimedOut<T : IResult> : RequestAddressed<T>
|
||||
class ReceivedButCouldNotPerform<T : IResult>(
|
||||
val message: String?
|
||||
) : RequestAddressed<T>
|
||||
}
|
||||
}
|
||||
|
||||
// Usage with generics
|
||||
fun handleResult(result: SignerResult<SignResult>) {
|
||||
when (result) {
|
||||
is SignerResult.RequestAddressed.Successful -> processEvent(result.result.event)
|
||||
is SignerResult.RequestAddressed.Rejected -> showRejected()
|
||||
is SignerResult.RequestAddressed.TimedOut -> showTimeout()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
1. **Multiple inheritance**: Subtype can implement other interfaces
|
||||
2. **Variance**: Supports `out`/`in` modifiers for generics
|
||||
3. **No constructor**: Can't hold state directly (subtypes can)
|
||||
4. **Nested hierarchies**: Can create sub-sealed hierarchies
|
||||
|
||||
### Sealed Class vs Sealed Interface
|
||||
|
||||
| Feature | Sealed Class | Sealed Interface |
|
||||
|---------|--------------|------------------|
|
||||
| **Constructor** | ✅ Can hold common state | ❌ No constructor |
|
||||
| **Inheritance** | ❌ Single parent only | ✅ Multiple interfaces |
|
||||
| **Generics** | ❌ No variance | ✅ Covariance/contravariance |
|
||||
| **Use case** | State variants | Result types, contracts |
|
||||
|
||||
**Decision tree:**
|
||||
|
||||
```
|
||||
Need to hold common data in base?
|
||||
YES → sealed class
|
||||
NO → sealed interface
|
||||
|
||||
Need generics with variance (out/in)?
|
||||
YES → sealed interface
|
||||
NO → Either works
|
||||
|
||||
Subtypes need multiple inheritance?
|
||||
YES → sealed interface
|
||||
NO → Either works
|
||||
```
|
||||
|
||||
**Amethyst examples:**
|
||||
- `sealed class AccountState` - state variants with different data
|
||||
- `sealed interface SignerResult<T>` - generic result types with variance
|
||||
|
||||
**See:** `references/sealed-class-catalog.md` for all sealed types in quartz.
|
||||
|
||||
---
|
||||
|
||||
## 3. Immutability & Compose Performance
|
||||
|
||||
### @Immutable Annotation
|
||||
|
||||
**Mental model:** @Immutable tells Compose "this value never changes after construction." Compose can skip recomposition if @Immutable object reference doesn't change.
|
||||
|
||||
**Amethyst pattern:**
|
||||
|
||||
```kotlin
|
||||
// TextNoteEvent.kt:51-63
|
||||
@Immutable
|
||||
class TextNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
// All properties immutable (val), no mutable state
|
||||
}
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
1. **All properties immutable**: Only `val`, never `var`
|
||||
2. **No mutable collections**: Use `ImmutableList`, `Array`, not `MutableList`
|
||||
3. **Deep immutability**: Nested objects also immutable
|
||||
4. **Compose optimization**: Skips recomposition if reference equals
|
||||
|
||||
**Why it matters:**
|
||||
|
||||
```kotlin
|
||||
// Without @Immutable
|
||||
@Composable
|
||||
fun NoteCard(note: TextNoteEvent) { // Recomposes every time parent recomposes
|
||||
Text(note.content)
|
||||
}
|
||||
|
||||
// With @Immutable
|
||||
@Composable
|
||||
fun NoteCard(note: TextNoteEvent) { // Only recomposes if note reference changes
|
||||
Text(note.content)
|
||||
}
|
||||
```
|
||||
|
||||
**173+ @Immutable classes** in quartz - all events immutable for Compose performance.
|
||||
|
||||
### Data Classes & Immutability
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class RelayStatus(
|
||||
val url: NormalizedRelayUrl,
|
||||
val connected: Boolean,
|
||||
val error: String? = null
|
||||
) {
|
||||
// Implicit: equals(), hashCode(), copy(), toString()
|
||||
}
|
||||
|
||||
// Usage
|
||||
val oldStatus = RelayStatus(url, connected = false)
|
||||
val newStatus = oldStatus.copy(connected = true) // Immutable update
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
1. **Structural equality**: `equals()` compares properties, not reference
|
||||
2. **copy()**: Create modified copies without mutating
|
||||
3. **All properties in constructor**: For proper `equals()`/`hashCode()`
|
||||
4. **Prefer val**: Make properties immutable
|
||||
|
||||
### kotlinx.collections.immutable
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```kotlin
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
// Instead of List (which could be mutable internally)
|
||||
val relays: ImmutableList<String> = persistentListOf("wss://relay1.com", "wss://relay2.com")
|
||||
|
||||
// Add returns new instance
|
||||
val updated = relays.add("wss://relay3.com") // relays unchanged, updated has 3 items
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Compose state that needs collection
|
||||
- Publicly exposed collections
|
||||
- Shared state across threads
|
||||
|
||||
**See:** `references/immutability-patterns.md`
|
||||
|
||||
---
|
||||
|
||||
## 4. DSL Builders
|
||||
|
||||
### Type-Safe Fluent APIs
|
||||
|
||||
**Mental model:** DSL (Domain-Specific Language) builders use lambda receivers and method chaining to create readable, type-safe APIs.
|
||||
|
||||
**Amethyst pattern:**
|
||||
|
||||
```kotlin
|
||||
// TagArrayBuilder.kt:23-90
|
||||
class TagArrayBuilder<T : IEvent> {
|
||||
private val tagList = mutableMapOf<String, MutableList<Tag>>()
|
||||
|
||||
fun add(tag: Array<String>): TagArrayBuilder<T> {
|
||||
if (tag.isEmpty() || tag[0].isEmpty()) return this
|
||||
tagList.getOrPut(tag[0], ::mutableListOf).add(tag)
|
||||
return this // Method chaining
|
||||
}
|
||||
|
||||
fun remove(tagName: String): TagArrayBuilder<T> {
|
||||
tagList.remove(tagName)
|
||||
return this // Method chaining
|
||||
}
|
||||
|
||||
fun build() = tagList.flatMap { it.value }.toTypedArray()
|
||||
}
|
||||
|
||||
// Inline function with lambda receiver (line 90)
|
||||
inline fun <T : Event> tagArray(initializer: TagArrayBuilder<T>.() -> Unit = {}): TagArray =
|
||||
TagArrayBuilder<T>().apply(initializer).build()
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
val tags = tagArray<TextNoteEvent> {
|
||||
add(arrayOf("e", eventId, relay, "reply"))
|
||||
add(arrayOf("p", pubkey))
|
||||
remove("a") // Remove address tags
|
||||
}
|
||||
```
|
||||
|
||||
**Key patterns:**
|
||||
1. **Method chaining**: Return `this` from mutator methods
|
||||
2. **Lambda receiver**: `TagArrayBuilder<T>.() -> Unit` - lambda has `this: TagArrayBuilder<T>`
|
||||
3. **inline function**: Eliminates lambda overhead
|
||||
4. **apply()**: Executes lambda with receiver, returns receiver
|
||||
|
||||
### DSL Pattern Template
|
||||
|
||||
```kotlin
|
||||
class MyBuilder {
|
||||
private val items = mutableListOf<Item>()
|
||||
|
||||
fun add(item: Item): MyBuilder {
|
||||
items.add(item)
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): Result = Result(items.toList())
|
||||
}
|
||||
|
||||
inline fun myDsl(init: MyBuilder.() -> Unit): Result =
|
||||
MyBuilder().apply(init).build()
|
||||
|
||||
// Usage
|
||||
val result = myDsl {
|
||||
add(Item("foo"))
|
||||
add(Item("bar"))
|
||||
}
|
||||
```
|
||||
|
||||
**Why inline?**
|
||||
- Eliminates lambda object allocation
|
||||
- Enables `reified` type parameters
|
||||
- Better performance for frequently-called DSLs
|
||||
|
||||
**See:** `references/dsl-builder-examples.md` for more patterns.
|
||||
|
||||
---
|
||||
|
||||
## 5. Inline Functions & reified
|
||||
|
||||
### inline fun: Eliminate Overhead
|
||||
|
||||
**Mental model:** `inline` copies function body to call site. No lambda object created, direct code insertion.
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```kotlin
|
||||
// Without inline
|
||||
fun <T> measureTime(block: () -> T): T {
|
||||
val start = System.currentTimeMillis()
|
||||
val result = block() // Lambda object allocated
|
||||
println("Time: ${System.currentTimeMillis() - start}ms")
|
||||
return result
|
||||
}
|
||||
|
||||
// With inline
|
||||
inline fun <T> measureTime(block: () -> T): T {
|
||||
val start = System.currentTimeMillis()
|
||||
val result = block() // No allocation, code inlined
|
||||
println("Time: ${System.currentTimeMillis() - start}ms")
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
1. **Zero overhead**: No lambda object allocation
|
||||
2. **Non-local returns**: Can `return` from outer function inside lambda
|
||||
3. **reified enabled**: Access to type parameter at runtime
|
||||
|
||||
### reified: Runtime Type Access
|
||||
|
||||
**Mental model:** `reified` makes generic type `T` available at runtime. Only works with `inline`.
|
||||
|
||||
**Amethyst pattern:**
|
||||
|
||||
```kotlin
|
||||
// OptimizedJsonMapper.kt:48
|
||||
expect object OptimizedJsonMapper {
|
||||
inline fun <reified T : OptimizedSerializable> fromJsonTo(json: String): T
|
||||
}
|
||||
|
||||
// Usage
|
||||
val event: TextNoteEvent = OptimizedJsonMapper.fromJsonTo(jsonString)
|
||||
// Compiler inlines and passes TextNoteEvent::class info
|
||||
```
|
||||
|
||||
**Without reified:**
|
||||
|
||||
```kotlin
|
||||
// Would need to pass class explicitly
|
||||
fun <T> fromJson(json: String, clazz: KClass<T>): T {
|
||||
return when (clazz) {
|
||||
TextNoteEvent::class -> parseTextNote(json) as T
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
val event = fromJson(json, TextNoteEvent::class) // Verbose
|
||||
```
|
||||
|
||||
**With reified:**
|
||||
|
||||
```kotlin
|
||||
inline fun <reified T> fromJson(json: String): T {
|
||||
return when (T::class) { // Can access T::class!
|
||||
TextNoteEvent::class -> parseTextNote(json) as T
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
val event = fromJson<TextNoteEvent>(json) // Clean
|
||||
```
|
||||
|
||||
### noinline & crossinline
|
||||
|
||||
**noinline**: Prevent specific lambda from being inlined
|
||||
|
||||
```kotlin
|
||||
inline fun foo(
|
||||
inlined: () -> Unit,
|
||||
noinline notInlined: () -> Unit // Can be stored, passed around
|
||||
) {
|
||||
inlined()
|
||||
someFunction(notInlined) // Can pass to non-inline function
|
||||
}
|
||||
```
|
||||
|
||||
**crossinline**: Lambda can't do non-local returns
|
||||
|
||||
```kotlin
|
||||
inline fun foo(crossinline block: () -> Unit) {
|
||||
launch {
|
||||
block() // OK: crossinline allows lambda in different context
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Value Classes (Opportunity)
|
||||
|
||||
**Mental model:** `value class` is a compile-time wrapper with zero runtime overhead. Single property, no boxing.
|
||||
|
||||
**Not currently used in Amethyst** - potential optimization.
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```kotlin
|
||||
@JvmInline
|
||||
value class EventId(val hex: String)
|
||||
|
||||
@JvmInline
|
||||
value class PubKey(val hex: String)
|
||||
|
||||
// Type safety without runtime cost
|
||||
fun fetchEvent(eventId: EventId): Event {
|
||||
// eventId.hex accessed without wrapper object
|
||||
}
|
||||
|
||||
val id = EventId("abc123")
|
||||
fetchEvent(id) // Type safe
|
||||
// fetchEvent(PubKey("xyz")) // Compile error!
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Type safety for primitives (IDs, hex strings, timestamps)
|
||||
- High-frequency allocations (event processing)
|
||||
- Clear domain types without overhead
|
||||
|
||||
**Restrictions:**
|
||||
- Single property only
|
||||
- Must be `val`
|
||||
- Can't have `init` block with logic
|
||||
- Inline at compile-time, may box in some cases
|
||||
|
||||
**Amethyst opportunity:**
|
||||
|
||||
```kotlin
|
||||
// Current (String everywhere, no type safety)
|
||||
fun fetchEvent(id: String): Event // Could pass wrong string
|
||||
|
||||
// With value class
|
||||
@JvmInline value class EventId(val hex: String)
|
||||
@JvmInline value class PubKeyHex(val hex: String)
|
||||
@JvmInline value class Bech32(val encoded: String)
|
||||
|
||||
fun fetchEvent(id: EventId): Event // Type safe, zero cost
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: StateFlow State Management
|
||||
|
||||
```kotlin
|
||||
class MyViewModel {
|
||||
private val _state = MutableStateFlow(State.Initial)
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
fun loadData() {
|
||||
viewModelScope.launch {
|
||||
_state.value = State.Loading
|
||||
val result = repository.getData()
|
||||
_state.value = when (result) {
|
||||
is Success -> State.Success(result.data)
|
||||
is Error -> State.Error(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class State {
|
||||
data object Initial : State()
|
||||
data object Loading : State()
|
||||
data class Success(val data: List<Item>) : State()
|
||||
data class Error(val message: String) : State()
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Sealed Result with Generics
|
||||
|
||||
```kotlin
|
||||
sealed interface Result<out T> {
|
||||
data class Success<T>(val value: T) : Result<T>
|
||||
data class Error(val exception: Exception) : Result<Nothing>
|
||||
data object Loading : Result<Nothing>
|
||||
}
|
||||
|
||||
// Use with variance
|
||||
fun <T> fetchData(): Result<T> = ...
|
||||
|
||||
val userResult: Result<User> = fetchData()
|
||||
val itemResult: Result<List<Item>> = fetchData()
|
||||
```
|
||||
|
||||
### Pattern: Immutable Event Builder
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class Event(
|
||||
val id: String,
|
||||
val kind: Int,
|
||||
val content: String,
|
||||
val tags: ImmutableList<Tag>
|
||||
) {
|
||||
companion object {
|
||||
fun builder() = EventBuilder()
|
||||
}
|
||||
}
|
||||
|
||||
class EventBuilder {
|
||||
private var id: String = ""
|
||||
private var kind: Int = 1
|
||||
private var content: String = ""
|
||||
private val tags = mutableListOf<Tag>()
|
||||
|
||||
fun id(value: String) = apply { id = value }
|
||||
fun kind(value: Int) = apply { kind = value }
|
||||
fun content(value: String) = apply { content = value }
|
||||
fun tag(tag: Tag) = apply { tags.add(tag) }
|
||||
|
||||
fun build() = Event(id, kind, content, tags.toImmutableList())
|
||||
}
|
||||
|
||||
// Usage
|
||||
val event = Event.builder()
|
||||
.id("abc")
|
||||
.kind(1)
|
||||
.content("Hello")
|
||||
.tag(Tag.P("pubkey"))
|
||||
.build()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Delegation Guide
|
||||
|
||||
**When to delegate:**
|
||||
|
||||
| Topic | Delegate To | This Skill Covers |
|
||||
|-------|-------------|-------------------|
|
||||
| Structured concurrency, channels | kotlin-coroutines agent | Flow state patterns only |
|
||||
| expect/actual, source sets | kotlin-multiplatform skill | Platform-agnostic Kotlin |
|
||||
| General Compose patterns | compose-expert skill | @Immutable for performance |
|
||||
| Build configuration | gradle-expert skill | - |
|
||||
|
||||
**Ask kotlin-coroutines agent for:**
|
||||
- Advanced Flow operators (flatMapLatest, combine, zip)
|
||||
- Channel patterns
|
||||
- Structured concurrency (supervisorScope, coroutineScope)
|
||||
- Error handling in coroutines
|
||||
|
||||
**This skill teaches:**
|
||||
- StateFlow/SharedFlow state management
|
||||
- Sealed hierarchies
|
||||
- @Immutable for Compose
|
||||
- DSL builders
|
||||
- Inline/reified patterns
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
❌ **Mutable public state:**
|
||||
```kotlin
|
||||
val accountState: MutableStateFlow<AccountState> // BAD
|
||||
```
|
||||
|
||||
✅ **Immutable public interface:**
|
||||
```kotlin
|
||||
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **Sealed class for generic results:**
|
||||
```kotlin
|
||||
sealed class Result<T> { // BAD: Can't use variance
|
||||
data class Success<T>(val value: T) : Result<T>()
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Sealed interface for generics:**
|
||||
```kotlin
|
||||
sealed interface Result<out T> { // GOOD: Covariance
|
||||
data class Success<T>(val value: T) : Result<T>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **Mutable properties in @Immutable class:**
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class Event(
|
||||
var content: String // BAD: var breaks immutability
|
||||
)
|
||||
```
|
||||
|
||||
✅ **All val:**
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class Event(
|
||||
val content: String
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
❌ **Passing class explicitly when reified available:**
|
||||
```kotlin
|
||||
inline fun <T> parse(json: String, clazz: KClass<T>): T // BAD
|
||||
```
|
||||
|
||||
✅ **Use reified:**
|
||||
```kotlin
|
||||
inline fun <reified T> parse(json: String): T // GOOD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Flow Decision Tree
|
||||
|
||||
```
|
||||
Need to expose state?
|
||||
YES → StateFlow (always has value, single latest)
|
||||
NO → Need events? → SharedFlow (optional replay, broadcast)
|
||||
|
||||
Need to mutate?
|
||||
Internal only → MutableStateFlow (private)
|
||||
Expose publicly → StateFlow via .asStateFlow()
|
||||
```
|
||||
|
||||
### Sealed Decision Tree
|
||||
|
||||
```
|
||||
Need common data in base type?
|
||||
YES → sealed class
|
||||
NO → sealed interface
|
||||
|
||||
Need generics with variance?
|
||||
YES → sealed interface
|
||||
NO → Either works
|
||||
|
||||
Need multiple inheritance?
|
||||
YES → sealed interface
|
||||
NO → Either works
|
||||
```
|
||||
|
||||
### Inline Decision Tree
|
||||
|
||||
```
|
||||
Passing lambda to function?
|
||||
Called frequently? → inline (performance)
|
||||
Need reified? → inline (required)
|
||||
Need to store/pass lambda? → regular fun (can't inline)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
### Official Docs
|
||||
- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow)
|
||||
- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html)
|
||||
- [Inline Functions | Kotlin Docs](https://kotlinlang.org/docs/inline-functions.html)
|
||||
|
||||
### Bundled References
|
||||
- `references/flow-patterns.md` - StateFlow/SharedFlow examples from AccountManager, RelayManager
|
||||
- `references/sealed-class-catalog.md` - All sealed types in quartz
|
||||
- `references/dsl-builder-examples.md` - TagArrayBuilder, other DSL patterns
|
||||
- `references/immutability-patterns.md` - @Immutable usage, data classes, collections
|
||||
|
||||
### Codebase Examples
|
||||
- AccountManager.kt:36-50 - sealed class AccountState, StateFlow pattern
|
||||
- RelayConnectionManager.kt:44-52 - StateFlow state management
|
||||
- SignerResult.kt:25-46 - sealed interface with generics
|
||||
- TextNoteEvent.kt:51-63 - @Immutable event class
|
||||
- TagArrayBuilder.kt:23-90 - DSL builder pattern, inline function
|
||||
- OptimizedJsonMapper.kt:48 - inline fun with reified
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2025-12-30
|
||||
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
|
||||
602
.claude/skills/kotlin-expert/references/dsl-builder-examples.md
Normal file
602
.claude/skills/kotlin-expert/references/dsl-builder-examples.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# DSL Builder Examples
|
||||
|
||||
Type-safe fluent APIs and DSL patterns from the codebase.
|
||||
|
||||
## Table of Contents
|
||||
- [TagArrayBuilder Pattern](#tagarraybuilder-pattern)
|
||||
- [Builder Variations](#builder-variations)
|
||||
- [DSL Principles](#dsl-principles)
|
||||
- [Creating Custom DSLs](#creating-custom-dsls)
|
||||
|
||||
---
|
||||
|
||||
## TagArrayBuilder Pattern
|
||||
|
||||
### Core Implementation
|
||||
|
||||
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt:23-91`
|
||||
|
||||
```kotlin
|
||||
class TagArrayBuilder<T : IEvent> {
|
||||
private val tagList = mutableMapOf<String, MutableList<Tag>>()
|
||||
|
||||
fun remove(tagName: String): TagArrayBuilder<T> {
|
||||
tagList.remove(tagName)
|
||||
return this // Method chaining
|
||||
}
|
||||
|
||||
fun remove(tagName: String, tagValue: String): TagArrayBuilder<T> {
|
||||
tagList[tagName]?.removeAll { it.valueOrNull() == tagValue }
|
||||
if (tagList[tagName]?.isEmpty() == true) {
|
||||
tagList.remove(tagName)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun removeIf(
|
||||
predicate: (Tag, Tag) -> Boolean,
|
||||
toCompare: Tag
|
||||
): TagArrayBuilder<T> {
|
||||
val tagName = toCompare.nameOrNull() ?: return this
|
||||
tagList[tagName]?.removeAll { predicate(it, toCompare) }
|
||||
if (tagList[tagName]?.isEmpty() == true) {
|
||||
tagList.remove(tagName)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun add(tag: Array<String>): TagArrayBuilder<T> {
|
||||
if (tag.isEmpty() || tag[0].isEmpty()) return this
|
||||
tagList.getOrPut(tag[0], ::mutableListOf).add(tag)
|
||||
return this
|
||||
}
|
||||
|
||||
fun addFirst(tag: Array<String>): TagArrayBuilder<T> {
|
||||
if (tag.isEmpty() || tag[0].isEmpty()) return this
|
||||
tagList.getOrPut(tag[0], ::mutableListOf).add(0, tag)
|
||||
return this
|
||||
}
|
||||
|
||||
fun addUnique(tag: Array<String>): TagArrayBuilder<T> {
|
||||
if (tag.isEmpty() || tag[0].isEmpty()) return this
|
||||
tagList[tag[0]] = mutableListOf(tag) // Replace existing
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAll(tag: List<Array<String>>): TagArrayBuilder<T> {
|
||||
tag.forEach(::add)
|
||||
return this
|
||||
}
|
||||
|
||||
fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray()
|
||||
|
||||
fun build() = toTypedArray()
|
||||
}
|
||||
|
||||
// Inline DSL function with lambda receiver
|
||||
inline fun <T : Event> tagArray(
|
||||
initializer: TagArrayBuilder<T>.() -> Unit = {}
|
||||
): TagArray = TagArrayBuilder<T>().apply(initializer).build()
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
**Basic usage:**
|
||||
|
||||
```kotlin
|
||||
val tags = tagArray<TextNoteEvent> {
|
||||
add(arrayOf("e", eventId, relay, "reply"))
|
||||
add(arrayOf("p", pubkey))
|
||||
add(arrayOf("t", "bitcoin"))
|
||||
}
|
||||
```
|
||||
|
||||
**Advanced patterns:**
|
||||
|
||||
```kotlin
|
||||
// Remove and add
|
||||
val tags = tagArray<TextNoteEvent> {
|
||||
addAll(existingTags)
|
||||
remove("a") // Remove all address tags
|
||||
addUnique(arrayOf("client", "Amethyst")) // Replace client tag
|
||||
}
|
||||
|
||||
// Conditional building
|
||||
val tags = tagArray<TextNoteEvent> {
|
||||
add(arrayOf("e", rootId, "", "root"))
|
||||
|
||||
if (replyToId != null) {
|
||||
add(arrayOf("e", replyToId, "", "reply"))
|
||||
}
|
||||
|
||||
mentionedPubkeys.forEach { pubkey ->
|
||||
add(arrayOf("p", pubkey))
|
||||
}
|
||||
|
||||
hashtags.forEach { tag ->
|
||||
add(arrayOf("t", tag.lowercase()))
|
||||
}
|
||||
}
|
||||
|
||||
// Custom predicate removal
|
||||
val tags = tagArray<TextNoteEvent> {
|
||||
addAll(originalTags)
|
||||
removeIf(
|
||||
predicate = { tag, compare -> tag[1] == compare[1] },
|
||||
toCompare = arrayOf("e", eventIdToRemove)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Builder Variations
|
||||
|
||||
### PrivateTagArrayBuilder
|
||||
|
||||
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt`
|
||||
|
||||
```kotlin
|
||||
class PrivateTagArrayBuilder {
|
||||
private val builder = TagArrayBuilder<Event>()
|
||||
|
||||
fun add(tag: PrivateTag): PrivateTagArrayBuilder {
|
||||
builder.add(tag.toArray())
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAll(tags: List<PrivateTag>): PrivateTagArrayBuilder {
|
||||
tags.forEach { add(it) }
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): Array<Array<String>> = builder.build()
|
||||
}
|
||||
|
||||
// DSL function
|
||||
inline fun privateTagArray(
|
||||
initializer: PrivateTagArrayBuilder.() -> Unit
|
||||
): Array<Array<String>> = PrivateTagArrayBuilder().apply(initializer).build()
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
val privateTags = privateTagArray {
|
||||
add(PrivateTag.Event(eventId, marker = "bookmark"))
|
||||
add(PrivateTag.Profile(pubkey))
|
||||
addAll(existingPrivateTags)
|
||||
}
|
||||
```
|
||||
|
||||
### TlvBuilder
|
||||
|
||||
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt`
|
||||
|
||||
```kotlin
|
||||
class TlvBuilder {
|
||||
private val entries = mutableListOf<TlvEntry>()
|
||||
|
||||
fun add(type: TlvType, value: ByteArray): TlvBuilder {
|
||||
entries.add(TlvEntry(type, value))
|
||||
return this
|
||||
}
|
||||
|
||||
fun addRelay(relay: String): TlvBuilder {
|
||||
add(TlvType.Relay, relay.encodeToByteArray())
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAuthor(pubkey: ByteArray): TlvBuilder {
|
||||
add(TlvType.Author, pubkey)
|
||||
return this
|
||||
}
|
||||
|
||||
fun addKind(kind: Int): TlvBuilder {
|
||||
add(TlvType.Kind, kind.toByteArray())
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): ByteArray {
|
||||
return entries.flatMap { it.encode() }.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
fun tlv(init: TlvBuilder.() -> Unit): ByteArray =
|
||||
TlvBuilder().apply(init).build()
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
val tlvData = tlv {
|
||||
addAuthor(pubkeyBytes)
|
||||
addRelay("wss://relay.damus.io")
|
||||
addKind(1)
|
||||
}
|
||||
```
|
||||
|
||||
### MapOfSetBuilder
|
||||
|
||||
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt`
|
||||
|
||||
```kotlin
|
||||
class MapOfSetBuilder<K, V> {
|
||||
private val map = mutableMapOf<K, MutableSet<V>>()
|
||||
|
||||
fun add(key: K, value: V): MapOfSetBuilder<K, V> {
|
||||
map.getOrPut(key) { mutableSetOf() }.add(value)
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAll(key: K, values: Collection<V>): MapOfSetBuilder<K, V> {
|
||||
map.getOrPut(key) { mutableSetOf() }.addAll(values)
|
||||
return this
|
||||
}
|
||||
|
||||
fun remove(key: K, value: V): MapOfSetBuilder<K, V> {
|
||||
map[key]?.remove(value)
|
||||
if (map[key]?.isEmpty() == true) {
|
||||
map.remove(key)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): Map<K, Set<V>> = map.mapValues { it.value.toSet() }
|
||||
}
|
||||
|
||||
inline fun <K, V> mapOfSets(
|
||||
init: MapOfSetBuilder<K, V>.() -> Unit
|
||||
): Map<K, Set<V>> = MapOfSetBuilder<K, V>().apply(init).build()
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
val relayMap = mapOfSets<String, EventId> {
|
||||
add("wss://relay1.com", eventId1)
|
||||
add("wss://relay1.com", eventId2)
|
||||
add("wss://relay2.com", eventId3)
|
||||
}
|
||||
// Result: {"wss://relay1.com": [eventId1, eventId2], "wss://relay2.com": [eventId3]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DSL Principles
|
||||
|
||||
### 1. Lambda with Receiver
|
||||
|
||||
**Mental model:** Lambda receiver makes `this` refer to builder instance inside lambda.
|
||||
|
||||
```kotlin
|
||||
// Without receiver
|
||||
fun buildTags(config: (TagArrayBuilder<Event>) -> Unit) {
|
||||
val builder = TagArrayBuilder<Event>()
|
||||
config(builder) // Must pass builder explicitly
|
||||
builder.build()
|
||||
}
|
||||
|
||||
buildTags { builder ->
|
||||
builder.add(...) // Verbose
|
||||
}
|
||||
|
||||
// With receiver
|
||||
inline fun buildTags(config: TagArrayBuilder<Event>.() -> Unit) {
|
||||
TagArrayBuilder<Event>().apply(config).build()
|
||||
}
|
||||
|
||||
buildTags {
|
||||
add(...) // Clean - 'this' is builder
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Method Chaining
|
||||
|
||||
**Pattern:** Return `this` from mutator methods.
|
||||
|
||||
```kotlin
|
||||
class Builder {
|
||||
private var value: String = ""
|
||||
|
||||
fun setValue(v: String): Builder {
|
||||
value = v
|
||||
return this // Enable chaining
|
||||
}
|
||||
|
||||
fun append(s: String): Builder {
|
||||
value += s
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): String = value
|
||||
}
|
||||
|
||||
// Usage
|
||||
val result = Builder()
|
||||
.setValue("Hello")
|
||||
.append(" ")
|
||||
.append("World")
|
||||
.build()
|
||||
```
|
||||
|
||||
### 3. Inline for Performance
|
||||
|
||||
**Why inline:**
|
||||
- Eliminates lambda allocation
|
||||
- Allows `reified` type parameters
|
||||
- Better for hot paths (frequently called)
|
||||
|
||||
```kotlin
|
||||
// NOT inline - lambda object created each call
|
||||
fun <T> myDsl(init: Builder<T>.() -> Unit): Result<T> {
|
||||
return Builder<T>().apply(init).build()
|
||||
}
|
||||
|
||||
// Inline - lambda code inlined at call site
|
||||
inline fun <T> myDsl(init: Builder<T>.() -> Unit): Result<T> {
|
||||
return Builder<T>().apply(init).build()
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Type Safety
|
||||
|
||||
**Use generics for compile-time safety:**
|
||||
|
||||
```kotlin
|
||||
// Type-safe builder
|
||||
class EventBuilder<T : Event> {
|
||||
fun addTag(tag: Tag<T>): EventBuilder<T> { // Only accepts tags for this event type
|
||||
tags.add(tag)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
val textNote = EventBuilder<TextNoteEvent>()
|
||||
.addTag(TextNoteTag.Subject("Hello")) // OK
|
||||
// .addTag(ChannelTag.Name("test")) // Compile error!
|
||||
.build()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating Custom DSLs
|
||||
|
||||
### Pattern: Simple Builder DSL
|
||||
|
||||
```kotlin
|
||||
class QueryBuilder {
|
||||
private val filters = mutableListOf<String>()
|
||||
private var limit: Int? = null
|
||||
private var offset: Int? = null
|
||||
|
||||
fun filter(field: String, value: String): QueryBuilder {
|
||||
filters.add("$field:$value")
|
||||
return this
|
||||
}
|
||||
|
||||
fun limit(n: Int): QueryBuilder {
|
||||
limit = n
|
||||
return this
|
||||
}
|
||||
|
||||
fun offset(n: Int): QueryBuilder {
|
||||
offset = n
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): String {
|
||||
val parts = mutableListOf<String>()
|
||||
if (filters.isNotEmpty()) {
|
||||
parts.add(filters.joinToString(" AND "))
|
||||
}
|
||||
if (limit != null) {
|
||||
parts.add("LIMIT $limit")
|
||||
}
|
||||
if (offset != null) {
|
||||
parts.add("OFFSET $offset")
|
||||
}
|
||||
return parts.joinToString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
inline fun query(init: QueryBuilder.() -> Unit): String =
|
||||
QueryBuilder().apply(init).build()
|
||||
|
||||
// Usage
|
||||
val sql = query {
|
||||
filter("status", "active")
|
||||
filter("age", ">18")
|
||||
limit(10)
|
||||
offset(20)
|
||||
}
|
||||
// Result: "status:active AND age:>18 LIMIT 10 OFFSET 20"
|
||||
```
|
||||
|
||||
### Pattern: Nested Builders
|
||||
|
||||
```kotlin
|
||||
class FilterBuilder {
|
||||
private val conditions = mutableListOf<String>()
|
||||
|
||||
fun equals(field: String, value: String) {
|
||||
conditions.add("$field = '$value'")
|
||||
}
|
||||
|
||||
fun greaterThan(field: String, value: Int) {
|
||||
conditions.add("$field > $value")
|
||||
}
|
||||
|
||||
fun build(): String = conditions.joinToString(" AND ")
|
||||
}
|
||||
|
||||
class QueryBuilder {
|
||||
private var filterClause: String = ""
|
||||
private var selectClause: String = "*"
|
||||
|
||||
fun select(vararg fields: String): QueryBuilder {
|
||||
selectClause = fields.joinToString(", ")
|
||||
return this
|
||||
}
|
||||
|
||||
fun where(init: FilterBuilder.() -> Unit): QueryBuilder {
|
||||
filterClause = FilterBuilder().apply(init).build()
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): String {
|
||||
return "SELECT $selectClause WHERE $filterClause"
|
||||
}
|
||||
}
|
||||
|
||||
inline fun query(init: QueryBuilder.() -> Unit): String =
|
||||
QueryBuilder().apply(init).build()
|
||||
|
||||
// Usage
|
||||
val sql = query {
|
||||
select("id", "name", "age")
|
||||
where {
|
||||
equals("status", "active")
|
||||
greaterThan("age", 18)
|
||||
}
|
||||
}
|
||||
// Result: "SELECT id, name, age WHERE status = 'active' AND age > 18"
|
||||
```
|
||||
|
||||
### Pattern: Type-Safe HTML DSL
|
||||
|
||||
```kotlin
|
||||
abstract class Tag(val name: String) {
|
||||
private val children = mutableListOf<Tag>()
|
||||
private val attributes = mutableMapOf<String, String>()
|
||||
|
||||
fun <T : Tag> tag(tag: T, init: T.() -> Unit): T {
|
||||
tag.init()
|
||||
children.add(tag)
|
||||
return tag
|
||||
}
|
||||
|
||||
fun attr(name: String, value: String) {
|
||||
attributes[name] = value
|
||||
}
|
||||
|
||||
fun render(builder: StringBuilder, indent: String) {
|
||||
builder.append("$indent<$name")
|
||||
attributes.forEach { (k, v) -> builder.append(" $k=\"$v\"") }
|
||||
if (children.isEmpty()) {
|
||||
builder.append("/>\n")
|
||||
} else {
|
||||
builder.append(">\n")
|
||||
children.forEach { it.render(builder, "$indent ") }
|
||||
builder.append("$indent</$name>\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HTML : Tag("html")
|
||||
class Head : Tag("head")
|
||||
class Body : Tag("body")
|
||||
class Div : Tag("div")
|
||||
class P : Tag("p")
|
||||
class A : Tag("a")
|
||||
|
||||
fun HTML.head(init: Head.() -> Unit) = tag(Head(), init)
|
||||
fun HTML.body(init: Body.() -> Unit) = tag(Body(), init)
|
||||
fun Body.div(init: Div.() -> Unit) = tag(Div(), init)
|
||||
fun Div.p(init: P.() -> Unit) = tag(P(), init)
|
||||
fun Div.a(init: A.() -> Unit) = tag(A(), init)
|
||||
|
||||
fun html(init: HTML.() -> Unit): HTML = HTML().apply(init)
|
||||
|
||||
// Usage
|
||||
val page = html {
|
||||
head {
|
||||
// ...
|
||||
}
|
||||
body {
|
||||
div {
|
||||
attr("class", "container")
|
||||
p {
|
||||
attr("id", "intro")
|
||||
}
|
||||
a {
|
||||
attr("href", "https://example.com")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
|
||||
1. **Return `this` for chaining:**
|
||||
```kotlin
|
||||
fun add(item: Item): Builder {
|
||||
items.add(item)
|
||||
return this
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use `inline` for DSL functions:**
|
||||
```kotlin
|
||||
inline fun myDsl(init: Builder.() -> Unit) = Builder().apply(init).build()
|
||||
```
|
||||
|
||||
3. **Provide sensible defaults:**
|
||||
```kotlin
|
||||
inline fun query(
|
||||
init: QueryBuilder.() -> Unit = {} // Empty lambda as default
|
||||
) = QueryBuilder().apply(init).build()
|
||||
```
|
||||
|
||||
4. **Validate in `build()`:**
|
||||
```kotlin
|
||||
fun build(): Result {
|
||||
require(fields.isNotEmpty()) { "Must specify at least one field" }
|
||||
return Result(fields)
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T
|
||||
|
||||
1. **Forget to return `this`:**
|
||||
```kotlin
|
||||
fun add(item: Item) { // BAD: Can't chain
|
||||
items.add(item)
|
||||
}
|
||||
```
|
||||
|
||||
2. **Mutate after build:**
|
||||
```kotlin
|
||||
val builder = Builder()
|
||||
builder.add("foo")
|
||||
val result = builder.build()
|
||||
builder.add("bar") // BAD: Confusing state
|
||||
```
|
||||
|
||||
3. **Expose mutable state:**
|
||||
```kotlin
|
||||
class Builder {
|
||||
val items = mutableListOf<Item>() // BAD: Can be mutated externally
|
||||
}
|
||||
```
|
||||
|
||||
4. **Make DSL functions non-inline unnecessarily:**
|
||||
```kotlin
|
||||
fun myDsl(init: Builder.() -> Unit) = ... // BAD: Lambda allocation overhead
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- TagArrayBuilder.kt:23-91
|
||||
- PrivateTagArrayBuilder.kt
|
||||
- TlvBuilder.kt
|
||||
- [Type-Safe Builders | Kotlin Docs](https://kotlinlang.org/docs/type-safe-builders.html)
|
||||
- [DSLs with Kotlin](https://kt.academy/article/dsl-intro)
|
||||
405
.claude/skills/kotlin-expert/references/flow-patterns.md
Normal file
405
.claude/skills/kotlin-expert/references/flow-patterns.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Flow Patterns in Amethyst
|
||||
|
||||
StateFlow and SharedFlow usage patterns from the codebase.
|
||||
|
||||
## Table of Contents
|
||||
- [StateFlow for State Management](#stateflow-for-state-management)
|
||||
- [Flow Composition](#flow-composition)
|
||||
- [Common Patterns](#common-patterns)
|
||||
- [Anti-Patterns](#anti-patterns)
|
||||
|
||||
---
|
||||
|
||||
## StateFlow for State Management
|
||||
|
||||
### AccountManager Pattern
|
||||
|
||||
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-115`
|
||||
|
||||
```kotlin
|
||||
sealed class AccountState {
|
||||
data object LoggedOut : AccountState()
|
||||
|
||||
data class LoggedIn(
|
||||
val signer: NostrSigner,
|
||||
val pubKeyHex: String,
|
||||
val npub: String,
|
||||
val nsec: String?,
|
||||
val isReadOnly: Boolean,
|
||||
) : AccountState()
|
||||
}
|
||||
|
||||
class AccountManager {
|
||||
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
|
||||
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
|
||||
|
||||
fun generateNewAccount(): AccountState.LoggedIn {
|
||||
val keyPair = KeyPair()
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
|
||||
val state = AccountState.LoggedIn(
|
||||
signer = signer,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = keyPair.privKey?.toNsec(),
|
||||
isReadOnly = false
|
||||
)
|
||||
_accountState.value = state // Update state
|
||||
return state
|
||||
}
|
||||
|
||||
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
|
||||
// ... validation ...
|
||||
|
||||
val state = AccountState.LoggedIn(...)
|
||||
_accountState.value = state
|
||||
return Result.success(state)
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
_accountState.value = AccountState.LoggedOut
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern highlights:**
|
||||
- Private `MutableStateFlow` for internal mutations
|
||||
- Public `StateFlow` via `.asStateFlow()` for read-only access
|
||||
- Sealed class for type-safe state variants
|
||||
- Initial value required (`AccountState.LoggedOut`)
|
||||
|
||||
### RelayConnectionManager Pattern
|
||||
|
||||
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt:44-80`
|
||||
|
||||
```kotlin
|
||||
data class RelayStatus(
|
||||
val url: NormalizedRelayUrl,
|
||||
val connected: Boolean,
|
||||
val error: String? = null,
|
||||
val messageCount: Int = 0
|
||||
)
|
||||
|
||||
open class RelayConnectionManager(
|
||||
websocketBuilder: WebsocketBuilder
|
||||
) : IRelayClientListener {
|
||||
private val client = NostrClient(websocketBuilder)
|
||||
|
||||
// Map of relay URLs to their status
|
||||
private val _relayStatuses = MutableStateFlow<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
|
||||
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
|
||||
|
||||
// Delegated StateFlows from client
|
||||
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
|
||||
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
|
||||
|
||||
fun addRelay(url: String): NormalizedRelayUrl? {
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null
|
||||
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
|
||||
return normalized
|
||||
}
|
||||
|
||||
fun removeRelay(url: NormalizedRelayUrl) {
|
||||
_relayStatuses.value = _relayStatuses.value - url // Immutable update (remove from map)
|
||||
}
|
||||
|
||||
private fun updateRelayStatus(
|
||||
relay: NormalizedRelayUrl,
|
||||
update: (RelayStatus) -> RelayStatus
|
||||
) {
|
||||
_relayStatuses.value = _relayStatuses.value.toMutableMap().apply {
|
||||
val current = get(relay) ?: RelayStatus(relay, false)
|
||||
put(relay, update(current))
|
||||
}
|
||||
}
|
||||
|
||||
// IRelayClientListener implementation
|
||||
override fun onConnect(relay: NormalizedRelayUrl) {
|
||||
updateRelayStatus(relay) { it.copy(connected = true, error = null) }
|
||||
}
|
||||
|
||||
override fun onError(relay: NormalizedRelayUrl, error: String) {
|
||||
updateRelayStatus(relay) { it.copy(connected = false, error = error) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern highlights:**
|
||||
- `Map` as state value for collection tracking
|
||||
- Immutable map updates (copy with modifications)
|
||||
- Helper function `updateRelayStatus` for consistent updates
|
||||
- Delegation pattern (client exposes its own StateFlows)
|
||||
|
||||
---
|
||||
|
||||
## Flow Composition
|
||||
|
||||
### Multiple StateFlows in UI
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoginScreen(accountManager: AccountManager) {
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
|
||||
when (accountState) {
|
||||
is AccountState.LoggedOut -> {
|
||||
LoginForm(onLogin = { key -> accountManager.loginWithKey(key) })
|
||||
}
|
||||
is AccountState.LoggedIn -> {
|
||||
MainApp(account = accountState as AccountState.LoggedIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Observing Multiple Flows
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayStatusCard(relayManager: RelayConnectionManager) {
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
|
||||
Column {
|
||||
Text("${connectedRelays.size} of ${relayStatuses.size} relays connected")
|
||||
|
||||
relayStatuses.forEach { (url, status) ->
|
||||
RelayRow(
|
||||
url = url,
|
||||
connected = status.connected,
|
||||
error = status.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Immutable State Updates
|
||||
|
||||
```kotlin
|
||||
// Map updates
|
||||
_relayStatuses.value = _relayStatuses.value + (url to newStatus) // Add
|
||||
_relayStatuses.value = _relayStatuses.value - url // Remove
|
||||
_relayStatuses.value = _relayStatuses.value.mapValues { (key, value) ->
|
||||
if (key == targetUrl) value.copy(connected = true) else value
|
||||
}
|
||||
|
||||
// List updates
|
||||
_items.value = _items.value + newItem // Append
|
||||
_items.value = _items.value.filter { it.id != removedId } // Remove
|
||||
_items.value = _items.value.map { if (it.id == id) it.copy(name = newName) else it } // Update
|
||||
|
||||
// Object updates
|
||||
_user.value = _user.value.copy(name = newName)
|
||||
```
|
||||
|
||||
### Pattern: Conditional State Transitions
|
||||
|
||||
```kotlin
|
||||
fun attemptLogin(credentials: Credentials) {
|
||||
if (_loginState.value is LoginState.LoggingIn) {
|
||||
return // Already logging in, ignore
|
||||
}
|
||||
|
||||
_loginState.value = LoginState.LoggingIn
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val user = repository.login(credentials)
|
||||
_loginState.value = LoginState.Success(user)
|
||||
} catch (e: Exception) {
|
||||
_loginState.value = LoginState.Error(e.message ?: "Login failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Derived State
|
||||
|
||||
```kotlin
|
||||
class MyViewModel {
|
||||
private val _items = MutableStateFlow<List<Item>>(emptyList())
|
||||
val items: StateFlow<List<Item>> = _items.asStateFlow()
|
||||
|
||||
// Derived state (computed from items)
|
||||
val itemCount: StateFlow<Int> = items.map { it.size }
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, 0)
|
||||
|
||||
val hasItems: StateFlow<Boolean> = items.map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, false)
|
||||
}
|
||||
|
||||
// Usage in Compose
|
||||
@Composable
|
||||
fun ItemList(viewModel: MyViewModel) {
|
||||
val itemCount by viewModel.itemCount.collectAsState()
|
||||
val hasItems by viewModel.hasItems.collectAsState()
|
||||
|
||||
if (hasItems) {
|
||||
Text("$itemCount items")
|
||||
} else {
|
||||
Text("No items")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: State with Loading/Error
|
||||
|
||||
```kotlin
|
||||
sealed class UiState<out T> {
|
||||
data object Loading : UiState<Nothing>()
|
||||
data class Success<T>(val data: T) : UiState<T>()
|
||||
data class Error(val message: String) : UiState<Nothing>()
|
||||
}
|
||||
|
||||
class FeedViewModel {
|
||||
private val _feedState = MutableStateFlow<UiState<List<Event>>>(UiState.Loading)
|
||||
val feedState: StateFlow<UiState<List<Event>>> = _feedState.asStateFlow()
|
||||
|
||||
fun loadFeed() {
|
||||
viewModelScope.launch {
|
||||
_feedState.value = UiState.Loading
|
||||
try {
|
||||
val events = repository.getEvents()
|
||||
_feedState.value = UiState.Success(events)
|
||||
} catch (e: Exception) {
|
||||
_feedState.value = UiState.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI
|
||||
@Composable
|
||||
fun FeedScreen(viewModel: FeedViewModel) {
|
||||
val state by viewModel.feedState.collectAsState()
|
||||
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingSpinner()
|
||||
is UiState.Success -> EventList((state as UiState.Success).data)
|
||||
is UiState.Error -> ErrorMessage((state as UiState.Error).message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ Exposing Mutable State
|
||||
|
||||
```kotlin
|
||||
// BAD: External code can mutate
|
||||
class BadViewModel {
|
||||
val state: MutableStateFlow<State> = MutableStateFlow(State.Initial)
|
||||
}
|
||||
|
||||
// Caller can do:
|
||||
viewModel.state.value = State.Hacked // Bypass internal logic!
|
||||
```
|
||||
|
||||
### ✅ Expose Immutable
|
||||
|
||||
```kotlin
|
||||
// GOOD: Only ViewModel can mutate
|
||||
class GoodViewModel {
|
||||
private val _state = MutableStateFlow(State.Initial)
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
fun updateState(newState: State) {
|
||||
// Controlled mutation with validation
|
||||
_state.value = newState
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ Not Using Immutable Updates
|
||||
|
||||
```kotlin
|
||||
// BAD: Mutating collection doesn't trigger StateFlow update
|
||||
val list = mutableListOf<Item>()
|
||||
list.add(newItem)
|
||||
_items.value = list // Same reference, no update emitted!
|
||||
```
|
||||
|
||||
### ✅ Create New Instance
|
||||
|
||||
```kotlin
|
||||
// GOOD: New list instance
|
||||
_items.value = _items.value + newItem // New list created, update emitted
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ StateFlow for Events
|
||||
|
||||
```kotlin
|
||||
// BAD: Events get lost if no collector
|
||||
class BadViewModel {
|
||||
val navigationEvent: StateFlow<NavEvent?> = MutableStateFlow(null)
|
||||
|
||||
fun navigate(event: NavEvent) {
|
||||
_navigationEvent.value = event // Lost if UI not observing!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ SharedFlow for Events
|
||||
|
||||
```kotlin
|
||||
// GOOD: Events queued
|
||||
class GoodViewModel {
|
||||
private val _navigationEvent = MutableSharedFlow<NavEvent>(replay = 0)
|
||||
val navigationEvent: SharedFlow<NavEvent> = _navigationEvent.asSharedFlow()
|
||||
|
||||
fun navigate(event: NavEvent) {
|
||||
viewModelScope.launch {
|
||||
_navigationEvent.emit(event) // Queued for collector
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ Blocking Operations in State Update
|
||||
|
||||
```kotlin
|
||||
// BAD: Blocking main thread
|
||||
fun loadData() {
|
||||
_state.value = fetchDataFromNetwork() // Blocks!
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Async Updates
|
||||
|
||||
```kotlin
|
||||
// GOOD: Use coroutines
|
||||
fun loadData() {
|
||||
viewModelScope.launch {
|
||||
_state.value = UiState.Loading
|
||||
val data = withContext(Dispatchers.IO) {
|
||||
fetchDataFromNetwork()
|
||||
}
|
||||
_state.value = UiState.Success(data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- AccountManager.kt:36-115
|
||||
- RelayConnectionManager.kt:44-80
|
||||
- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow)
|
||||
- [Hot vs Cold Flows](https://carrion.dev/en/posts/kotlin-flows-hot-cold/)
|
||||
641
.claude/skills/kotlin-expert/references/immutability-patterns.md
Normal file
641
.claude/skills/kotlin-expert/references/immutability-patterns.md
Normal file
@@ -0,0 +1,641 @@
|
||||
# Immutability Patterns
|
||||
|
||||
@Immutable annotation, data classes, and immutable collections for Compose performance.
|
||||
|
||||
## Table of Contents
|
||||
- [Why Immutability Matters](#why-immutability-matters)
|
||||
- [@Immutable Annotation](#immutable-annotation)
|
||||
- [Data Classes](#data-classes)
|
||||
- [Immutable Collections](#immutable-collections)
|
||||
- [Common Patterns](#common-patterns)
|
||||
- [Performance Impact](#performance-impact)
|
||||
|
||||
---
|
||||
|
||||
## Why Immutability Matters
|
||||
|
||||
### Compose Recomposition
|
||||
|
||||
**Mental model:** Compose tracks state changes by comparing references. If an `@Immutable` object reference doesn't change, Compose skips recomposition.
|
||||
|
||||
```kotlin
|
||||
// Without @Immutable - Recomposes on every parent recomposition
|
||||
data class User(val name: String, val age: Int)
|
||||
|
||||
@Composable
|
||||
fun UserCard(user: User) { // Recomposes unnecessarily
|
||||
Text(user.name)
|
||||
}
|
||||
|
||||
// With @Immutable - Only recomposes when user reference changes
|
||||
@Immutable
|
||||
data class User(val name: String, val age: Int)
|
||||
|
||||
@Composable
|
||||
fun UserCard(user: User) { // Smart recomposition
|
||||
Text(user.name)
|
||||
}
|
||||
```
|
||||
|
||||
**Performance difference:**
|
||||
- Without `@Immutable`: 1000 `UserCard` recompositions per screen update
|
||||
- With `@Immutable`: 10 `UserCard` recompositions (only changed users)
|
||||
|
||||
### Thread Safety
|
||||
|
||||
Immutable objects are inherently thread-safe:
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class Event(
|
||||
val id: String,
|
||||
val content: String,
|
||||
val createdAt: Long
|
||||
)
|
||||
|
||||
// Safe to share across coroutines without synchronization
|
||||
val sharedEvent: Event = fetchEvent()
|
||||
launch { processEvent(sharedEvent) } // Safe
|
||||
launch { saveEvent(sharedEvent) } // Safe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## @Immutable Annotation
|
||||
|
||||
### Basic Usage
|
||||
|
||||
**Pattern from Amethyst:**
|
||||
|
||||
```kotlin
|
||||
// TextNoteEvent.kt:51-63
|
||||
@Immutable
|
||||
class TextNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
// All properties are val (immutable)
|
||||
// No var properties
|
||||
// No mutable collections
|
||||
}
|
||||
```
|
||||
|
||||
**Requirements for @Immutable:**
|
||||
1. All properties must be `val` (no `var`)
|
||||
2. All property types must be immutable or primitives
|
||||
3. No mutable collections (`MutableList`, `MutableMap`)
|
||||
4. Arrays are allowed (treated as immutable by contract)
|
||||
5. No public mutable state
|
||||
|
||||
### @Immutable vs @Stable
|
||||
|
||||
**@Immutable:** Value never changes after construction
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class User(val name: String, val age: Int)
|
||||
// Once created, user.name and user.age never change
|
||||
```
|
||||
|
||||
**@Stable:** Value can change, but changes are tracked
|
||||
|
||||
```kotlin
|
||||
@Stable
|
||||
class MutableCounter {
|
||||
var count by mutableStateOf(0) // Changes tracked by Compose
|
||||
}
|
||||
```
|
||||
|
||||
**Amethyst uses @Immutable extensively:**
|
||||
- 173+ event classes annotated with `@Immutable`
|
||||
- All Nostr events immutable by design
|
||||
- Critical for feed performance (thousands of events)
|
||||
|
||||
---
|
||||
|
||||
## Data Classes
|
||||
|
||||
### Immutable Data Classes
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class RelayStatus(
|
||||
val url: NormalizedRelayUrl,
|
||||
val connected: Boolean,
|
||||
val error: String? = null,
|
||||
val messageCount: Int = 0
|
||||
) {
|
||||
// Immutable properties only (val)
|
||||
// Default values allowed
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
1. **Structural equality:** `equals()` compares values, not references
|
||||
2. **copy():** Create modified copies without mutation
|
||||
3. **toString():** Debugging-friendly output
|
||||
4. **hashCode():** Consistent hashing for collections
|
||||
5. **componentN():** Destructuring support
|
||||
|
||||
### copy() for Updates
|
||||
|
||||
**Mental model:** Instead of mutating, create modified copies.
|
||||
|
||||
```kotlin
|
||||
val status = RelayStatus(
|
||||
url = "wss://relay.damus.io",
|
||||
connected = false,
|
||||
error = null
|
||||
)
|
||||
|
||||
// Immutable update
|
||||
val updatedStatus = status.copy(connected = true)
|
||||
|
||||
// Original unchanged
|
||||
assert(status.connected == false)
|
||||
assert(updatedStatus.connected == true)
|
||||
```
|
||||
|
||||
**StateFlow pattern:**
|
||||
|
||||
```kotlin
|
||||
private val _relayStatuses = MutableStateFlow<Map<String, RelayStatus>>(emptyMap())
|
||||
|
||||
fun updateRelay(url: String, connected: Boolean) {
|
||||
_relayStatuses.value = _relayStatuses.value.mapValues { (key, status) ->
|
||||
if (key == url) {
|
||||
status.copy(connected = connected) // Immutable update
|
||||
} else {
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### All Properties in Constructor
|
||||
|
||||
**Why important for data classes:**
|
||||
|
||||
```kotlin
|
||||
// BAD: Properties outside constructor not included in equals/hashCode
|
||||
data class User(val name: String) {
|
||||
var age: Int = 0 // NOT in equals/hashCode/copy!
|
||||
}
|
||||
|
||||
val user1 = User("Alice")
|
||||
val user2 = User("Alice")
|
||||
user1.age = 25
|
||||
user2.age = 30
|
||||
|
||||
assert(user1 == user2) // TRUE! age not compared
|
||||
assert(user1.copy() == user1) // TRUE! age not copied
|
||||
|
||||
// GOOD: All properties in constructor
|
||||
@Immutable
|
||||
data class User(
|
||||
val name: String,
|
||||
val age: Int // Included in equals/hashCode/copy
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Immutable Collections
|
||||
|
||||
### kotlinx.collections.immutable
|
||||
|
||||
**Installation:**
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
|
||||
}
|
||||
```
|
||||
|
||||
**Why use:**
|
||||
- Structural sharing (efficient copies)
|
||||
- Explicit immutability (compiler enforced)
|
||||
- Safe for Compose state
|
||||
|
||||
### ImmutableList
|
||||
|
||||
```kotlin
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
// Create immutable list
|
||||
val relays: ImmutableList<String> = persistentListOf(
|
||||
"wss://relay1.com",
|
||||
"wss://relay2.com"
|
||||
)
|
||||
|
||||
// Add returns NEW list
|
||||
val updated = relays.add("wss://relay3.com")
|
||||
assert(relays.size == 2) // Original unchanged
|
||||
assert(updated.size == 3) // New list has 3 items
|
||||
|
||||
// Convert from regular list
|
||||
val mutableList = mutableListOf("a", "b", "c")
|
||||
val immutable = mutableList.toImmutableList()
|
||||
```
|
||||
|
||||
### ImmutableMap
|
||||
|
||||
```kotlin
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
|
||||
// Create immutable map
|
||||
val relayStatuses: ImmutableMap<String, RelayStatus> = persistentMapOf(
|
||||
"wss://relay1.com" to RelayStatus(...),
|
||||
"wss://relay2.com" to RelayStatus(...)
|
||||
)
|
||||
|
||||
// Put returns NEW map
|
||||
val updated = relayStatuses.put("wss://relay3.com", RelayStatus(...))
|
||||
|
||||
// Remove returns NEW map
|
||||
val removed = relayStatuses.remove("wss://relay1.com")
|
||||
```
|
||||
|
||||
### ImmutableSet
|
||||
|
||||
```kotlin
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
val connectedRelays: ImmutableSet<String> = persistentSetOf(
|
||||
"wss://relay1.com",
|
||||
"wss://relay2.com"
|
||||
)
|
||||
|
||||
val updated = connectedRelays.add("wss://relay3.com")
|
||||
```
|
||||
|
||||
### Structural Sharing
|
||||
|
||||
**Mental model:** Immutable collections reuse internal structure for efficiency.
|
||||
|
||||
```kotlin
|
||||
val list1 = persistentListOf(1, 2, 3, 4, 5) // 5 items
|
||||
val list2 = list1.add(6) // Shares structure with list1
|
||||
|
||||
// Internally:
|
||||
// list1 and list2 share nodes for items 1-5
|
||||
// list2 has one additional node for item 6
|
||||
// O(1) time, O(1) space for add operation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Immutable State Updates
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class FeedState(
|
||||
val events: ImmutableList<Event>,
|
||||
val loading: Boolean,
|
||||
val error: String?
|
||||
)
|
||||
|
||||
class FeedViewModel {
|
||||
private val _state = MutableStateFlow(
|
||||
FeedState(
|
||||
events = persistentListOf(),
|
||||
loading = false,
|
||||
error = null
|
||||
)
|
||||
)
|
||||
val state: StateFlow<FeedState> = _state.asStateFlow()
|
||||
|
||||
fun loadEvents() {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val events = repository.getEvents()
|
||||
_state.value = _state.value.copy(
|
||||
events = events.toImmutableList(),
|
||||
loading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addEvent(event: Event) {
|
||||
_state.value = _state.value.copy(
|
||||
events = _state.value.events.add(event) // Immutable add
|
||||
)
|
||||
}
|
||||
|
||||
fun removeEvent(eventId: String) {
|
||||
_state.value = _state.value.copy(
|
||||
events = _state.value.events.filter { it.id != eventId }.toImmutableList()
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Deep Immutability
|
||||
|
||||
```kotlin
|
||||
// Nested immutable structures
|
||||
@Immutable
|
||||
data class User(
|
||||
val name: String,
|
||||
val profile: Profile // Also immutable
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class Profile(
|
||||
val bio: String,
|
||||
val avatar: String,
|
||||
val relays: ImmutableList<String> // Immutable collection
|
||||
)
|
||||
|
||||
// Safe deep copy
|
||||
val user = User(
|
||||
name = "Alice",
|
||||
profile = Profile(
|
||||
bio = "Nostr enthusiast",
|
||||
avatar = "https://...",
|
||||
relays = persistentListOf("wss://relay1.com")
|
||||
)
|
||||
)
|
||||
|
||||
val updatedUser = user.copy(
|
||||
profile = user.profile.copy(
|
||||
bio = "Bitcoin & Nostr enthusiast" // Deep update
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern: Collection Builder to Immutable
|
||||
|
||||
```kotlin
|
||||
// Build mutable, convert to immutable
|
||||
fun processEvents(input: List<Event>): ImmutableList<Event> {
|
||||
val processed = mutableListOf<Event>()
|
||||
|
||||
for (event in input) {
|
||||
if (event.isValid()) {
|
||||
processed.add(event.normalize())
|
||||
}
|
||||
}
|
||||
|
||||
return processed.toImmutableList() // Convert once at end
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Immutable Map Updates
|
||||
|
||||
```kotlin
|
||||
private val _relayStatuses = MutableStateFlow<ImmutableMap<String, RelayStatus>>(
|
||||
persistentMapOf()
|
||||
)
|
||||
|
||||
fun updateRelay(url: String, connected: Boolean) {
|
||||
val currentStatuses = _relayStatuses.value
|
||||
val currentStatus = currentStatuses[url] ?: RelayStatus(url, false)
|
||||
|
||||
_relayStatuses.value = currentStatuses.put(
|
||||
url,
|
||||
currentStatus.copy(connected = connected)
|
||||
)
|
||||
}
|
||||
|
||||
fun removeRelay(url: String) {
|
||||
_relayStatuses.value = _relayStatuses.value.remove(url)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### Benchmarks (Approximate)
|
||||
|
||||
**Recomposition cost:**
|
||||
|
||||
```kotlin
|
||||
// 1000 items in LazyColumn
|
||||
// Without @Immutable: ~100ms per frame (skipped frames)
|
||||
// With @Immutable: ~16ms per frame (smooth 60fps)
|
||||
|
||||
@Immutable
|
||||
data class Item(val id: String, val name: String)
|
||||
|
||||
@Composable
|
||||
fun ItemList(items: ImmutableList<Item>) {
|
||||
LazyColumn {
|
||||
items(items, key = { it.id }) { item ->
|
||||
ItemRow(item) // Only recomposes when item changes
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Structural sharing efficiency:**
|
||||
|
||||
```kotlin
|
||||
val list1 = persistentListOf(1..10000)
|
||||
val list2 = list1.add(10001) // O(log n) time, shares structure
|
||||
|
||||
// Regular list (copy on modification):
|
||||
val mutableList = (1..10000).toMutableList()
|
||||
val copy = mutableList.toList() + 10001 // O(n) time, full copy
|
||||
```
|
||||
|
||||
### When to Use Immutable Collections
|
||||
|
||||
**Use ImmutableList/Map/Set when:**
|
||||
- Storing in Compose state (@Immutable class)
|
||||
- Sharing across coroutines
|
||||
- Frequent modifications (structural sharing efficient)
|
||||
- Need compile-time immutability guarantee
|
||||
|
||||
**Use Array when:**
|
||||
- Fixed size, no modifications
|
||||
- Nostr protocol (tags are `Array<Array<String>>`)
|
||||
- Performance-critical (array access is fastest)
|
||||
|
||||
**Use regular List/Map/Set when:**
|
||||
- Local scope only
|
||||
- Build once, read many times
|
||||
- Converting to immutable at boundary
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ Mutable Properties in @Immutable Class
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class BadEvent(
|
||||
val id: String,
|
||||
var content: String // BAD: var breaks immutability
|
||||
)
|
||||
```
|
||||
|
||||
### ✅ All val Properties
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class GoodEvent(
|
||||
val id: String,
|
||||
val content: String
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ Mutable Collections in @Immutable Class
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class BadState(
|
||||
val items: MutableList<Item> // BAD: Can mutate items
|
||||
)
|
||||
|
||||
// Caller can mutate:
|
||||
val state = BadState(mutableListOf())
|
||||
state.items.add(newItem) // Breaks immutability!
|
||||
```
|
||||
|
||||
### ✅ Immutable Collections
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class GoodState(
|
||||
val items: ImmutableList<Item>
|
||||
)
|
||||
|
||||
// Caller must create new state:
|
||||
val updated = state.copy(items = state.items.add(newItem))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ Direct Mutation
|
||||
|
||||
```kotlin
|
||||
val status = RelayStatus(url, connected = false)
|
||||
status.connected = true // Compile error (val)
|
||||
|
||||
// But could happen with mutable nested objects:
|
||||
@Immutable
|
||||
data class Config(
|
||||
val settings: Settings // If Settings is mutable...
|
||||
)
|
||||
|
||||
class Settings {
|
||||
var theme: String = "dark" // BAD
|
||||
}
|
||||
|
||||
val config = Config(Settings())
|
||||
config.settings.theme = "light" // Mutates "immutable" config!
|
||||
```
|
||||
|
||||
### ✅ Deep Immutability
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class Config(
|
||||
val settings: Settings
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class Settings(
|
||||
val theme: String // val only
|
||||
)
|
||||
|
||||
val config = Config(Settings("dark"))
|
||||
val updated = config.copy(
|
||||
settings = config.settings.copy(theme = "light")
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ Exposing Mutable Internal State
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
class BadViewModel {
|
||||
private val _items = mutableListOf<Item>()
|
||||
val items: List<Item> = _items // BAD: Exposes mutable list
|
||||
|
||||
fun addItem(item: Item) {
|
||||
_items.add(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Caller can cast and mutate:
|
||||
val vm = BadViewModel()
|
||||
(vm.items as MutableList).clear() // Breaks encapsulation!
|
||||
```
|
||||
|
||||
### ✅ Convert to Immutable at Boundary
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
class GoodViewModel {
|
||||
private val _items = mutableListOf<Item>()
|
||||
val items: ImmutableList<Item>
|
||||
get() = _items.toImmutableList() // GOOD: Copy to immutable
|
||||
|
||||
fun addItem(item: Item) {
|
||||
_items.add(item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist for Immutability
|
||||
|
||||
**For @Immutable classes:**
|
||||
- [ ] All properties are `val`, never `var`
|
||||
- [ ] No mutable collections (`MutableList`, `MutableMap`, `MutableSet`)
|
||||
- [ ] Nested objects are also `@Immutable` or primitives
|
||||
- [ ] No public mutable state
|
||||
- [ ] Use `copy()` for updates, never mutation
|
||||
- [ ] Arrays used only when truly immutable by contract
|
||||
|
||||
**For StateFlow state:**
|
||||
- [ ] State class is `@Immutable`
|
||||
- [ ] Use immutable collections (ImmutableList, ImmutableMap)
|
||||
- [ ] Create new instances for updates (`copy()`, `.add()`, `.put()`)
|
||||
- [ ] Never mutate state in-place
|
||||
|
||||
**For Compose performance:**
|
||||
- [ ] All `@Composable` parameters are `@Immutable` or `@Stable`
|
||||
- [ ] Lists use `ImmutableList` and `key` parameter in `items()`
|
||||
- [ ] Heavy objects (events, profiles) cached and reused
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- TextNoteEvent.kt:51-63 - @Immutable event example
|
||||
- RelayConnectionManager.kt - Immutable map updates
|
||||
- [Compose Performance | Android Developers](https://developer.android.com/jetpack/compose/performance/stability)
|
||||
- [kotlinx.collections.immutable | GitHub](https://github.com/Kotlin/kotlinx.collections.immutable)
|
||||
- [@Stable and @Immutable | Compose Docs](https://developer.android.com/jetpack/compose/performance/stability/fix)
|
||||
482
.claude/skills/kotlin-expert/references/sealed-class-catalog.md
Normal file
482
.claude/skills/kotlin-expert/references/sealed-class-catalog.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# Sealed Class Catalog
|
||||
|
||||
Comprehensive list of sealed types in AmethystMultiplatform with usage patterns.
|
||||
|
||||
## Table of Contents
|
||||
- [State Management](#state-management)
|
||||
- [Result Types](#result-types)
|
||||
- [Tag Variants](#tag-variants)
|
||||
- [Sealed Class vs Sealed Interface](#sealed-class-vs-sealed-interface)
|
||||
- [Patterns](#patterns)
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### AccountState (Sealed Class)
|
||||
|
||||
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-46`
|
||||
|
||||
```kotlin
|
||||
sealed class AccountState {
|
||||
data object LoggedOut : AccountState()
|
||||
|
||||
data class LoggedIn(
|
||||
val signer: NostrSigner,
|
||||
val pubKeyHex: String,
|
||||
val npub: String,
|
||||
val nsec: String?,
|
||||
val isReadOnly: Boolean
|
||||
) : AccountState()
|
||||
}
|
||||
```
|
||||
|
||||
**Why sealed class:**
|
||||
- Two distinct states with different data
|
||||
- `LoggedIn` holds data, `LoggedOut` doesn't
|
||||
- No need for generics or multiple inheritance
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
fun handleAccountState(state: AccountState) {
|
||||
when (state) {
|
||||
is AccountState.LoggedOut -> showLogin()
|
||||
is AccountState.LoggedIn -> {
|
||||
showFeed(
|
||||
pubkey = state.pubKeyHex,
|
||||
canSign = !state.isReadOnly
|
||||
)
|
||||
}
|
||||
} // Exhaustive - compiler enforces
|
||||
}
|
||||
```
|
||||
|
||||
### VerificationState (Sealed Class)
|
||||
|
||||
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt`
|
||||
|
||||
```kotlin
|
||||
sealed class VerificationState {
|
||||
data object NotStarted : VerificationState()
|
||||
data object Started : VerificationState()
|
||||
data class Failed(val reason: String) : VerificationState()
|
||||
data object Verified : VerificationState()
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
- State machine (NotStarted → Started → Failed/Verified)
|
||||
- Only `Failed` carries data (reason)
|
||||
- Rest are singletons (`data object`)
|
||||
|
||||
---
|
||||
|
||||
## Result Types
|
||||
|
||||
### SignerResult (Sealed Interface with Generics)
|
||||
|
||||
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt:25-46`
|
||||
|
||||
```kotlin
|
||||
sealed interface SignerResult<T : IResult> {
|
||||
sealed interface RequestAddressed<T : IResult> : SignerResult<T> {
|
||||
class Successful<T : IResult>(val result: T) : RequestAddressed<T>
|
||||
class Rejected<T : IResult> : RequestAddressed<T>
|
||||
class TimedOut<T : IResult> : RequestAddressed<T>
|
||||
class ReceivedButCouldNotPerform<T : IResult>(
|
||||
val message: String? = null
|
||||
) : RequestAddressed<T>
|
||||
class ReceivedButCouldNotParseEventFromResult<T : IResult>(
|
||||
val eventJson: String
|
||||
) : RequestAddressed<T>
|
||||
class ReceivedButCouldNotVerifyResultingEvent<T : IResult>(
|
||||
val invalidEvent: Event
|
||||
) : RequestAddressed<T>
|
||||
}
|
||||
}
|
||||
|
||||
interface IResult
|
||||
|
||||
data class SignResult(val event: Event) : IResult
|
||||
data class EncryptionResult(val ciphertext: String) : IResult
|
||||
data class DecryptionResult(val plaintext: String) : IResult
|
||||
```
|
||||
|
||||
**Why sealed interface:**
|
||||
- Generic result type `<T : IResult>`
|
||||
- Nested sealed hierarchy (RequestAddressed)
|
||||
- Need covariance for flexible result types
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
suspend fun signEvent(event: Event): SignerResult<SignResult> {
|
||||
return when (val result = remoteSigner.sign(event)) {
|
||||
is SignerResult.RequestAddressed.Successful -> result
|
||||
is SignerResult.RequestAddressed.Rejected -> {
|
||||
logger.warn("Signing rejected")
|
||||
result
|
||||
}
|
||||
is SignerResult.RequestAddressed.TimedOut -> {
|
||||
logger.error("Signing timed out")
|
||||
result
|
||||
}
|
||||
is SignerResult.RequestAddressed.ReceivedButCouldNotPerform -> {
|
||||
logger.error("Signer error: ${result.message}")
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CacheResults (Sealed Class with Generics)
|
||||
|
||||
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt`
|
||||
|
||||
```kotlin
|
||||
sealed class CacheResults<T> {
|
||||
data class Found<T>(val value: T) : CacheResults<T>()
|
||||
class NotFound<T> : CacheResults<T>()
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
- Simple binary result (found/not found)
|
||||
- `Found` carries data, `NotFound` doesn't
|
||||
- Generic for reusability
|
||||
|
||||
---
|
||||
|
||||
## Tag Variants
|
||||
|
||||
### MuteTag (Sealed Class)
|
||||
|
||||
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt`
|
||||
|
||||
```kotlin
|
||||
sealed class MuteTag(
|
||||
val nameOrNull: String?,
|
||||
val valueOrNull: String?
|
||||
) {
|
||||
class Event(eventId: String) : MuteTag("e", eventId)
|
||||
class Profile(pubkey: String) : MuteTag("p", pubkey)
|
||||
class Word(word: String) : MuteTag("word", word)
|
||||
class Thread(threadId: String) : MuteTag("thread", threadId)
|
||||
|
||||
companion object {
|
||||
fun parse(tag: Array<String>): MuteTag? {
|
||||
return when (tag.getOrNull(0)) {
|
||||
"e" -> tag.getOrNull(1)?.let { Event(it) }
|
||||
"p" -> tag.getOrNull(1)?.let { Profile(it) }
|
||||
"word" -> tag.getOrNull(1)?.let { Word(it) }
|
||||
"thread" -> tag.getOrNull(1)?.let { Thread(it) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toArray(): Array<String> {
|
||||
return arrayOf(nameOrNull ?: "", valueOrNull ?: "")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
- Common base class with shared properties
|
||||
- Each variant represents different tag type
|
||||
- Factory method `parse()` for parsing
|
||||
- `toArray()` for serialization
|
||||
|
||||
### BookmarkIdTag (Sealed Class)
|
||||
|
||||
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt`
|
||||
|
||||
```kotlin
|
||||
sealed class BookmarkIdTag {
|
||||
abstract val id: String
|
||||
abstract val marker: String?
|
||||
|
||||
data class Event(override val id: String, override val marker: String?) : BookmarkIdTag()
|
||||
data class Profile(override val id: String, override val marker: String?) : BookmarkIdTag()
|
||||
data class Address(override val id: String, override val marker: String?) : BookmarkIdTag()
|
||||
|
||||
companion object {
|
||||
fun parse(tag: Array<String>): BookmarkIdTag? {
|
||||
val marker = tag.getOrNull(3)
|
||||
return when (tag.getOrNull(0)) {
|
||||
"e" -> tag.getOrNull(1)?.let { Event(it, marker) }
|
||||
"p" -> tag.getOrNull(1)?.let { Profile(it, marker) }
|
||||
"a" -> tag.getOrNull(1)?.let { Address(it, marker) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
- Abstract properties in sealed class
|
||||
- Data classes implement abstract properties
|
||||
- Parse factory returns sealed variant
|
||||
|
||||
---
|
||||
|
||||
## Exception Hierarchies
|
||||
|
||||
### SignerExceptions (Sealed Class)
|
||||
|
||||
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt`
|
||||
|
||||
```kotlin
|
||||
sealed class SignerExceptions(message: String) : Exception(message) {
|
||||
class UnableToSign(message: String) : SignerExceptions(message)
|
||||
class UnableToDecrypt(message: String) : SignerExceptions(message)
|
||||
class UnableToEncrypt(message: String) : SignerExceptions(message)
|
||||
class UnableToGetPublicKey(message: String) : SignerExceptions(message)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
- Sealed exception hierarchy
|
||||
- Extends `Exception` base class
|
||||
- Type-safe error handling
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
try {
|
||||
signer.sign(event)
|
||||
} catch (e: SignerExceptions) {
|
||||
when (e) {
|
||||
is SignerExceptions.UnableToSign -> logger.error("Signing failed: ${e.message}")
|
||||
is SignerExceptions.UnableToDecrypt -> logger.error("Decryption failed: ${e.message}")
|
||||
is SignerExceptions.UnableToEncrypt -> logger.error("Encryption failed: ${e.message}")
|
||||
is SignerExceptions.UnableToGetPublicKey -> logger.error("No public key: ${e.message}")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sealed Class vs Sealed Interface
|
||||
|
||||
### When to Use Sealed Class
|
||||
|
||||
**Examples from codebase:**
|
||||
|
||||
1. **AccountState** - State variants with different data
|
||||
2. **VerificationState** - State machine
|
||||
3. **MuteTag** - Tag variants with common base properties
|
||||
4. **SignerExceptions** - Exception hierarchy
|
||||
|
||||
**Characteristics:**
|
||||
- Need common constructor parameters
|
||||
- Single inheritance only
|
||||
- State variants
|
||||
- Exception hierarchies
|
||||
|
||||
### When to Use Sealed Interface
|
||||
|
||||
**Examples from codebase:**
|
||||
|
||||
1. **SignerResult<T>** - Generic result types needing variance
|
||||
2. **RelayUrlNormalizer.Result** - Binary result with no shared state
|
||||
|
||||
**Characteristics:**
|
||||
- Need generics with variance (`out`, `in`)
|
||||
- No common state needed
|
||||
- Multiple inheritance possible
|
||||
- Contract/capability representation
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
|
||||
### Pattern: State Machine
|
||||
|
||||
```kotlin
|
||||
sealed class ConnectionState {
|
||||
data object Disconnected : ConnectionState()
|
||||
data object Connecting : ConnectionState()
|
||||
data class Connected(val relay: String) : ConnectionState()
|
||||
data class Failed(val error: String) : ConnectionState()
|
||||
}
|
||||
|
||||
// Allowed transitions
|
||||
fun transition(from: ConnectionState, event: Event): ConnectionState {
|
||||
return when (from) {
|
||||
is ConnectionState.Disconnected -> {
|
||||
when (event) {
|
||||
is Event.Connect -> ConnectionState.Connecting
|
||||
else -> from
|
||||
}
|
||||
}
|
||||
is ConnectionState.Connecting -> {
|
||||
when (event) {
|
||||
is Event.Success -> ConnectionState.Connected(event.relay)
|
||||
is Event.Error -> ConnectionState.Failed(event.message)
|
||||
is Event.Cancel -> ConnectionState.Disconnected
|
||||
else -> from
|
||||
}
|
||||
}
|
||||
is ConnectionState.Connected -> {
|
||||
when (event) {
|
||||
is Event.Disconnect -> ConnectionState.Disconnected
|
||||
is Event.Error -> ConnectionState.Failed(event.message)
|
||||
else -> from
|
||||
}
|
||||
}
|
||||
is ConnectionState.Failed -> {
|
||||
when (event) {
|
||||
is Event.Retry -> ConnectionState.Connecting
|
||||
is Event.Cancel -> ConnectionState.Disconnected
|
||||
else -> from
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Result Type
|
||||
|
||||
```kotlin
|
||||
sealed interface Result<out T> {
|
||||
data class Success<T>(val data: T) : Result<T>
|
||||
data class Error(val exception: Exception) : Result<Nothing>
|
||||
data object Loading : Result<Nothing>
|
||||
}
|
||||
|
||||
// Extension functions
|
||||
fun <T> Result<T>.getOrNull(): T? = when (this) {
|
||||
is Result.Success -> data
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun <T> Result<T>.getOrThrow(): T = when (this) {
|
||||
is Result.Success -> data
|
||||
is Result.Error -> throw exception
|
||||
is Result.Loading -> error("Still loading")
|
||||
}
|
||||
|
||||
fun <T, R> Result<T>.map(transform: (T) -> R): Result<R> = when (this) {
|
||||
is Result.Success -> Result.Success(transform(data))
|
||||
is Result.Error -> this
|
||||
is Result.Loading -> Result.Loading
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Tagged Union (Discriminated Union)
|
||||
|
||||
```kotlin
|
||||
sealed class Command {
|
||||
data class SendEvent(val event: Event) : Command()
|
||||
data class Subscribe(val filters: List<Filter>) : Command()
|
||||
data class Unsubscribe(val subId: String) : Command()
|
||||
data object Close : Command()
|
||||
|
||||
fun toJson(): String = when (this) {
|
||||
is SendEvent -> """["EVENT",${event.toJson()}]"""
|
||||
is Subscribe -> """["REQ","sub",${filters.joinToString { it.toJson() }}]"""
|
||||
is Unsubscribe -> """["CLOSE","$subId"]"""
|
||||
is Close -> """["CLOSE"]"""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Nested Sealed Hierarchies
|
||||
|
||||
```kotlin
|
||||
sealed interface UiState {
|
||||
sealed interface Loading : UiState {
|
||||
data object Initial : Loading
|
||||
data class Refreshing(val currentData: List<Item>) : Loading
|
||||
}
|
||||
|
||||
sealed interface Content : UiState {
|
||||
data class Success(val data: List<Item>) : Content
|
||||
data object Empty : Content
|
||||
}
|
||||
|
||||
sealed interface Error : UiState {
|
||||
data class Network(val message: String) : Error
|
||||
data class Server(val code: Int, val message: String) : Error
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
fun renderUi(state: UiState) {
|
||||
when (state) {
|
||||
is UiState.Loading.Initial -> showFullScreenLoader()
|
||||
is UiState.Loading.Refreshing -> showRefreshIndicator(state.currentData)
|
||||
is UiState.Content.Success -> showList(state.data)
|
||||
is UiState.Content.Empty -> showEmptyState()
|
||||
is UiState.Error.Network -> showNetworkError(state.message)
|
||||
is UiState.Error.Server -> showServerError(state.code, state.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## All Sealed Types in Quartz
|
||||
|
||||
**Complete list of sealed types found in codebase:**
|
||||
|
||||
### Commons
|
||||
- AccountState (class)
|
||||
|
||||
### Quartz
|
||||
- BaseZapSplitSetup (class)
|
||||
- MuteTag (class)
|
||||
- BookmarkIdTag (class)
|
||||
- SignerResult (interface)
|
||||
- VerificationState (class)
|
||||
- CacheResults (class)
|
||||
- SignerExceptions (class)
|
||||
- RelayUrlNormalizer.Result (interface)
|
||||
|
||||
**Total:** 8 sealed types (7 classes, 1 interface)
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Need to represent variants of a concept?
|
||||
YES → Use sealed type
|
||||
NO → Regular class/interface
|
||||
|
||||
Variants have different data?
|
||||
YES → sealed class or sealed interface
|
||||
NO → enum (if simple constants)
|
||||
|
||||
Need generics with variance (out/in)?
|
||||
YES → sealed interface
|
||||
NO → sealed class (simpler)
|
||||
|
||||
Need common constructor/properties?
|
||||
YES → sealed class
|
||||
NO → sealed interface
|
||||
|
||||
Need multiple inheritance?
|
||||
YES → sealed interface
|
||||
NO → Either works
|
||||
|
||||
Representing state machine?
|
||||
→ sealed class (state transitions)
|
||||
|
||||
Representing result/error types?
|
||||
→ sealed interface (if generic, else class)
|
||||
|
||||
Representing tag/command variants?
|
||||
→ sealed class (common structure)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html)
|
||||
- [Effective Kotlin: Sealed Classes](https://kt.academy/article/ek-sealed-classes)
|
||||
- [Complete Guide: Sealed Classes & Interfaces 2025](https://proandroiddev.com/complete-technical-guide-sealed-classes-sealed-interfaces-enums-in-kotlin-28ffc39116df)
|
||||
402
.claude/skills/kotlin-multiplatform/SKILL.md
Normal file
402
.claude/skills/kotlin-multiplatform/SKILL.md
Normal file
@@ -0,0 +1,402 @@
|
||||
---
|
||||
name: kotlin-multiplatform
|
||||
description: |
|
||||
Platform abstraction decision-making for Amethyst KMP project. Guides when to abstract vs keep platform-specific,
|
||||
source set placement (commonMain, jvmAndroid, platform-specific), expect/actual patterns. Covers primary targets
|
||||
(Android, JVM/Desktop, iOS) with web/wasm future considerations. Integrates with gradle-expert for dependency issues.
|
||||
Triggers on: abstraction decisions ("should I share this?"), source set placement questions, expect/actual creation,
|
||||
build.gradle.kts work, incorrect placement detection, KMP dependency suggestions.
|
||||
---
|
||||
|
||||
# Kotlin Multiplatform: Platform Abstraction Decisions
|
||||
|
||||
Expert guidance for KMP architecture in Amethyst - deciding what to share vs keep platform-specific.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Making platform abstraction decisions:
|
||||
- "Should I create expect/actual or keep Android-only?"
|
||||
- "Can I share this ViewModel logic?"
|
||||
- "Where does this crypto/JSON/network implementation belong?"
|
||||
- "This uses Android Context - can it be abstracted?"
|
||||
- "Is this code in the wrong module?"
|
||||
- Preparing for iOS/web/wasm targets
|
||||
- Detecting incorrect placements
|
||||
|
||||
## Abstraction Decision Tree
|
||||
|
||||
**Central question:** "Should this code be reused across platforms?"
|
||||
|
||||
Follow this decision path (< 1 minute):
|
||||
|
||||
```
|
||||
Q: Is it used by 2+ platforms?
|
||||
├─ NO → Keep platform-specific
|
||||
│ Example: Android-only permission handling
|
||||
│
|
||||
└─ YES → Continue ↓
|
||||
|
||||
Q: Is it pure Kotlin (no platform APIs)?
|
||||
├─ YES → commonMain
|
||||
│ Example: Nostr event parsing, business rules
|
||||
│
|
||||
└─ NO → Continue ↓
|
||||
|
||||
Q: Does it vary by platform or by JVM vs non-JVM?
|
||||
├─ By platform (Android ≠ iOS ≠ Desktop)
|
||||
│ → expect/actual
|
||||
│ Example: Secp256k1Instance (uses different security APIs)
|
||||
│
|
||||
├─ By JVM (Android = Desktop ≠ iOS/web)
|
||||
│ → jvmAndroid
|
||||
│ Example: Jackson JSON parsing (JVM library)
|
||||
│
|
||||
└─ Complex/UI-related
|
||||
→ Keep platform-specific
|
||||
Example: Navigation (Activity vs Window too different)
|
||||
|
||||
Final check:
|
||||
Q: Maintenance cost of abstraction < duplication cost?
|
||||
├─ YES → Proceed with abstraction
|
||||
└─ NO → Duplicate (simpler)
|
||||
```
|
||||
|
||||
### Real Examples from Codebase
|
||||
|
||||
**Crypto → expect/actual:**
|
||||
```kotlin
|
||||
// commonMain - expect declaration
|
||||
expect object Secp256k1Instance {
|
||||
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
// androidMain - uses Android Keystore
|
||||
// jvmMain - uses Desktop JVM crypto
|
||||
// iosMain - uses iOS Security framework
|
||||
```
|
||||
**Why:** Each platform has different security APIs.
|
||||
|
||||
**JSON parsing → jvmAndroid:**
|
||||
```kotlin
|
||||
// quartz/build.gradle.kts
|
||||
val jvmAndroid = create("jvmAndroid") {
|
||||
api(libs.jackson.module.kotlin)
|
||||
}
|
||||
```
|
||||
**Why:** Jackson is JVM-only, works on Android + Desktop, not iOS/web.
|
||||
|
||||
**Navigation → platform-specific:**
|
||||
- Android: `MainActivity` (Activity + Compose Navigation)
|
||||
- Desktop: `Window` + sidebar + MenuBar
|
||||
**Why:** UI paradigms fundamentally different.
|
||||
|
||||
## Mental Model: Source Sets as Dependency Graph
|
||||
|
||||
Think of source sets as a dependency graph, not folders.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ commonMain = Contract (pure Kotlin) │
|
||||
│ - Business logic, protocol, data models │
|
||||
│ - No platform APIs │
|
||||
└────────────┬────────────────────────────────┘
|
||||
│
|
||||
├──────────────────────┬────────────────────
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌──────────────────┐
|
||||
│ jvmAndroid │ │ iosMain │
|
||||
│ JVM libs shared │ │ iOS common │
|
||||
│ - Jackson │ │ │
|
||||
│ - OkHttp │ └────┬─────────────┘
|
||||
└───┬───────────┬───┘ │
|
||||
│ │ │
|
||||
▼ ▼ ├─→ iosArm64Main
|
||||
┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main
|
||||
│android │ │jvmMain │
|
||||
│Main │ │(Desktop) │
|
||||
└─────────┘ └──────────┘
|
||||
|
||||
Future: jsMain, wasmMain
|
||||
```
|
||||
|
||||
**Key insight:** jvmAndroid is NOT a platform - it's a shared JVM layer.
|
||||
|
||||
## The jvmAndroid Pattern
|
||||
|
||||
**Unique to Amethyst.** Shares JVM libraries between Android + Desktop.
|
||||
|
||||
### When to Use jvmAndroid
|
||||
|
||||
Use jvmAndroid when:
|
||||
- ✅ JVM-specific libraries (Jackson, OkHttp, url-detector)
|
||||
- ✅ Android implementation = Desktop implementation (same JVM)
|
||||
- ✅ Library doesn't work on iOS/web
|
||||
|
||||
Do NOT use jvmAndroid for:
|
||||
- ❌ Pure Kotlin code (use commonMain)
|
||||
- ❌ Platform-specific APIs (use androidMain/jvmMain)
|
||||
- ❌ Code that should work on all platforms
|
||||
|
||||
### Example from quartz/build.gradle.kts
|
||||
|
||||
```kotlin
|
||||
// Must be defined BEFORE androidMain and jvmMain
|
||||
val jvmAndroid = create("jvmAndroid") {
|
||||
dependsOn(commonMain.get())
|
||||
|
||||
dependencies {
|
||||
api(libs.jackson.module.kotlin) // JSON parsing - JVM only
|
||||
api(libs.url.detector) // URL extraction - JVM only
|
||||
implementation(libs.okhttp) // HTTP client - JVM only
|
||||
}
|
||||
}
|
||||
|
||||
// Both depend on jvmAndroid
|
||||
jvmMain { dependsOn(jvmAndroid) }
|
||||
androidMain { dependsOn(jvmAndroid) }
|
||||
```
|
||||
|
||||
**Why Jackson in jvmAndroid, not commonMain?**
|
||||
- Jackson is JVM-specific library
|
||||
- Works on Android (runs on JVM)
|
||||
- Works on Desktop (runs on JVM)
|
||||
- Does NOT work on iOS (not JVM) or web (not JVM)
|
||||
|
||||
**Web/wasm consideration:** For future web support, consider migrating from Jackson → kotlinx.serialization (see Target-Specific Guidance).
|
||||
|
||||
## What to Abstract vs Keep Platform-Specific
|
||||
|
||||
Quick decision guidelines based on codebase patterns:
|
||||
|
||||
### Always Abstract
|
||||
- **Crypto** (Secp256k1, encryption, signing)
|
||||
- **Core protocol logic** (Nostr events, NIPs)
|
||||
- **Why:** Needed everywhere, platform security APIs vary
|
||||
|
||||
### Often Abstract
|
||||
- **I/O operations** (file reading, caching)
|
||||
- **Logging** (platform logging systems differ)
|
||||
- **Serialization** (if using kotlinx.serialization)
|
||||
- **Why:** Commonly reused, platform implementations available
|
||||
|
||||
### Sometimes Abstract
|
||||
- **Business logic:** YES - state machines, data processing
|
||||
- **ViewModels:** YES - state + business logic shareable (StateFlow/SharedFlow)
|
||||
- **Screen layouts:** NO - platform-native (Window vs Activity)
|
||||
- **Why:** ViewModels contain platform-agnostic state; Screens render differently per platform
|
||||
|
||||
### Rarely Abstract
|
||||
- **Complex UI components** (composables with heavy platform dependencies)
|
||||
- **Why:** Platform paradigms can differ significantly
|
||||
|
||||
### Never Abstract
|
||||
- **Navigation** (Activity vs Window fundamentally different)
|
||||
- **Permissions** (Android vs iOS APIs incompatible)
|
||||
- **Platform UX patterns**
|
||||
- **Why:** Too platform-specific, abstraction creates leaky APIs
|
||||
|
||||
### Evidence from shared-ui-analysis.md
|
||||
|
||||
| Component | Shared? | Rationale |
|
||||
|-----------|---------|-----------|
|
||||
| PubKeyFormatter, ZapFormatter | ✅ YES | Pure Kotlin, no platform APIs |
|
||||
| TimeAgoFormatter | ⚠️ ABSTRACTED | Needs StringProvider for localized strings |
|
||||
| ViewModels (state + logic) | ✅ YES | StateFlow/SharedFlow platform-agnostic, Compose Multiplatform lifecycle compatible |
|
||||
| Screen layouts (Scaffold, nav) | ❌ NO | Window vs Activity, sidebar vs bottom nav fundamentally different |
|
||||
| Image loading (Coil) | ⚠️ ABSTRACTED | Coil 3.x supports KMP, needs expect/actual wrapper |
|
||||
|
||||
## expect/actual Mechanics
|
||||
|
||||
**When to use:** Code needed by 2+ platforms, varies by platform.
|
||||
|
||||
### Pattern Categories from Codebase
|
||||
|
||||
**Objects (singletons):**
|
||||
```kotlin
|
||||
// 24 expect declarations found, common pattern:
|
||||
expect object Secp256k1Instance { ... }
|
||||
expect object Log { ... }
|
||||
expect object LibSodiumInstance { ... }
|
||||
```
|
||||
|
||||
**Classes (instantiable):**
|
||||
```kotlin
|
||||
expect class AESCBC { ... }
|
||||
expect class DigestInstance { ... }
|
||||
```
|
||||
|
||||
**Functions (utilities):**
|
||||
```kotlin
|
||||
expect fun platform(): String
|
||||
expect fun currentTimeSeconds(): Long
|
||||
```
|
||||
|
||||
**See** [references/expect-actual-catalog.md](references/expect-actual-catalog.md) for complete catalog with rationale.
|
||||
|
||||
## Target-Specific Guidance
|
||||
|
||||
### Android, JVM (Desktop), iOS - Current Primary Targets
|
||||
|
||||
**Status:** Mature patterns, stable APIs
|
||||
|
||||
**Android (androidMain):**
|
||||
- Uses Android framework (Activity, Context, etc.)
|
||||
- secp256k1-kmp-jni-android for crypto
|
||||
- AndroidX libraries
|
||||
|
||||
**Desktop JVM (jvmMain):**
|
||||
- Uses Compose Desktop (Window, MenuBar, etc.)
|
||||
- secp256k1-kmp-jni-jvm for crypto
|
||||
- Pure JVM libraries
|
||||
|
||||
**iOS (iosMain):**
|
||||
- Active development, framework configured
|
||||
- Architecture targets: macosArm64Main, iosArm64Main, iosSimulatorArm64Main
|
||||
- Platform APIs via platform.posix, Security framework
|
||||
|
||||
### Web, wasm - Future Targets
|
||||
|
||||
**Status:** Not yet implemented, consider for future-proofing
|
||||
|
||||
**Constraints to know:**
|
||||
- ❌ No platform.posix (file I/O different)
|
||||
- ❌ No JVM libraries (Jackson, OkHttp won't work)
|
||||
- ❌ Different async model (JS event loop vs threads)
|
||||
|
||||
**Future-proofing tips:**
|
||||
1. Prefer pure Kotlin in commonMain
|
||||
2. Use kotlinx.* libraries:
|
||||
- kotlinx.serialization instead of Jackson
|
||||
- ktor instead of OkHttp (ktor supports web)
|
||||
- kotlinx.datetime instead of custom date handling
|
||||
3. Avoid platform.posix for file operations
|
||||
4. Test abstractions work without JVM assumptions
|
||||
|
||||
**Example migration path:**
|
||||
```kotlin
|
||||
// Current: jvmAndroid (JVM-only)
|
||||
api(libs.jackson.module.kotlin)
|
||||
|
||||
// Future: commonMain (all platforms)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
```
|
||||
|
||||
## Integration: When to Invoke Other Skills
|
||||
|
||||
### Invoke gradle-expert
|
||||
|
||||
Trigger gradle-expert skill when encountering:
|
||||
- Dependency conflicts (e.g., secp256k1-android vs secp256k1-jvm version mismatch)
|
||||
- Build errors related to source sets
|
||||
- Version catalog issues (libs.versions.toml)
|
||||
- "Duplicate class" errors
|
||||
- Performance/build time issues
|
||||
|
||||
**Example trigger:**
|
||||
```
|
||||
Error: Duplicate class found: fr.acinq.secp256k1.Secp256k1
|
||||
```
|
||||
→ Invoke gradle-expert for dependency conflict resolution.
|
||||
|
||||
### Flags to Raise
|
||||
|
||||
**Platform code in commonMain:**
|
||||
```kotlin
|
||||
// ❌ INCORRECT - Android API in commonMain
|
||||
expect fun getContext(): Context // Context is Android-only!
|
||||
```
|
||||
→ Flag: "Android API in commonMain won't compile on other platforms"
|
||||
|
||||
**Duplicated business logic:**
|
||||
```kotlin
|
||||
// ❌ INCORRECT - Same logic in both
|
||||
// androidMain/.../CryptoUtils.kt
|
||||
fun validateSignature(...) { ... }
|
||||
|
||||
// jvmMain/.../CryptoUtils.kt
|
||||
fun validateSignature(...) { ... } // Duplicated!
|
||||
```
|
||||
→ Flag: "Business logic duplicated, should be in commonMain or expect/actual"
|
||||
|
||||
**Reinventing wheel - suggest KMP alternatives:**
|
||||
- Custom date/time → kotlinx.datetime
|
||||
- OkHttp → ktor (supports web)
|
||||
- Jackson → kotlinx.serialization
|
||||
- Custom UUID → kotlinx.uuid (when stable)
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### 1. Over-Abstraction
|
||||
**Problem:** Creating expect/actual for UI components
|
||||
```kotlin
|
||||
// ❌ BAD
|
||||
expect fun NavigationComponent(...)
|
||||
```
|
||||
**Why:** Navigation paradigms too different (Activity vs Window)
|
||||
**Fix:** Keep platform-specific, accept duplication
|
||||
|
||||
### 2. Under-Sharing
|
||||
**Problem:** Duplicating business logic across platforms
|
||||
```kotlin
|
||||
// ❌ BAD - duplicated in androidMain and jvmMain
|
||||
fun parseNostrEvent(json: String): Event { ... }
|
||||
```
|
||||
**Why:** Bug fixes need to be applied twice, tests duplicated
|
||||
**Fix:** Move to commonMain (pure Kotlin) or create expect/actual
|
||||
|
||||
### 3. Leaky Abstractions
|
||||
**Problem:** Platform code in commonMain
|
||||
```kotlin
|
||||
// commonMain - ❌ BAD
|
||||
import android.content.Context // Won't compile on iOS!
|
||||
```
|
||||
**Fix:** Use expect/actual or dependency injection
|
||||
|
||||
### 4. Premature Abstraction
|
||||
**Problem:** Creating expect/actual before second platform needs it
|
||||
```kotlin
|
||||
// ❌ BAD - only used on Android currently
|
||||
expect fun showNotification(...)
|
||||
```
|
||||
**Why:** Wrong abstraction boundaries, wasted effort
|
||||
**Fix:** Wait until iOS actually needs it, then abstract
|
||||
|
||||
### 5. Wrong Source Set
|
||||
**Problem:** JVM libraries in commonMain
|
||||
```kotlin
|
||||
// commonMain - ❌ BAD
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
```
|
||||
**Why:** Jackson won't compile on iOS/web
|
||||
**Fix:** Move to jvmAndroid or migrate to kotlinx.serialization
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Code Type | Recommended Location | Reason |
|
||||
|-----------|---------------------|--------|
|
||||
| Pure Kotlin business logic | commonMain | Works everywhere |
|
||||
| Nostr protocol, NIPs | commonMain | Core logic, no platform APIs |
|
||||
| JVM libs (Jackson, OkHttp) | jvmAndroid | Android + Desktop only |
|
||||
| Crypto (varies by platform) | expect in commonMain, actual in platforms | Different security APIs per platform |
|
||||
| I/O, logging | expect in commonMain, actual in platforms | Platform implementations differ |
|
||||
| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns |
|
||||
| **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** |
|
||||
| UI formatters (pure) | commons/commonMain | Reusable, no dependencies |
|
||||
| UI components (simple) | commons/commonMain | Cards, buttons, dialogs |
|
||||
| **Screen layouts** | **Platform-specific** | **Window vs Activity, sidebar vs bottom nav** |
|
||||
| Navigation | Platform-specific only | Activity vs Window too different |
|
||||
| Permissions | Platform-specific only | APIs incompatible |
|
||||
| Platform UX (menus, etc.) | Platform-specific only | Native feel required |
|
||||
|
||||
## See Also
|
||||
|
||||
- [references/abstraction-examples.md](references/abstraction-examples.md) - Good/bad abstraction examples with rationale
|
||||
- [references/source-set-hierarchy.md](references/source-set-hierarchy.md) - Visual hierarchy with Amethyst examples
|
||||
- [references/expect-actual-catalog.md](references/expect-actual-catalog.md) - All 24 expect/actual pairs with "why abstracted"
|
||||
- [references/target-compatibility.md](references/target-compatibility.md) - Platform constraints and future-proofing
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/validate-kmp-structure.sh` - Detect incorrect placements, validate source sets
|
||||
- `scripts/suggest-kmp-dependency.sh` - Suggest KMP library alternatives (ktor, kotlinx.serialization, etc.)
|
||||
@@ -0,0 +1,311 @@
|
||||
# Abstraction Examples from Amethyst Codebase
|
||||
|
||||
Real examples of abstraction decisions with rationale.
|
||||
|
||||
## Good Abstractions (Why They Work)
|
||||
|
||||
### 1. Secp256k1Instance - Crypto Signing
|
||||
|
||||
**Location:** expect in commonMain, actual in androidMain/jvmMain/iosMain
|
||||
|
||||
**Code:**
|
||||
```kotlin
|
||||
// quartz/src/commonMain/.../Secp256k1Instance.kt
|
||||
expect object Secp256k1Instance {
|
||||
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
|
||||
fun verifySchnorr(signature: ByteArray, hash: ByteArray, pubKey: ByteArray): Boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Why abstracted:**
|
||||
- Used by all platforms (Android, Desktop, iOS)
|
||||
- Security APIs fundamentally different:
|
||||
- Android: secp256k1-kmp-jni-android (Android Keystore integration)
|
||||
- Desktop: secp256k1-kmp-jni-jvm (pure JVM crypto)
|
||||
- iOS: Native Security framework
|
||||
- Core protocol requirement (Nostr signatures)
|
||||
|
||||
**Decision rationale:** Always abstract crypto - varies by platform security APIs, critical for all platforms.
|
||||
|
||||
---
|
||||
|
||||
### 2. Log - Platform Logging
|
||||
|
||||
**Location:** expect object in commonMain
|
||||
|
||||
**Code:**
|
||||
```kotlin
|
||||
// quartz/src/commonMain/.../Log.kt
|
||||
expect object Log {
|
||||
fun d(tag: String, message: String)
|
||||
fun w(tag: String, message: String, throwable: Throwable?)
|
||||
fun e(tag: String, message: String, throwable: Throwable?)
|
||||
}
|
||||
```
|
||||
|
||||
**Why abstracted:**
|
||||
- Used throughout quartz module (protocol library)
|
||||
- Logging systems differ:
|
||||
- Android: android.util.Log
|
||||
- Desktop: println or logging framework
|
||||
- iOS: NSLog or OSLog
|
||||
- Simple interface, easy to implement
|
||||
|
||||
**Decision rationale:** Often abstract logging - platform systems differ, widely used, simple interface.
|
||||
|
||||
---
|
||||
|
||||
### 3. Platform Utils - Time & Platform Name
|
||||
|
||||
**Location:** expect functions in commonMain
|
||||
|
||||
**Code:**
|
||||
```kotlin
|
||||
// quartz/src/commonMain/.../Platform.kt
|
||||
expect fun platform(): String
|
||||
expect fun currentTimeSeconds(): Long
|
||||
```
|
||||
|
||||
**Why abstracted:**
|
||||
- Used by Nostr event creation (timestamps)
|
||||
- Platform name for debugging
|
||||
- Simple utilities, clear platform boundary
|
||||
|
||||
**Decision rationale:** Platform utilities are good abstraction candidates - simple, useful everywhere.
|
||||
|
||||
---
|
||||
|
||||
### 4. Jackson JSON (jvmAndroid Pattern)
|
||||
|
||||
**Location:** jvmAndroid source set
|
||||
|
||||
**Code:**
|
||||
```kotlin
|
||||
// quartz/build.gradle.kts
|
||||
val jvmAndroid = create("jvmAndroid") {
|
||||
api(libs.jackson.module.kotlin) // JVM-only library
|
||||
}
|
||||
```
|
||||
|
||||
**Why jvmAndroid (not commonMain):**
|
||||
- Jackson is JVM-specific library
|
||||
- Works on Android (JVM) + Desktop (JVM)
|
||||
- Does NOT work on iOS (not JVM) or web (not JVM)
|
||||
- Performance-critical JSON parsing
|
||||
|
||||
**Decision rationale:** Use jvmAndroid for JVM libraries shared between Android and Desktop.
|
||||
|
||||
**Future consideration:** For web support, migrate to kotlinx.serialization (works on all platforms).
|
||||
|
||||
---
|
||||
|
||||
## Bad/Over-Abstractions (Why They Failed)
|
||||
|
||||
### 1. Navigation Abstraction (Avoided)
|
||||
|
||||
**What COULD have been done:**
|
||||
```kotlin
|
||||
// ❌ Over-abstraction - DON'T DO THIS
|
||||
expect interface Navigator {
|
||||
fun navigate(route: String)
|
||||
fun popBackStack()
|
||||
}
|
||||
```
|
||||
|
||||
**Why NOT abstracted:**
|
||||
- Navigation paradigms fundamentally different:
|
||||
- Android: Activity + Compose Navigation + back stack
|
||||
- Desktop: Window + screen state + no back stack concept
|
||||
- Complex APIs don't map well
|
||||
- Creates leaky abstraction
|
||||
|
||||
**Actual approach:** Keep platform-specific
|
||||
- Android: `INav` interface + Compose Navigation
|
||||
- Desktop: Simple screen enum + state
|
||||
|
||||
**Decision rationale:** Never abstract navigation - platforms too different, abstraction would be leaky.
|
||||
|
||||
---
|
||||
|
||||
### 2. String Resources (Abstraction Planned)
|
||||
|
||||
**Current state:** Platform-specific (over-duplication)
|
||||
|
||||
**Problem:**
|
||||
```kotlin
|
||||
// Android uses R.string.*
|
||||
Text(stringResource(R.string.post_not_found))
|
||||
|
||||
// Desktop uses hardcoded strings
|
||||
Text("Post not found")
|
||||
```
|
||||
|
||||
**Why NOT yet abstracted:** Waiting for second platform to fully implement UI, then will create StringProvider interface.
|
||||
|
||||
**Planned abstraction:**
|
||||
```kotlin
|
||||
// commonMain
|
||||
interface StringProvider {
|
||||
fun get(key: String): String
|
||||
}
|
||||
|
||||
// androidMain
|
||||
class AndroidStringProvider(context: Context): StringProvider { ... }
|
||||
|
||||
// jvmMain
|
||||
class DesktopStringProvider: StringProvider { ... }
|
||||
```
|
||||
|
||||
**Lesson:** Don't abstract prematurely - wait until second platform needs it, then create proper abstraction.
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific Code (Why NOT Abstracted)
|
||||
|
||||
### 1. MainActivity (Android Activity)
|
||||
|
||||
**Location:** amethyst/src/main/.../MainActivity.kt
|
||||
|
||||
**Code:**
|
||||
```kotlin
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
AccountScreen(accountStateViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why platform-specific:**
|
||||
- AppCompatActivity is Android framework
|
||||
- Activity lifecycle unique to Android
|
||||
- enableEdgeToEdge() is Android-specific API
|
||||
- No equivalent on Desktop (uses Window)
|
||||
|
||||
**Decision rationale:** Android Activity is platform-specific by nature.
|
||||
|
||||
---
|
||||
|
||||
### 2. Desktop Window & MenuBar
|
||||
|
||||
**Location:** desktopApp/src/jvmMain/.../Main.kt
|
||||
|
||||
**Code:**
|
||||
```kotlin
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Amethyst"
|
||||
) {
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item("New Note", onClick = { ... }, shortcut = KeyShortcut(Key.N, ctrl = true))
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
}
|
||||
}
|
||||
NavigationRail { ... } // Sidebar navigation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why platform-specific:**
|
||||
- Window, MenuBar, NavigationRail are Compose Desktop APIs
|
||||
- Keyboard shortcuts (Ctrl+N) are desktop paradigm
|
||||
- Sidebar navigation vs Android bottom nav
|
||||
- No equivalent on Android
|
||||
|
||||
**Decision rationale:** Desktop UX patterns are platform-specific by nature.
|
||||
|
||||
---
|
||||
|
||||
### 3. AccountViewModel (Android ViewModel)
|
||||
|
||||
**Location:** amethyst/.../AccountStateViewModel.kt
|
||||
|
||||
**Partially abstracted:**
|
||||
- Business logic → IAccountState interface (can be shared)
|
||||
- UI state + lifecycle → AndroidX ViewModel (Android-only)
|
||||
|
||||
**Why not fully abstracted:**
|
||||
- AndroidX ViewModel lifecycle tied to Android
|
||||
- Desktop doesn't need ViewModel (simpler state management)
|
||||
- SavedStateHandle is Android-specific
|
||||
|
||||
**Decision rationale:** Extract business logic to interface, keep UI state platform-specific.
|
||||
|
||||
---
|
||||
|
||||
## Migration Examples (Android → Shared)
|
||||
|
||||
### Example 1: PubKeyFormatter (Pure Kotlin)
|
||||
|
||||
**Before:**
|
||||
```kotlin
|
||||
// amethyst/ui/note/PubKeyFormatter.kt
|
||||
fun String.toDisplayHexKey(): String {
|
||||
return "${take(8)}:${takeLast(8)}"
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```kotlin
|
||||
// commons/commonMain/formatters/PubKeyFormatter.kt
|
||||
fun String.toDisplayHexKey(): String {
|
||||
return "${take(8)}:${takeLast(8)}"
|
||||
}
|
||||
|
||||
// Both apps use it
|
||||
import com.vitorpamplona.amethyst.commons.formatters.toDisplayHexKey
|
||||
```
|
||||
|
||||
**Why successful:**
|
||||
- Pure Kotlin, no platform dependencies
|
||||
- Widely reused
|
||||
- Simple utility function
|
||||
|
||||
---
|
||||
|
||||
### Example 2: TimeAgoFormatter (Requires Abstraction)
|
||||
|
||||
**Problem:**
|
||||
```kotlin
|
||||
// Uses Android R.string.*
|
||||
fun timeAgo(timestamp: Long): String {
|
||||
return context.getString(R.string.x_minutes_ago, minutes)
|
||||
}
|
||||
```
|
||||
|
||||
**Solution:** Abstract string resources
|
||||
```kotlin
|
||||
// commonMain
|
||||
fun timeAgo(timestamp: Long, stringProvider: StringProvider): String {
|
||||
return stringProvider.get("x_minutes_ago", minutes)
|
||||
}
|
||||
|
||||
// androidMain
|
||||
stringProvider = AndroidStringProvider(context)
|
||||
|
||||
// jvmMain
|
||||
stringProvider = DesktopStringProvider()
|
||||
```
|
||||
|
||||
**Why successful:** Clear platform boundary (string resources), useful on both platforms.
|
||||
|
||||
---
|
||||
|
||||
## Decision Pattern Summary
|
||||
|
||||
| Pattern | Abstract? | Why |
|
||||
|---------|-----------|-----|
|
||||
| Pure Kotlin utilities | ✅ YES | No platform dependency, easy |
|
||||
| Crypto APIs | ✅ YES (expect/actual) | Platform security APIs differ |
|
||||
| JVM libraries | ⚠️ jvmAndroid | Works on Android+Desktop only |
|
||||
| UI components (simple) | ✅ YES | Composables work cross-platform |
|
||||
| UI components (complex) | ❌ NO | Platform dependencies |
|
||||
| Navigation | ❌ NO | Paradigms too different |
|
||||
| ViewModels | ⚠️ PARTIAL | Business logic yes, UI state no |
|
||||
| String resources | ⚠️ PLANNED | Needs abstraction layer |
|
||||
@@ -0,0 +1,163 @@
|
||||
# Complete expect/actual Catalog
|
||||
|
||||
All 24 expect declarations in Amethyst quartz module with rationale.
|
||||
|
||||
| # | Name | Type | Purpose | Why Abstracted | Files |
|
||||
|---|------|------|---------|----------------|-------|
|
||||
| 1 | AESCBC | class | AES CBC encryption | Platform crypto APIs differ | quartz/.../ciphers/AESCBC.kt |
|
||||
| 2 | AESGCM | class | AES GCM encryption | Platform crypto APIs differ | quartz/.../ciphers/AESGCM.kt |
|
||||
| 3 | DigestInstance | class | Hash digests (SHA256) | Platform implementations | quartz/.../diggest/DigestInstance.kt |
|
||||
| 4 | MacInstance | class | MAC (HMAC) operations | Platform crypto APIs | quartz/.../mac/MacInstance.kt |
|
||||
| 5 | Sha256 | object | SHA256 hashing | Platform-specific optimizations | quartz/.../sha256/Sha256.kt |
|
||||
| 6 | LargeCache | object | Large object caching | Platform storage APIs differ | quartz/.../cache/LargeCache.kt |
|
||||
| 7 | UriParser | object | URI parsing | Platform URL APIs differ | quartz/.../UriParser.kt |
|
||||
| 8 | UrlEncoder | object | URL encoding | Platform encoding differs | quartz/.../UrlEncoder.kt |
|
||||
| 9 | Urls | object | URL utilities | Platform URL handling | quartz/.../Urls.kt |
|
||||
| 10 | Platform | functions | platform(), currentTimeSeconds() | Platform name & time APIs | quartz/.../Platform.kt |
|
||||
| 11 | Rfc3986 | object | RFC 3986 URL normalization | Used in jvmAndroid | quartz/.../Rfc3986.kt |
|
||||
| 12 | Secp256k1Instance | object | Bitcoin crypto (secp256k1) | Different libs per platform | quartz/.../Secp256k1Instance.kt |
|
||||
| 13 | SecureRandom | object | Cryptographically secure random | Platform random APIs differ | quartz/.../SecureRandom.kt |
|
||||
| 14 | StringExt | functions | String utilities | Platform string handling | quartz/.../StringExt.kt |
|
||||
| 15 | UnicodeNormalizer | object | Unicode normalization | Platform text APIs | quartz/.../UnicodeNormalizer.kt |
|
||||
| 16 | GZip | object | GZip compression | Platform compression APIs | quartz/.../GZip.kt |
|
||||
| 17 | LibSodiumInstance | object | NaCl/libsodium (NIP-44 encryption) | Different libs per platform | quartz/.../LibSodiumInstance.kt |
|
||||
| 18 | Log | object | Logging | Platform logging systems | quartz/.../Log.kt |
|
||||
| 19 | BigDecimal | class | Arbitrary precision decimal | Not in Kotlin common stdlib | quartz/.../BigDecimal.kt |
|
||||
| 20 | BitSet | class | Bit set data structure | Not in Kotlin common stdlib | quartz/.../BitSet.kt |
|
||||
| 21 | ServerInfoParser | object | Server info parsing (NIP-96) | Platform JSON parsing | quartz/.../nip96.../ServerInfoParser.kt |
|
||||
| 22 | EventHasherSerializer | object | Event hashing | Platform-specific optimizations | quartz/.../nip01Core.../EventHasherSerializer.kt |
|
||||
| 23 | OptimizedJsonMapper | object | JSON mapping | Platform JSON libraries | quartz/.../nip01Core.../OptimizedJsonMapper.kt |
|
||||
| 24 | Address | data class | Address data structure | Platform-specific string handling | quartz/.../nip01Core.../Address.kt |
|
||||
|
||||
## Pattern Analysis
|
||||
|
||||
### Objects (Singletons) - 19 total
|
||||
Most common pattern for platform-specific singletons:
|
||||
- Crypto: Secp256k1Instance, LibSodiumInstance, Sha256
|
||||
- I/O: UriParser, UrlEncoder, GZip
|
||||
- Utils: Log, Platform, SecureRandom
|
||||
|
||||
### Classes (Instantiable) - 4 total
|
||||
For objects that need to maintain state:
|
||||
- AESCBC, AESGCM (cipher state)
|
||||
- DigestInstance, MacInstance (hash/MAC state)
|
||||
- BigDecimal, BitSet (data structures)
|
||||
|
||||
### Functions - 2 total
|
||||
Simple utilities:
|
||||
- platform(), currentTimeSeconds()
|
||||
|
||||
## Why Abstracted Categories
|
||||
|
||||
### Crypto (8 items)
|
||||
**Always abstract:** Security APIs fundamentally different across platforms
|
||||
- Android: Android Keystore, secp256k1-android
|
||||
- Desktop: JVM crypto, secp256k1-jvm
|
||||
- iOS: Security framework, native crypto
|
||||
|
||||
### I/O & Platform Utils (7 items)
|
||||
**Often abstract:** File systems, URLs, compression differ
|
||||
- Platform storage APIs
|
||||
- URL handling varies
|
||||
- Compression libraries differ
|
||||
|
||||
### Data Structures (2 items)
|
||||
**Abstract when missing:** Not available in Kotlin common stdlib
|
||||
- BigDecimal, BitSet not in common
|
||||
|
||||
### JSON/Parsing (3 items)
|
||||
**Platform-specific optimization:** Uses platform JSON libraries
|
||||
- Android/Desktop: Jackson (via jvmAndroid)
|
||||
- iOS: Native parsers
|
||||
|
||||
### Logging (1 item)
|
||||
**Always abstract:** Platform logging systems differ
|
||||
- Android: android.util.Log
|
||||
- Desktop: println or logging framework
|
||||
- iOS: NSLog or OSLog
|
||||
|
||||
## Actual Implementation Examples
|
||||
|
||||
### Simple Object Pattern
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect object Log {
|
||||
fun d(tag: String, message: String)
|
||||
}
|
||||
|
||||
// androidMain
|
||||
actual object Log {
|
||||
actual fun d(tag: String, message: String) {
|
||||
android.util.Log.d(tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
// jvmMain
|
||||
actual object Log {
|
||||
actual fun d(tag: String, message: String) {
|
||||
println("[$tag] $message")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Object with Dependencies
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect object Secp256k1Instance {
|
||||
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
// androidMain - uses JNI bindings
|
||||
actual object Secp256k1Instance {
|
||||
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
|
||||
return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null)
|
||||
}
|
||||
}
|
||||
|
||||
// jvmMain - different JNI library
|
||||
actual object Secp256k1Instance {
|
||||
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
|
||||
return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null)
|
||||
}
|
||||
}
|
||||
|
||||
// iosMain - native iOS implementation
|
||||
actual object Secp256k1Instance {
|
||||
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
|
||||
// Uses iOS Security framework or native lib
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Class Pattern
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect class BigDecimal {
|
||||
constructor(value: String)
|
||||
fun add(other: BigDecimal): BigDecimal
|
||||
override fun toString(): String
|
||||
}
|
||||
|
||||
// jvmAndroid (works on Android + Desktop)
|
||||
actual typealias BigDecimal = java.math.BigDecimal
|
||||
|
||||
// iosMain
|
||||
actual class BigDecimal {
|
||||
private val value: NSDecimalNumber
|
||||
actual constructor(value: String) {
|
||||
this.value = NSDecimalNumber(value)
|
||||
}
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Decision Patterns
|
||||
|
||||
Ask for each declaration:
|
||||
1. **Used by 2+ platforms?** → YES (otherwise platform-specific)
|
||||
2. **Pure Kotlin possible?** → NO (otherwise commonMain)
|
||||
3. **Varies by platform?** → YES (expect/actual)
|
||||
4. **JVM-only library?** → NO (otherwise jvmAndroid)
|
||||
@@ -0,0 +1,330 @@
|
||||
# Source Set Hierarchy in Amethyst
|
||||
|
||||
Visual guide to source set organization with concrete examples from the codebase.
|
||||
|
||||
## Hierarchy Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ commonMain │
|
||||
│ Pure Kotlin, no platform APIs │
|
||||
│ Examples: │
|
||||
│ - Nostr event parsing (TextNoteEvent, MetadataEvent) │
|
||||
│ - Business logic (data validation, crypto algorithms) │
|
||||
│ - Data models (@Immutable data classes) │
|
||||
│ Dependencies: kotlin-stdlib, kotlinx-coroutines │
|
||||
└──────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌────────────┴────────────┬───────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────┐ ┌───────────────────┐ ┌──────────────┐
|
||||
│ jvmAndroid │ │ iosMain │ │ Future: │
|
||||
│ JVM libraries │ │ iOS common │ │ jsMain │
|
||||
│ Examples: │ │ Examples: │ │ wasmMain │
|
||||
│ - Jackson JSON │ │ - Platform API │ └──────────────┘
|
||||
│ - OkHttp HTTP │ │ - Actuals for │
|
||||
│ - url-detector │ │ crypto/I/O │
|
||||
│ Dependencies: │ │ Dependencies: │
|
||||
│ - Jackson │ │ - Platform libs │
|
||||
│ - OkHttp │ └───────┬───────────┘
|
||||
└────┬─────────┬───┘ │
|
||||
│ │ │
|
||||
│ │ ├─→ iosArm64Main (device ARM64)
|
||||
│ │ └─→ iosSimulatorArm64Main (Apple Silicon)
|
||||
▼ ▼
|
||||
┌──────────┐ ┌───────────┐
|
||||
│android │ │ jvmMain │
|
||||
│Main │ │ (Desktop) │
|
||||
│Examples: │ │ Examples: │
|
||||
│- Activity│ │- Window │
|
||||
│- ViewModel│ │- MenuBar │
|
||||
│- Android │ │- Desktop │
|
||||
│ APIs │ │ Compose │
|
||||
│Deps: │ │ Deps: │
|
||||
│- secp256k│ │- secp256k │
|
||||
│ 1-android│ │ 1-jvm │
|
||||
│- androidx│ │- Compose │
|
||||
│ │ │ Desktop │
|
||||
└──────────┘ └───────────┘
|
||||
```
|
||||
|
||||
## Dependency Flow
|
||||
|
||||
```
|
||||
Code in commonMain
|
||||
↓ can use
|
||||
Nothing (only Kotlin stdlib)
|
||||
|
||||
Code in jvmAndroid
|
||||
↓ can use
|
||||
commonMain + JVM libraries (Jackson, OkHttp)
|
||||
|
||||
Code in androidMain
|
||||
↓ can use
|
||||
commonMain + jvmAndroid + Android framework
|
||||
|
||||
Code in jvmMain
|
||||
↓ can use
|
||||
commonMain + jvmAndroid + JVM + Compose Desktop
|
||||
|
||||
Code in iosMain
|
||||
↓ can use
|
||||
commonMain + iOS platform APIs
|
||||
```
|
||||
|
||||
## Real Examples from Amethyst
|
||||
|
||||
### commonMain - Pure Kotlin
|
||||
|
||||
**File:** `quartz/src/commonMain/.../TextNoteEvent.kt`
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
class TextNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseThreadedEvent(...) {
|
||||
// Pure Kotlin - works everywhere
|
||||
override fun indexableContent() = "Subject: " + subject() + "\n" + content
|
||||
}
|
||||
```
|
||||
|
||||
**Why commonMain:**
|
||||
- Pure Kotlin code
|
||||
- No platform APIs
|
||||
- Data class with business logic
|
||||
- Needed by all platforms
|
||||
|
||||
---
|
||||
|
||||
### jvmAndroid - JVM Libraries
|
||||
|
||||
**File:** `quartz/build.gradle.kts`
|
||||
|
||||
```kotlin
|
||||
val jvmAndroid = create("jvmAndroid") {
|
||||
dependsOn(commonMain.get())
|
||||
|
||||
dependencies {
|
||||
// Normalizes URLs
|
||||
api(libs.rfc3986.normalizer)
|
||||
|
||||
// Performant Parser of JSONs into Events
|
||||
api(libs.jackson.module.kotlin)
|
||||
|
||||
// Parses URLs from Text
|
||||
api(libs.url.detector)
|
||||
|
||||
// Websockets API
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttpCoroutines)
|
||||
}
|
||||
}
|
||||
|
||||
jvmMain { dependsOn(jvmAndroid) } // Desktop gets Jackson, OkHttp
|
||||
androidMain { dependsOn(jvmAndroid) } // Android gets Jackson, OkHttp
|
||||
```
|
||||
|
||||
**Why jvmAndroid:**
|
||||
- Jackson, OkHttp are JVM-only libraries
|
||||
- Works on Android (JVM) and Desktop (JVM)
|
||||
- Does NOT work on iOS (not JVM) or web (not JVM)
|
||||
|
||||
**Usage in code:**
|
||||
```kotlin
|
||||
// Can use Jackson in jvmAndroid source set
|
||||
val mapper = ObjectMapper()
|
||||
val event = mapper.readValue(json, Event::class.java)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### androidMain - Android Platform
|
||||
|
||||
**File:** `amethyst/src/main/.../MainActivity.kt`
|
||||
|
||||
```kotlin
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge() // Android API
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent { // Compose for Android
|
||||
AmethystTheme {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
AccountScreen(accountStateViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why androidMain:**
|
||||
- AppCompatActivity is Android framework
|
||||
- Activity lifecycle Android-specific
|
||||
- AndroidX libraries (viewModel())
|
||||
|
||||
**Dependencies:**
|
||||
```kotlin
|
||||
androidMain {
|
||||
dependsOn(jvmAndroid) // Gets Jackson, OkHttp
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
api(libs.secp256k1.kmp.jni.android) // Android crypto
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### jvmMain - Desktop Platform
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../Main.kt`
|
||||
|
||||
```kotlin
|
||||
fun main() = application {
|
||||
val windowState = rememberWindowState(
|
||||
width = 1200.dp,
|
||||
height = 800.dp
|
||||
)
|
||||
|
||||
Window( // Compose Desktop API
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = windowState,
|
||||
title = "Amethyst"
|
||||
) {
|
||||
MenuBar { // Desktop-specific
|
||||
Menu("File") {
|
||||
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true))
|
||||
}
|
||||
}
|
||||
NavigationRail { ... } // Sidebar
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why jvmMain:**
|
||||
- Window, MenuBar, NavigationRail are Compose Desktop
|
||||
- Keyboard shortcuts desktop paradigm
|
||||
- Different UX from Android (sidebar vs bottom nav)
|
||||
|
||||
**Dependencies:**
|
||||
```kotlin
|
||||
jvmMain {
|
||||
dependsOn(jvmAndroid) // Gets Jackson, OkHttp
|
||||
dependencies {
|
||||
implementation(libs.secp256k1.kmp.jni.jvm) // Desktop crypto
|
||||
implementation(compose.desktop.currentOs)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### iosMain - iOS Platform
|
||||
|
||||
**File:** `quartz/build.gradle.kts`
|
||||
|
||||
```kotlin
|
||||
iosMain {
|
||||
dependsOn(commonMain.get())
|
||||
dependencies {
|
||||
// iOS platform dependencies
|
||||
}
|
||||
}
|
||||
|
||||
val iosArm64Main by getting { dependsOn(iosMain.get()) }
|
||||
val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) }
|
||||
```
|
||||
|
||||
**Why iosMain:**
|
||||
- iOS platform APIs
|
||||
- Native crypto (Security framework)
|
||||
- Different from Android/Desktop
|
||||
|
||||
**Architecture targets:**
|
||||
- iosArm64Main: Device (iPhone, iPad)
|
||||
- iosSimulatorArm64Main: Apple Silicon simulator
|
||||
|
||||
---
|
||||
|
||||
## Build Order Matters
|
||||
|
||||
**CRITICAL:** jvmAndroid must be defined BEFORE androidMain and jvmMain:
|
||||
|
||||
```kotlin
|
||||
// ✅ CORRECT ORDER
|
||||
val jvmAndroid = create("jvmAndroid") { ... }
|
||||
jvmMain { dependsOn(jvmAndroid) }
|
||||
androidMain { dependsOn(jvmAndroid) }
|
||||
|
||||
// ❌ WRONG - Build error
|
||||
androidMain { dependsOn(jvmAndroid) } // jvmAndroid not defined yet!
|
||||
val jvmAndroid = create("jvmAndroid") { ... }
|
||||
```
|
||||
|
||||
See comment in quartz/build.gradle.kts:131:
|
||||
```kotlin
|
||||
// Must be defined before androidMain and jvmMain
|
||||
val jvmAndroid = create("jvmAndroid") { ... }
|
||||
```
|
||||
|
||||
## Choosing the Right Source Set
|
||||
|
||||
Decision flowchart:
|
||||
|
||||
```
|
||||
Q: Where should this code go?
|
||||
|
||||
├─ Pure Kotlin? (no platform APIs)
|
||||
│ └─ commonMain
|
||||
│
|
||||
├─ JVM library? (Jackson, OkHttp)
|
||||
│ └─ jvmAndroid
|
||||
│
|
||||
├─ Android API? (Activity, Context)
|
||||
│ └─ androidMain
|
||||
│
|
||||
├─ Desktop API? (Window, MenuBar)
|
||||
│ └─ jvmMain
|
||||
│
|
||||
└─ iOS API? (platform.posix, Security)
|
||||
└─ iosMain
|
||||
```
|
||||
|
||||
## Future: Web/wasm Source Sets
|
||||
|
||||
**Not yet implemented**, but structure would be:
|
||||
|
||||
```
|
||||
commonMain
|
||||
├─→ jsMain (JavaScript/Web)
|
||||
│ └─ JS-specific: DOM APIs, fetch
|
||||
│
|
||||
└─→ wasmMain (WebAssembly)
|
||||
└─ wasm-specific: limited APIs
|
||||
```
|
||||
|
||||
**Constraints:**
|
||||
- Cannot use jvmAndroid (Jackson, OkHttp)
|
||||
- Cannot use platform.posix
|
||||
- Must use pure Kotlin or web-compatible libs (ktor, kotlinx.serialization)
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Source Set | Extends | Can Use | Example Code |
|
||||
|------------|---------|---------|--------------|
|
||||
| commonMain | - | Kotlin stdlib only | TextNoteEvent, business logic |
|
||||
| jvmAndroid | commonMain | JVM libs (Jackson, OkHttp) | JSON parsing, HTTP |
|
||||
| androidMain | jvmAndroid | Android framework | Activity, ViewModel |
|
||||
| jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar |
|
||||
| iosMain | commonMain | iOS platform | Security framework |
|
||||
| iosMain | Simulator (Intel) | Architecture-specific |
|
||||
| iosArm64Main | iosMain | Device (ARM64) | Architecture-specific |
|
||||
| jsMain | commonMain | JS/DOM | Web (future) |
|
||||
| wasmMain | commonMain | wasm APIs | WebAssembly (future) |
|
||||
@@ -0,0 +1,344 @@
|
||||
# Target Compatibility Guide
|
||||
|
||||
Current targets (Android, JVM/Desktop, iOS) and future targets (web, wasm) with constraints.
|
||||
|
||||
## Current Primary Targets
|
||||
|
||||
### Android (androidMain)
|
||||
|
||||
**Status:** ✅ Mature, production-ready
|
||||
|
||||
**Runtime:** JVM (Dalvik/ART)
|
||||
|
||||
**Available:**
|
||||
- Android framework (Activity, Context, Intent, etc.)
|
||||
- AndroidX libraries (ViewModel, Navigation, etc.)
|
||||
- JVM libraries via jvmAndroid (Jackson, OkHttp)
|
||||
- Platform-specific crypto: secp256k1-kmp-jni-android
|
||||
|
||||
**Constraints:**
|
||||
- Mobile UX paradigms (bottom navigation, vertical scroll)
|
||||
- Touch-first interaction
|
||||
- Limited screen space
|
||||
- Battery/performance constraints
|
||||
|
||||
**Example code:**
|
||||
```kotlin
|
||||
// androidMain
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Android-specific lifecycle
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### JVM / Desktop (jvmMain)
|
||||
|
||||
**Status:** ✅ Active development, functional
|
||||
|
||||
**Runtime:** JVM
|
||||
|
||||
**Available:**
|
||||
- Pure JVM libraries
|
||||
- JVM libraries via jvmAndroid (Jackson, OkHttp)
|
||||
- Compose Desktop (Window, MenuBar, etc.)
|
||||
- Platform-specific crypto: secp256k1-kmp-jni-jvm
|
||||
|
||||
**Constraints:**
|
||||
- Desktop UX paradigms (sidebar, menus, keyboard shortcuts)
|
||||
- Keyboard + mouse interaction
|
||||
- Larger screen space
|
||||
- Different navigation patterns (no back stack)
|
||||
|
||||
**Example code:**
|
||||
```kotlin
|
||||
// jvmMain
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Amethyst"
|
||||
) {
|
||||
MenuBar { ... } // Desktop-specific
|
||||
NavigationRail { ... } // Sidebar
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### iOS (iosMain + architecture targets)
|
||||
|
||||
**Status:** ⚠️ In development, framework configured
|
||||
|
||||
**Runtime:** Native iOS
|
||||
|
||||
**Source sets:**
|
||||
- iosMain (common iOS code)
|
||||
- iosArm64Main (device - iPhone/iPad)
|
||||
- iosSimulatorArm64Main (Apple Silicon simulator)
|
||||
|
||||
**Available:**
|
||||
- iOS platform APIs (platform.posix, Foundation, etc.)
|
||||
- Native crypto (Security framework)
|
||||
- SwiftUI integration (via KMP framework)
|
||||
|
||||
**NOT available:**
|
||||
- JVM libraries (Jackson, OkHttp)
|
||||
- jvmAndroid source set
|
||||
- JVM-specific APIs
|
||||
|
||||
**Constraints:**
|
||||
- Mobile UX (similar to Android)
|
||||
- Swift/Objective-C interop
|
||||
- XCFramework distribution
|
||||
- CocoaPods integration
|
||||
|
||||
**Example code:**
|
||||
```kotlin
|
||||
// iosMain
|
||||
actual object Secp256k1Instance {
|
||||
actual fun signSchnorr(...): ByteArray {
|
||||
// Use iOS Security framework
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**XCFramework setup:**
|
||||
```kotlin
|
||||
// quartz/build.gradle.kts
|
||||
kotlin {
|
||||
listOf(macosArm64(), iosArm64(), iosSimulatorArm64())
|
||||
.forEach { target ->
|
||||
target.binaries.framework {
|
||||
baseName = "quartz-kmpKit"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Targets
|
||||
|
||||
### Web / JavaScript (jsMain)
|
||||
|
||||
**Status:** ❌ Not implemented, consider for future
|
||||
|
||||
**Runtime:** JavaScript (browser or Node.js)
|
||||
|
||||
**Available:**
|
||||
- Kotlin/JS stdlib
|
||||
- JS/DOM APIs
|
||||
- kotlinx.* libraries (serialization, coroutines, datetime)
|
||||
- ktor-client (HTTP)
|
||||
|
||||
**NOT available:**
|
||||
- ❌ JVM libraries (Jackson, OkHttp)
|
||||
- ❌ jvmAndroid source set
|
||||
- ❌ platform.posix (no file system access like native)
|
||||
- ❌ Blocking APIs (different async model - JS event loop)
|
||||
|
||||
**Constraints:**
|
||||
- Single-threaded event loop
|
||||
- No blocking calls
|
||||
- Different async patterns (Promises, async/await)
|
||||
- Browser security (CORS, no file system)
|
||||
|
||||
**Migration path from current code:**
|
||||
|
||||
| Current (jvmAndroid) | Web-compatible alternative |
|
||||
|---------------------|---------------------------|
|
||||
| Jackson JSON | kotlinx.serialization |
|
||||
| OkHttp HTTP | ktor-client |
|
||||
| java.math.BigDecimal | Kotlin BigDecimal (coming) |
|
||||
| Blocking I/O | Suspending functions |
|
||||
|
||||
**Example migration:**
|
||||
```kotlin
|
||||
// Current: jvmAndroid
|
||||
val mapper = ObjectMapper()
|
||||
val event = mapper.readValue(json, Event::class.java)
|
||||
|
||||
// Future: commonMain (works on web)
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val event = json.decodeFromString<Event>(jsonString)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WebAssembly (wasmMain)
|
||||
|
||||
**Status:** ❌ Not implemented, experimental Kotlin/Wasm
|
||||
|
||||
**Runtime:** WebAssembly
|
||||
|
||||
**Available:**
|
||||
- Kotlin/Wasm stdlib
|
||||
- Limited kotlinx.* libraries
|
||||
- wasm-specific APIs
|
||||
|
||||
**NOT available:**
|
||||
- ❌ JVM libraries
|
||||
- ❌ Full platform.posix
|
||||
- ❌ Many kotlinx libraries (limited wasm support)
|
||||
|
||||
**Constraints:**
|
||||
- Even more limited than JS
|
||||
- Experimental Kotlin support
|
||||
- Limited library ecosystem
|
||||
|
||||
**Recommendation:** Focus on web (jsMain) first, wasm later.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Target Compatibility Matrix
|
||||
|
||||
| Feature | Android | JVM/Desktop | iOS | Web (JS) | wasm |
|
||||
|---------|---------|-------------|-----|----------|------|
|
||||
| Pure Kotlin | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| kotlinx.coroutines | ✅ | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| kotlinx.serialization | ✅ | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| kotlinx.datetime | ✅ | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| ktor-client | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Jackson JSON | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ |
|
||||
| OkHttp | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ |
|
||||
| platform.posix | ❌ | ❌ | ✅ | ❌ | ⚠️ |
|
||||
| Compose Multiplatform | ✅ | ✅ | ⚠️ (experimental) | ⚠️ (experimental) | ❌ |
|
||||
|
||||
Legend:
|
||||
- ✅ Full support
|
||||
- ⚠️ Limited/experimental
|
||||
- ❌ Not available
|
||||
|
||||
---
|
||||
|
||||
## Future-Proofing Recommendations
|
||||
|
||||
### For Web Compatibility
|
||||
|
||||
**DO:**
|
||||
- ✅ Use kotlinx.serialization instead of Jackson
|
||||
- ✅ Use ktor-client instead of OkHttp
|
||||
- ✅ Use kotlinx.datetime instead of java.time
|
||||
- ✅ Use suspending functions (non-blocking)
|
||||
- ✅ Keep business logic in commonMain
|
||||
|
||||
**DON'T:**
|
||||
- ❌ Put JVM libraries in commonMain
|
||||
- ❌ Use platform.posix for critical features
|
||||
- ❌ Use blocking I/O
|
||||
- ❌ Depend on threading (use coroutines)
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
// ❌ NOT web-compatible
|
||||
// jvmAndroid
|
||||
fun parseJson(json: String): Event {
|
||||
val mapper = ObjectMapper() // Jackson - JVM only
|
||||
return mapper.readValue(json, Event::class.java)
|
||||
}
|
||||
|
||||
// ✅ Web-compatible
|
||||
// commonMain
|
||||
@Serializable
|
||||
data class Event(...)
|
||||
|
||||
fun parseJson(json: String): Event {
|
||||
return Json.decodeFromString<Event>(json) // Works everywhere
|
||||
}
|
||||
```
|
||||
|
||||
### Current Migration Priorities
|
||||
|
||||
**High priority:** (Needed for web)
|
||||
1. Migrate Jackson → kotlinx.serialization
|
||||
2. Migrate OkHttp → ktor-client
|
||||
3. Move business logic to commonMain
|
||||
|
||||
**Medium priority:** (Nice to have)
|
||||
1. Abstract date/time handling → kotlinx.datetime
|
||||
2. Remove platform.posix usage where possible
|
||||
3. Use suspending functions over blocking
|
||||
|
||||
**Low priority:** (Future optimization)
|
||||
1. wasm-specific optimizations
|
||||
2. Platform-specific performance tuning
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific Patterns
|
||||
|
||||
### Android vs iOS Differences
|
||||
|
||||
| Aspect | Android | iOS |
|
||||
|--------|---------|-----|
|
||||
| **Activity/ViewController** | Activity | UIViewController |
|
||||
| **Navigation** | Compose Navigation | UINavigationController |
|
||||
| **Lifecycle** | onCreate, onResume, etc. | viewDidLoad, viewWillAppear |
|
||||
| **Permissions** | Runtime permissions | Info.plist + runtime |
|
||||
| **Crypto** | secp256k1-android | Security framework |
|
||||
| **Storage** | Room, SharedPreferences | Core Data, UserDefaults |
|
||||
|
||||
### Desktop vs Mobile Differences
|
||||
|
||||
| Aspect | Desktop | Mobile |
|
||||
|--------|---------|--------|
|
||||
| **Navigation** | Sidebar | Bottom nav |
|
||||
| **Input** | Keyboard + mouse | Touch |
|
||||
| **Screen** | Large, landscape | Small, portrait |
|
||||
| **Windows** | Multi-window | Single app |
|
||||
| **Shortcuts** | Keyboard shortcuts (Ctrl+N) | None |
|
||||
| **Menus** | MenuBar | Bottom sheets |
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Per-Target Testing
|
||||
|
||||
**Android:**
|
||||
- Unit tests: androidTest
|
||||
- Instrumented: androidInstrumentedTest
|
||||
- Device/emulator testing
|
||||
|
||||
**Desktop:**
|
||||
- Unit tests: jvmTest
|
||||
- Manual desktop app testing
|
||||
|
||||
**iOS:**
|
||||
- Unit tests: iosTest (iosArm64Test, etc.)
|
||||
- Simulator/device testing
|
||||
|
||||
**Web (future):**
|
||||
- Unit tests: jsTest
|
||||
- Browser testing (Selenium, Playwright)
|
||||
|
||||
### Shared Testing
|
||||
|
||||
**commonTest:**
|
||||
- Business logic tests
|
||||
- Pure Kotlin code
|
||||
- Works on all platforms
|
||||
|
||||
```kotlin
|
||||
// commonTest
|
||||
class EventParsingTest {
|
||||
@Test
|
||||
fun parseTextNoteEvent() {
|
||||
// Tests run on all platforms
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Current Focus:** Android, JVM/Desktop, iOS (active development)
|
||||
|
||||
**Future Considerations:** Web (requires migration from Jackson/OkHttp)
|
||||
|
||||
**Key Decision:** Prefer kotlinx.* libraries over JVM-specific libs for future web compatibility.
|
||||
166
.claude/skills/kotlin-multiplatform/scripts/suggest-kmp-dependency.sh
Executable file
166
.claude/skills/kotlin-multiplatform/scripts/suggest-kmp-dependency.sh
Executable file
@@ -0,0 +1,166 @@
|
||||
#!/bin/bash
|
||||
# Suggests KMP library alternatives for JVM-specific dependencies
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_ROOT="${1:-.}"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== KMP Dependency Suggestions ==="
|
||||
echo
|
||||
|
||||
# Colors
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
SUGGESTIONS_FOUND=0
|
||||
|
||||
# Check for Jackson (suggest kotlinx.serialization)
|
||||
echo "📦 Checking for Jackson JSON..."
|
||||
if grep -r "jackson" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then
|
||||
echo -e "${YELLOW}⚠ Found Jackson dependency${NC}"
|
||||
echo " Current: Jackson (JVM-only)"
|
||||
echo -e " ${GREEN}Suggest: kotlinx.serialization${NC} (works on all platforms)"
|
||||
echo
|
||||
echo " Migration:"
|
||||
echo " // Remove:"
|
||||
echo " api(libs.jackson.module.kotlin)"
|
||||
echo
|
||||
echo " // Add to commonMain:"
|
||||
echo " implementation(libs.kotlinx.serialization.json)"
|
||||
echo
|
||||
echo " // Code change:"
|
||||
echo " // Before (Jackson):"
|
||||
echo " val mapper = ObjectMapper()"
|
||||
echo " val event = mapper.readValue(json, Event::class.java)"
|
||||
echo
|
||||
echo " // After (kotlinx.serialization):"
|
||||
echo " @Serializable"
|
||||
echo " data class Event(...)"
|
||||
echo " val event = Json.decodeFromString<Event>(json)"
|
||||
echo
|
||||
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
|
||||
else
|
||||
echo -e "${GREEN}✓ Not using Jackson (or already using kotlinx.serialization)${NC}"
|
||||
fi
|
||||
|
||||
# Check for OkHttp (suggest ktor)
|
||||
echo
|
||||
echo "📦 Checking for OkHttp..."
|
||||
if grep -r "okhttp" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then
|
||||
echo -e "${YELLOW}⚠ Found OkHttp dependency${NC}"
|
||||
echo " Current: OkHttp (JVM-only)"
|
||||
echo -e " ${GREEN}Suggest: ktor-client${NC} (works on all platforms)"
|
||||
echo
|
||||
echo " Migration:"
|
||||
echo " // Remove:"
|
||||
echo " implementation(libs.okhttp)"
|
||||
echo
|
||||
echo " // Add to commonMain:"
|
||||
echo " implementation(libs.ktor.client.core)"
|
||||
echo " // Platform-specific engines:"
|
||||
echo " // androidMain: implementation(libs.ktor.client.android)"
|
||||
echo " // jvmMain: implementation(libs.ktor.client.cio)"
|
||||
echo " // iosMain: implementation(libs.ktor.client.darwin)"
|
||||
echo
|
||||
echo " // Code change:"
|
||||
echo " // Before (OkHttp):"
|
||||
echo " val client = OkHttpClient()"
|
||||
echo " val request = Request.Builder().url(url).build()"
|
||||
echo " val response = client.newCall(request).execute()"
|
||||
echo
|
||||
echo " // After (ktor):"
|
||||
echo " val client = HttpClient()"
|
||||
echo " val response: String = client.get(url)"
|
||||
echo
|
||||
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
|
||||
else
|
||||
echo -e "${GREEN}✓ Not using OkHttp (or already using ktor)${NC}"
|
||||
fi
|
||||
|
||||
# Check for java.time (suggest kotlinx.datetime)
|
||||
echo
|
||||
echo "📦 Checking for java.time usage..."
|
||||
if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.time\." >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠ Found java.time imports${NC}"
|
||||
echo " Current: java.time (JVM-only)"
|
||||
echo -e " ${GREEN}Suggest: kotlinx.datetime${NC} (works on all platforms)"
|
||||
echo
|
||||
echo " Migration:"
|
||||
echo " // Add to commonMain:"
|
||||
echo " implementation(libs.kotlinx.datetime)"
|
||||
echo
|
||||
echo " // Code change:"
|
||||
echo " // Before (java.time):"
|
||||
echo " import java.time.Instant"
|
||||
echo " val now = Instant.now()"
|
||||
echo
|
||||
echo " // After (kotlinx.datetime):"
|
||||
echo " import kotlinx.datetime.Clock"
|
||||
echo " val now = Clock.System.now()"
|
||||
echo
|
||||
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
|
||||
else
|
||||
echo -e "${GREEN}✓ Not using java.time (or already using kotlinx.datetime)${NC}"
|
||||
fi
|
||||
|
||||
# Check for java.math.BigDecimal
|
||||
echo
|
||||
echo "📦 Checking for java.math.BigDecimal usage..."
|
||||
if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.math.BigDecimal" >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠ Found java.math.BigDecimal imports${NC}"
|
||||
echo " Current: java.math.BigDecimal (JVM-only)"
|
||||
echo -e " ${BLUE}Note:${NC} KMP BigDecimal not yet in stable kotlinx"
|
||||
echo
|
||||
echo " Options:"
|
||||
echo " 1. Use expect/actual (current approach in quartz)"
|
||||
echo " 2. Wait for kotlinx.decimal (proposal stage)"
|
||||
echo " 3. Use third-party KMP library (e.g., bignum)"
|
||||
echo
|
||||
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
|
||||
else
|
||||
echo -e "${GREEN}✓ Not using java.math.BigDecimal directly${NC}"
|
||||
fi
|
||||
|
||||
# Check for platform.posix usage
|
||||
echo
|
||||
echo "📦 Checking for platform.posix usage..."
|
||||
if find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "import platform.posix\." >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠ Found platform.posix in commonMain${NC}"
|
||||
echo " Current: platform.posix (native platforms only, not web)"
|
||||
echo -e " ${GREEN}Suggest:${NC} Abstract file I/O with expect/actual"
|
||||
echo
|
||||
echo " For web compatibility:"
|
||||
echo " - iOS/Native: platform.posix"
|
||||
echo " - Web: Use kotlinx-io or ktor file APIs"
|
||||
echo " - Create expect/actual for file operations"
|
||||
echo
|
||||
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
|
||||
else
|
||||
echo -e "${GREEN}✓ Not using platform.posix in commonMain${NC}"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo
|
||||
echo "=== Summary ==="
|
||||
if [ "$SUGGESTIONS_FOUND" -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ No JVM-specific dependencies found!${NC}"
|
||||
echo " Your code is ready for web/wasm targets."
|
||||
else
|
||||
echo -e "${YELLOW}Found $SUGGESTIONS_FOUND suggestion(s) for KMP alternatives${NC}"
|
||||
echo
|
||||
echo "Priority recommendations:"
|
||||
echo " 1. ${GREEN}High:${NC} Jackson → kotlinx.serialization (enables web support)"
|
||||
echo " 2. ${GREEN}High:${NC} OkHttp → ktor-client (enables web support)"
|
||||
echo " 3. ${GREEN}Medium:${NC} java.time → kotlinx.datetime"
|
||||
echo " 4. ${GREEN}Low:${NC} Consider web compatibility for platform.posix usage"
|
||||
echo
|
||||
echo "Resources:"
|
||||
echo " - kotlinx.serialization: https://github.com/Kotlin/kotlinx.serialization"
|
||||
echo " - ktor: https://ktor.io/docs/client.html"
|
||||
echo " - kotlinx.datetime: https://github.com/Kotlin/kotlinx-datetime"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
126
.claude/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh
Executable file
126
.claude/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh
Executable file
@@ -0,0 +1,126 @@
|
||||
#!/bin/bash
|
||||
# Validates KMP source set structure and detects common issues
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_ROOT="${1:-.}"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== Validating KMP Structure ==="
|
||||
echo
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
ISSUES_FOUND=0
|
||||
|
||||
# Check 1: jvmAndroid defined before androidMain/jvmMain
|
||||
echo "📋 Checking source set definition order..."
|
||||
if [ -f "quartz/build.gradle.kts" ]; then
|
||||
jvmandroid_line=$(grep -n "val jvmAndroid = create" quartz/build.gradle.kts | cut -d: -f1)
|
||||
android_line=$(grep -n "androidMain {" quartz/build.gradle.kts | cut -d: -f1)
|
||||
jvm_line=$(grep -n "jvmMain {" quartz/build.gradle.kts | cut -d: -f1)
|
||||
|
||||
if [ -n "$jvmandroid_line" ] && [ -n "$android_line" ] && [ -n "$jvm_line" ]; then
|
||||
if [ "$jvmandroid_line" -lt "$android_line" ] && [ "$jvmandroid_line" -lt "$jvm_line" ]; then
|
||||
echo -e "${GREEN}✓${NC} jvmAndroid defined before androidMain and jvmMain"
|
||||
else
|
||||
echo -e "${RED}✗${NC} jvmAndroid must be defined BEFORE androidMain and jvmMain"
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check 2: Platform code in commonMain (Android imports)
|
||||
echo
|
||||
echo "📋 Checking for platform code in commonMain..."
|
||||
android_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import android\." || true)
|
||||
if [ -n "$android_imports_in_common" ]; then
|
||||
echo -e "${RED}✗${NC} Found Android imports in commonMain:"
|
||||
echo "$android_imports_in_common" | sed 's/^/ /'
|
||||
echo " Fix: Move to androidMain or create expect/actual"
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} No Android imports in commonMain"
|
||||
fi
|
||||
|
||||
# Check 3: JVM libraries in commonMain (Jackson, OkHttp)
|
||||
echo
|
||||
echo "📋 Checking for JVM libraries in commonMain..."
|
||||
jvm_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import com.fasterxml.jackson\|^import okhttp3\." || true)
|
||||
if [ -n "$jvm_imports_in_common" ]; then
|
||||
echo -e "${RED}✗${NC} Found JVM library imports in commonMain:"
|
||||
echo "$jvm_imports_in_common" | sed 's/^/ /'
|
||||
echo " Fix: Move to jvmAndroid or migrate to kotlinx.serialization/ktor"
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} No JVM library imports in commonMain"
|
||||
fi
|
||||
|
||||
# Check 4: Unmatched expect/actual declarations
|
||||
echo
|
||||
echo "📋 Checking expect/actual pairs..."
|
||||
expect_files=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^expect " || true)
|
||||
if [ -n "$expect_files" ]; then
|
||||
for file in $expect_files; do
|
||||
# Extract declarations
|
||||
expects=$(grep "^expect \(class\|object\|fun\|interface\)" "$file" | sed 's/expect //' | awk '{print $2}' | sed 's/[({].*$//')
|
||||
|
||||
# Check for actuals in platform source sets
|
||||
for expect_name in $expects; do
|
||||
actual_count=0
|
||||
for platform in androidMain jvmMain iosMain; do
|
||||
platform_dir=$(dirname "$file" | sed "s/commonMain/$platform/")
|
||||
platform_file="${platform_dir}/$(basename "$file")"
|
||||
if [ -f "$platform_file" ] && grep -q "actual.*$expect_name" "$platform_file"; then
|
||||
actual_count=$((actual_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$actual_count" -eq 0 ]; then
|
||||
echo -e "${YELLOW}⚠${NC} No actual implementations found for: $expect_name in $file"
|
||||
echo " Check: androidMain, jvmMain, iosMain"
|
||||
ISSUES_FOUND=$((ISSUES_FOUND + 1))
|
||||
fi
|
||||
done
|
||||
done
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} No expect declarations to validate"
|
||||
fi
|
||||
|
||||
# Check 5: Duplicated business logic across platforms
|
||||
echo
|
||||
echo "📋 Checking for potential code duplication..."
|
||||
# This is a heuristic check - look for similar function names in different platform source sets
|
||||
common_functions=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -h "^fun " | awk '{print $2}' | sed 's/[({<].*$//' | sort -u || true)
|
||||
if [ -n "$common_functions" ]; then
|
||||
for func in $common_functions; do
|
||||
android_count=$(find */src/androidMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l)
|
||||
jvm_count=$(find */src/jvmMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l)
|
||||
|
||||
if [ "$android_count" -gt 0 ] && [ "$jvm_count" -gt 0 ]; then
|
||||
echo -e "${YELLOW}⚠${NC} Function '$func' found in both androidMain and jvmMain"
|
||||
echo " Consider: Move to commonMain or jvmAndroid if truly shared"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo
|
||||
echo "=== Summary ==="
|
||||
if [ "$ISSUES_FOUND" -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ All checks passed!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}✗ Found $ISSUES_FOUND issue(s)${NC}"
|
||||
echo
|
||||
echo "Common fixes:"
|
||||
echo " 1. Platform code in commonMain → Move to androidMain or create expect/actual"
|
||||
echo " 2. JVM libraries in commonMain → Move to jvmAndroid or migrate to kotlinx.*"
|
||||
echo " 3. Missing actual implementations → Implement in all target platforms"
|
||||
echo " 4. Duplicated logic → Move to commonMain or jvmAndroid"
|
||||
exit 1
|
||||
fi
|
||||
551
.claude/skills/nostr-expert/SKILL.md
Normal file
551
.claude/skills/nostr-expert/SKILL.md
Normal file
@@ -0,0 +1,551 @@
|
||||
---
|
||||
name: nostr-expert
|
||||
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
|
||||
---
|
||||
|
||||
# Nostr Protocol Expert (Quartz Implementation)
|
||||
|
||||
Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP Nostr library.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Implementing Nostr event types (TextNote, Reaction, Zap, etc.)
|
||||
- Creating/parsing events with TagArrayBuilder DSL
|
||||
- Working with event kinds and tags
|
||||
- Finding NIP implementations in quartz/ codebase
|
||||
- Nostr cryptography (secp256k1 signing, NIP-44 encryption)
|
||||
- Bech32 encoding/decoding (npub, nsec, note formats)
|
||||
- Event validation and verification
|
||||
|
||||
**For NIP specifications** → Use `nostr-protocol` agent
|
||||
**For Quartz implementation** → Use this skill
|
||||
|
||||
## Quartz Architecture
|
||||
|
||||
Quartz organizes code by NIP number:
|
||||
|
||||
```
|
||||
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/
|
||||
├── nip01Core/ # Core protocol (Event, Kind, Tags)
|
||||
├── nip04Dm/ # Legacy DMs (deprecated)
|
||||
├── nip10Notes/ # Text notes with threading
|
||||
├── nip17Dm/ # Private DMs (gift wrap)
|
||||
├── nip19Bech32/ # Bech32 encoding
|
||||
├── nip44Encryption/ # Modern encryption (ChaCha20)
|
||||
├── nip57Zaps/ # Lightning zaps
|
||||
├── ... (57 NIPs total)
|
||||
└── experimental/ # Draft NIPs
|
||||
```
|
||||
|
||||
**Pattern**: `nip##<Name>/` directories contain event classes, tags, and utilities for that NIP.
|
||||
|
||||
**Find implementations**: Use `scripts/nip-lookup.sh <nip-number>` or see `references/nip-catalog.md`.
|
||||
|
||||
## Event Anatomy
|
||||
|
||||
### Core Structure
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
open class Event(
|
||||
val id: HexKey, // SHA-256 hash of serialized event
|
||||
val pubKey: HexKey, // Author's public key (32 bytes hex)
|
||||
val createdAt: Long, // Unix timestamp
|
||||
val kind: Kind, // Event kind (Int typealias)
|
||||
val tags: TagArray, // Array of tag arrays
|
||||
val content: String, // Event content
|
||||
val sig: HexKey, // Schnorr signature (64 bytes hex)
|
||||
) : IEvent
|
||||
```
|
||||
|
||||
**Key insight**: `Event` is the base class. Specific event types (TextNoteEvent, ReactionEvent) extend it and add parsing/helper methods.
|
||||
|
||||
### Kind Classification
|
||||
|
||||
```kotlin
|
||||
typealias Kind = Int
|
||||
|
||||
fun Kind.isEphemeral() = this in 20000..29999 // Not stored
|
||||
fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999
|
||||
fun Kind.isAddressable() = this in 30000..39999 // Replaceable + has d-tag
|
||||
fun Kind.isRegular() = this in 1000..9999 // Stored, not replaced
|
||||
```
|
||||
|
||||
**Pattern**: Kind determines event lifecycle and replaceability.
|
||||
|
||||
## Creating Events
|
||||
|
||||
### EventTemplate Pattern
|
||||
|
||||
```kotlin
|
||||
fun eventTemplate(
|
||||
kind: Kind,
|
||||
content: String,
|
||||
tags: TagArray = emptyArray()
|
||||
): EventTemplate
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
val template = eventTemplate(
|
||||
kind = 1, // Text note
|
||||
content = "Hello Nostr!",
|
||||
tags = tagArray {
|
||||
add(arrayOf("subject", "Greeting"))
|
||||
}
|
||||
)
|
||||
|
||||
// Sign with a signer
|
||||
val signedEvent = signer.sign(template)
|
||||
```
|
||||
|
||||
**Why templates?** Separates event data from signing. Templates can be signed by different signers (local keys, remote signers, hardware wallets).
|
||||
|
||||
### TagArrayBuilder DSL
|
||||
|
||||
```kotlin
|
||||
fun <T : Event> tagArray(
|
||||
initializer: TagArrayBuilder<T>.() -> Unit
|
||||
): TagArray
|
||||
```
|
||||
|
||||
**Methods**:
|
||||
- `add(tag)` - Append tag
|
||||
- `addFirst(tag)` - Prepend tag (for ordering)
|
||||
- `addUnique(tag)` - Replace all tags with this name
|
||||
- `remove(tagName)` - Remove by name
|
||||
- `addAll(tags)` - Bulk add
|
||||
|
||||
**Example**:
|
||||
```kotlin
|
||||
val tags = tagArray<TextNoteEvent> {
|
||||
add(arrayOf("e", replyToEventId, "", "reply"))
|
||||
add(arrayOf("p", authorPubkey))
|
||||
addUnique(arrayOf("subject", "Re: Hello"))
|
||||
add(arrayOf("content-warning", "spoilers"))
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Fluent DSL for building tag arrays with validation and deduplication.
|
||||
|
||||
## Common Event Types
|
||||
|
||||
### TextNoteEvent (kind 1)
|
||||
|
||||
```kotlin
|
||||
class TextNoteEvent : BaseThreadedEvent
|
||||
```
|
||||
|
||||
**Creating**:
|
||||
```kotlin
|
||||
val note = eventTemplate(
|
||||
kind = 1,
|
||||
content = "Hello world!",
|
||||
tags = tagArray {
|
||||
add(arrayOf("subject", "First post"))
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Parsing**:
|
||||
```kotlin
|
||||
val event: TextNoteEvent = ...
|
||||
val subject = event.subject() // Extension from nip14Subject
|
||||
val mentions = event.mentions() // List of p-tags
|
||||
val quotedEvents = event.quotes() // List of q-tags
|
||||
```
|
||||
|
||||
### ReactionEvent (kind 7)
|
||||
|
||||
```kotlin
|
||||
fun createReaction(
|
||||
targetEvent: Event,
|
||||
emoji: String = "+"
|
||||
): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 7,
|
||||
content = emoji,
|
||||
tags = tagArray {
|
||||
add(arrayOf("e", targetEvent.id))
|
||||
add(arrayOf("p", targetEvent.pubKey))
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### MetadataEvent (kind 0)
|
||||
|
||||
```kotlin
|
||||
data class UserMetadata(
|
||||
val name: String?,
|
||||
val displayName: String?,
|
||||
val picture: String?,
|
||||
val banner: String?,
|
||||
val about: String?,
|
||||
// ... more fields
|
||||
)
|
||||
|
||||
fun createMetadata(metadata: UserMetadata): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 0,
|
||||
content = metadata.toJson() // Serialize to JSON
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Addressable Events (kinds 30000-40000)
|
||||
|
||||
```kotlin
|
||||
fun createArticle(
|
||||
slug: String,
|
||||
title: String,
|
||||
content: String
|
||||
): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 30023,
|
||||
content = content,
|
||||
tags = tagArray {
|
||||
addUnique(arrayOf("d", slug)) // Unique identifier
|
||||
add(arrayOf("title", title))
|
||||
add(arrayOf("published_at", "${TimeUtils.now()}"))
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Key**: `d-tag` makes it addressable. Events with same kind + pubkey + d-tag replace each other.
|
||||
|
||||
## Tag Patterns
|
||||
|
||||
Tags are `Array<String>` with pattern `[name, value, ...optionalParams]`.
|
||||
|
||||
### Core Tags
|
||||
|
||||
**e-tag** (event reference):
|
||||
```kotlin
|
||||
add(arrayOf("e", eventId, relayHint, marker))
|
||||
// marker: "reply", "root", "mention"
|
||||
```
|
||||
|
||||
**p-tag** (pubkey reference):
|
||||
```kotlin
|
||||
add(arrayOf("p", pubkey, relayHint))
|
||||
```
|
||||
|
||||
**a-tag** (addressable event):
|
||||
```kotlin
|
||||
add(arrayOf("a", "$kind:$pubkey:$dtag", relayHint))
|
||||
```
|
||||
|
||||
**d-tag** (identifier for addressable events):
|
||||
```kotlin
|
||||
addUnique(arrayOf("d", "unique-slug"))
|
||||
```
|
||||
|
||||
### Tag Extensions
|
||||
|
||||
```kotlin
|
||||
// Find tags
|
||||
event.tags.tagValue("subject") // First subject tag value
|
||||
event.tags.allTags("p") // All p-tags
|
||||
event.tags.tagValues("e") // All e-tag values
|
||||
|
||||
// Parse structured tags
|
||||
event.tags.mapNotNull(ETag::parse) // Parse as ETag objects
|
||||
```
|
||||
|
||||
For comprehensive tag patterns, see `references/tag-patterns.md`.
|
||||
|
||||
## Threading (NIP-10)
|
||||
|
||||
```kotlin
|
||||
fun createReply(
|
||||
original: TextNoteEvent,
|
||||
content: String
|
||||
): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 1,
|
||||
content = content,
|
||||
tags = tagArray {
|
||||
// Reply marker
|
||||
add(arrayOf("e", original.id, "", "reply"))
|
||||
|
||||
// Root marker (original's root, or original itself)
|
||||
original.rootEvent()?.let {
|
||||
add(arrayOf("e", it.id, "", "root"))
|
||||
} ?: add(arrayOf("e", original.id, "", "root"))
|
||||
|
||||
// Tag author
|
||||
add(arrayOf("p", original.pubKey))
|
||||
|
||||
// Tag all mentioned users
|
||||
original.mentions().forEach {
|
||||
add(arrayOf("p", it))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: `reply` and `root` markers establish thread hierarchy.
|
||||
|
||||
## Cryptography
|
||||
|
||||
### Signing (secp256k1)
|
||||
|
||||
```kotlin
|
||||
interface ISigner {
|
||||
suspend fun sign(template: EventTemplate): Event
|
||||
}
|
||||
|
||||
// Local key signing
|
||||
class LocalSigner(private val privateKey: ByteArray) : ISigner {
|
||||
override suspend fun sign(template: EventTemplate): Event {
|
||||
val id = template.generateId()
|
||||
val sig = Secp256k1.sign(id, privateKey)
|
||||
return Event(id, pubKey, createdAt, kind, tags, content, sig)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Signers abstract key management. Can be local, remote (NIP-46), or hardware.
|
||||
|
||||
### Encryption (NIP-44)
|
||||
|
||||
```kotlin
|
||||
// Modern encryption (ChaCha20-Poly1305)
|
||||
object Nip44v2 {
|
||||
fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
}
|
||||
|
||||
// Usage
|
||||
val encrypted = Nip44v2.encrypt(
|
||||
plaintext = "Secret message",
|
||||
privateKey = myPrivateKey,
|
||||
pubKey = recipientPubKey
|
||||
)
|
||||
|
||||
val decrypted = Nip44v2.decrypt(
|
||||
ciphertext = encrypted,
|
||||
privateKey = myPrivateKey,
|
||||
pubKey = senderPubKey
|
||||
)
|
||||
```
|
||||
|
||||
**Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD.
|
||||
|
||||
### NIP-04 (Deprecated)
|
||||
|
||||
```kotlin
|
||||
// Legacy encryption (NIP-04, deprecated for NIP-44)
|
||||
object Nip04 {
|
||||
fun encrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
fun decrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues.
|
||||
|
||||
## Bech32 Encoding (NIP-19)
|
||||
|
||||
```kotlin
|
||||
object Nip19 {
|
||||
// Encode
|
||||
fun npubEncode(pubkey: HexKey): String // npub1...
|
||||
fun nsecEncode(privateKey: ByteArray): String // nsec1...
|
||||
fun noteEncode(eventId: HexKey): String // note1...
|
||||
fun neventEncode(eventId: HexKey, relays: List<String> = emptyList()): String
|
||||
fun nprofileEncode(pubkey: HexKey, relays: List<String> = emptyList()): String
|
||||
fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List<String> = emptyList()): String
|
||||
|
||||
// Decode
|
||||
fun decode(bech32: String): Nip19Result
|
||||
}
|
||||
|
||||
sealed class Nip19Result {
|
||||
data class NPub(val hex: HexKey) : Nip19Result()
|
||||
data class NSec(val hex: HexKey) : Nip19Result()
|
||||
data class Note(val hex: HexKey) : Nip19Result()
|
||||
data class NEvent(val hex: HexKey, val relays: List<String>) : Nip19Result()
|
||||
data class NProfile(val hex: HexKey, val relays: List<String>) : Nip19Result()
|
||||
data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List<String>) : Nip19Result()
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
// Encode
|
||||
val npub = Nip19.npubEncode(pubkeyHex)
|
||||
// Output: "npub1..."
|
||||
|
||||
// Decode
|
||||
when (val result = Nip19.decode(npub)) {
|
||||
is Nip19Result.NPub -> println("Pubkey: ${result.hex}")
|
||||
is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}")
|
||||
else -> println("Other type")
|
||||
}
|
||||
```
|
||||
|
||||
## Event Validation
|
||||
|
||||
```kotlin
|
||||
fun Event.verify(): Boolean {
|
||||
// 1. Verify ID matches content hash
|
||||
val computedId = generateId()
|
||||
if (id != computedId) return false
|
||||
|
||||
// 2. Verify signature
|
||||
return Secp256k1.verify(id, sig, pubKey)
|
||||
}
|
||||
|
||||
fun Event.generateId(): HexKey {
|
||||
val serialized = serializeForId() // JSON array format
|
||||
return sha256(serialized)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Always verify events from untrusted sources (relays).
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Publishing an Event
|
||||
|
||||
```kotlin
|
||||
suspend fun publishNote(content: String, signer: ISigner, relays: List<String>) {
|
||||
// 1. Create template
|
||||
val template = eventTemplate(kind = 1, content = content)
|
||||
|
||||
// 2. Sign
|
||||
val event = signer.sign(template)
|
||||
|
||||
// 3. Verify (optional but recommended)
|
||||
require(event.verify()) { "Signature verification failed" }
|
||||
|
||||
// 4. Publish to relays
|
||||
relays.forEach { relay ->
|
||||
relayClient.send(relay, event)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Querying Events
|
||||
|
||||
```kotlin
|
||||
// Subscription filter
|
||||
data class Filter(
|
||||
val ids: List<HexKey>? = null,
|
||||
val authors: List<HexKey>? = null,
|
||||
val kinds: List<Kind>? = null,
|
||||
val since: Long? = null,
|
||||
val until: Long? = null,
|
||||
val limit: Int? = null,
|
||||
val tags: Map<String, List<String>>? = null // e.g., {"#e": [eventId], "#p": [pubkey]}
|
||||
)
|
||||
|
||||
// Usage
|
||||
val filter = Filter(
|
||||
authors = listOf(userPubkey),
|
||||
kinds = listOf(1), // Text notes only
|
||||
limit = 50
|
||||
)
|
||||
|
||||
relayClient.subscribe(relay, filter) { event ->
|
||||
// Handle incoming events
|
||||
}
|
||||
```
|
||||
|
||||
### Creating a Zap (NIP-57)
|
||||
|
||||
```kotlin
|
||||
fun createZapRequest(
|
||||
targetEvent: Event,
|
||||
amountSats: Long,
|
||||
comment: String = ""
|
||||
): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 9734, // Zap request
|
||||
content = comment,
|
||||
tags = tagArray {
|
||||
add(arrayOf("e", targetEvent.id))
|
||||
add(arrayOf("p", targetEvent.pubKey))
|
||||
add(arrayOf("amount", "${amountSats * 1000}")) // millisats
|
||||
add(arrayOf("relays", "wss://relay1.com", "wss://relay2.com"))
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Gift-Wrapped DMs (NIP-17)
|
||||
|
||||
```kotlin
|
||||
fun createGiftWrappedDM(
|
||||
recipientPubkey: HexKey,
|
||||
message: String,
|
||||
signer: ISigner
|
||||
): Event {
|
||||
// 1. Create sealed gossip (kind 14)
|
||||
val sealedGossip = createSealedGossip(message, recipientPubkey, signer)
|
||||
|
||||
// 2. Wrap in gift wrap (kind 1059)
|
||||
return createGiftWrap(sealedGossip, recipientPubkey, signer)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Double encryption + random ephemeral keys for metadata protection.
|
||||
|
||||
## Finding NIPs
|
||||
|
||||
Use the bundled script:
|
||||
|
||||
```bash
|
||||
# Find by NIP number
|
||||
scripts/nip-lookup.sh 44
|
||||
|
||||
# Search by term
|
||||
scripts/nip-lookup.sh encryption
|
||||
scripts/nip-lookup.sh "gift wrap"
|
||||
```
|
||||
|
||||
Or see `references/nip-catalog.md` for complete catalog.
|
||||
|
||||
## Bundled Resources
|
||||
|
||||
- **references/nip-catalog.md** - All 57 NIPs with package locations and key files
|
||||
- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types
|
||||
- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
|
||||
- **scripts/nip-lookup.sh** - Find NIP implementations by number or search term
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Pattern | Location |
|
||||
|------|---------|----------|
|
||||
| Create event | `eventTemplate(kind, content, tags)` | nip01Core/signers/ |
|
||||
| Build tags | `tagArray { add(...) }` | nip01Core/core/ |
|
||||
| Sign event | `signer.sign(template)` | nip01Core/signers/ |
|
||||
| Verify signature | `event.verify()` | nip01Core/core/ |
|
||||
| Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ |
|
||||
| Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ |
|
||||
| Find NIP | `scripts/nip-lookup.sh <number>` | - |
|
||||
|
||||
## Common Event Kinds
|
||||
|
||||
| Kind | Type | NIP | Package |
|
||||
|------|------|-----|---------|
|
||||
| 0 | Metadata | 01 | nip01Core/ |
|
||||
| 1 | Text note | 01, 10 | nip10Notes/ |
|
||||
| 3 | Contact list | 02 | nip02FollowList/ |
|
||||
| 5 | Deletion | 09 | nip09Deletions/ |
|
||||
| 7 | Reaction | 25 | nip25Reactions/ |
|
||||
| 1059 | Gift wrap | 59 | nip59Giftwrap/ |
|
||||
| 9734 | Zap request | 57 | nip57Zaps/ |
|
||||
| 9735 | Zap receipt | 57 | nip57Zaps/ |
|
||||
| 10002 | Relay list | 65 | nip65RelayList/ |
|
||||
| 30023 | Long-form content | 23 | nip23LongContent/ |
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **nostr-protocol** - NIP specifications and protocol details
|
||||
- **kotlin-expert** - Kotlin patterns (@Immutable, sealed classes, DSLs)
|
||||
- **kotlin-coroutines** - Async patterns for relay communication
|
||||
- **kotlin-multiplatform** - KMP patterns, expect/actual in Quartz
|
||||
293
.claude/skills/nostr-expert/references/event-hierarchy.md
Normal file
293
.claude/skills/nostr-expert/references/event-hierarchy.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Event Hierarchy & Structure
|
||||
|
||||
## Core Hierarchy
|
||||
|
||||
```
|
||||
IEvent (empty interface)
|
||||
└── Event (@Immutable base class)
|
||||
├── BaseAddressableEvent (replaceable + addressable, has d-tag)
|
||||
│ ├── BaseReplaceableEvent (kinds 10000-20000, FIXED_D_TAG = "")
|
||||
│ └── [Specific addressable events - 30000-40000]
|
||||
└── [Specific event implementations - all other kinds]
|
||||
```
|
||||
|
||||
## Event Base Class
|
||||
|
||||
**Location**: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt`
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
open class Event(
|
||||
val id: HexKey, // SHA-256 hash of serialized event
|
||||
val pubKey: HexKey, // Author's public key (32 bytes hex)
|
||||
val createdAt: Long, // Unix timestamp
|
||||
val kind: Kind, // Event kind (Int typealias)
|
||||
val tags: TagArray, // Array of tag arrays
|
||||
val content: String, // Event content
|
||||
val sig: HexKey, // schnorr signature (64 bytes hex)
|
||||
) : IEvent, OptimizedSerializable
|
||||
```
|
||||
|
||||
## Kind Classification
|
||||
|
||||
```kotlin
|
||||
typealias Kind = Int
|
||||
|
||||
fun Kind.isEphemeral() = this in 20000..29999
|
||||
fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999
|
||||
fun Kind.isAddressable() = this in 30000..39999
|
||||
fun Kind.isRegular() = this in 1000..9999
|
||||
```
|
||||
|
||||
## Common Event Types
|
||||
|
||||
### Text Note (kind 1)
|
||||
```kotlin
|
||||
class TextNoteEvent(...) : BaseThreadedEvent(...),
|
||||
EventHintProvider, AddressHintProvider, PubKeyHintProvider, SearchableEvent
|
||||
|
||||
// Threading support via markers: reply, root, mention
|
||||
fun replyTo(): List<Note> // Direct reply targets
|
||||
fun root(): Note? // Root of thread
|
||||
```
|
||||
|
||||
### Metadata (kind 0)
|
||||
```kotlin
|
||||
class MetadataEvent(...) : BaseAddressableEvent(...)
|
||||
|
||||
// Replaceable: newest version overwrites old
|
||||
// d-tag automatically set to "" for kind 0
|
||||
fun name(): String?
|
||||
fun displayName(): String?
|
||||
fun picture(): String?
|
||||
fun about(): String?
|
||||
fun lnAddress(): String?
|
||||
```
|
||||
|
||||
### Reaction (kind 7)
|
||||
```kotlin
|
||||
class ReactionEvent(...) : Event(...)
|
||||
|
||||
companion object {
|
||||
const val LIKE = "+"
|
||||
const val DISLIKE = "-"
|
||||
|
||||
fun like(reactedTo: EventHintBundle<Event>, ...)
|
||||
fun dislike(reactedTo: EventHintBundle<Event>, ...)
|
||||
}
|
||||
```
|
||||
|
||||
### Zap Request/Receipt (kinds 9734, 9735)
|
||||
```kotlin
|
||||
class LnZapRequestEvent(...) : Event(...)
|
||||
// Created by client, sent to Lightning Address
|
||||
|
||||
class LnZapEvent(...) : Event(...)
|
||||
// Receipt from LSP, contains bolt11 + embedded zap request
|
||||
val zapRequest: LnZapRequestEvent? by lazy { containedPost() }
|
||||
val amount: BigDecimal? by lazy { /* parse from bolt11 */ }
|
||||
```
|
||||
|
||||
### Long-Form Content (kind 30023)
|
||||
```kotlin
|
||||
class LongTextNoteEvent(...) : BaseAddressableEvent(...)
|
||||
// Blog posts, articles
|
||||
// Addressable via kind:pubkey:d-tag
|
||||
```
|
||||
|
||||
### Lists (kinds 10000-30004)
|
||||
```kotlin
|
||||
sealed class PeopleListEvent : BaseAddressableEvent {
|
||||
object MuteList : PeopleListEvent(10000)
|
||||
object PinList : PeopleListEvent(10001)
|
||||
object BookmarkList : PeopleListEvent(10003)
|
||||
// ... 18 list types total
|
||||
}
|
||||
```
|
||||
|
||||
## Event Interfaces
|
||||
|
||||
### Hint Providers
|
||||
Events can implement interfaces to optimize relay queries:
|
||||
|
||||
```kotlin
|
||||
interface EventHintProvider {
|
||||
fun taggedEventIds(): Set<HexKey>
|
||||
fun taggedEventRelays(): Map<HexKey, Set<NormalizedRelayUrl>>
|
||||
}
|
||||
|
||||
interface PubKeyHintProvider {
|
||||
fun taggedPubKeys(): Set<HexKey>
|
||||
fun taggedPubKeyRelays(): Map<HexKey, Set<NormalizedRelayUrl>>
|
||||
}
|
||||
|
||||
interface AddressHintProvider {
|
||||
fun taggedAddresses(): Set<Address>
|
||||
fun taggedAddressRelays(): Map<Address, Set<NormalizedRelayUrl>>
|
||||
}
|
||||
|
||||
interface SearchableEvent {
|
||||
fun subject(): String?
|
||||
fun isContentEncoded(): Boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Event Building Pattern
|
||||
|
||||
### DSL Builder
|
||||
```kotlin
|
||||
TextNoteEvent.build(
|
||||
note = "Hello Nostr",
|
||||
replyingTo = eventBundle,
|
||||
createdAt = TimeUtils.now()
|
||||
) {
|
||||
pTag(pubKey, relayHint) // Tag person
|
||||
eTag(eventId, relayHint, "reply") // Tag event with marker
|
||||
hashtag("nostr") // Add hashtag
|
||||
alt("A short note") // Alt text
|
||||
}
|
||||
```
|
||||
|
||||
### Event Template (Low-level)
|
||||
```kotlin
|
||||
suspend fun eventTemplate(
|
||||
kind: Kind,
|
||||
content: String,
|
||||
createdAt: Long,
|
||||
initializer: TagArrayBuilder.() -> Unit
|
||||
): EventTemplate {
|
||||
val tags = TagArrayBuilder().apply(initializer).build()
|
||||
return EventTemplate(kind, tags, content, createdAt)
|
||||
}
|
||||
|
||||
// Sign with signer
|
||||
val template = eventTemplate(1, "Hello", now()) { pTag(pubkey) }
|
||||
val signedEvent = signer.sign(template)
|
||||
```
|
||||
|
||||
## Addressable vs Regular Events
|
||||
|
||||
| Feature | Regular Event | Addressable Event |
|
||||
|---------|---------------|-------------------|
|
||||
| **Identifier** | Event ID (SHA-256 hash) | Address (kind:pubkey:d-tag) |
|
||||
| **Replaceability** | Immutable | Newest replaces old |
|
||||
| **d-tag** | Optional | Required |
|
||||
| **Lookup** | By event ID | By address |
|
||||
| **Example** | Text note (kind 1) | Metadata (kind 0), Long-form (kind 30023) |
|
||||
|
||||
```kotlin
|
||||
// Regular event address
|
||||
note = LocalCache.getNoteIfExists(eventId)
|
||||
|
||||
// Addressable event address
|
||||
address = Address(kind = 30023, pubkey = authorHex, dTag = "my-article")
|
||||
note = LocalCache.getAddressableNoteIfExists(address)
|
||||
```
|
||||
|
||||
## Event Validation
|
||||
|
||||
```kotlin
|
||||
// Verify event ID matches computed hash
|
||||
fun Event.verifyId(): Boolean =
|
||||
EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content)
|
||||
|
||||
// Verify signature
|
||||
fun Event.verifySignature(): Boolean =
|
||||
Nip01.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
|
||||
|
||||
// Complete verification
|
||||
fun Event.checkSignature() {
|
||||
if (!verifyId()) throw Exception("ID mismatch")
|
||||
if (!verifySignature()) throw Exception("Bad signature!")
|
||||
}
|
||||
```
|
||||
|
||||
## Event Serialization
|
||||
|
||||
```kotlin
|
||||
// To JSON (for transmission/signing)
|
||||
fun Event.toJson(): String = OptimizedJsonMapper.toJson(this)
|
||||
|
||||
// From JSON
|
||||
fun Event.fromJson(json: String): Event = OptimizedJsonMapper.fromJson(json)
|
||||
|
||||
// Event ID generation (SHA-256 of canonical JSON)
|
||||
fun EventHasher.hashId(
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Kind,
|
||||
tags: TagArray,
|
||||
content: String
|
||||
): HexKey {
|
||||
val serialized = """[0,"$pubKey",$createdAt,$kind,${tags.toJson()},"$content"]"""
|
||||
return sha256(serialized.encodeToByteArray()).toHexKey()
|
||||
}
|
||||
```
|
||||
|
||||
## Event Lifecycle in LocalCache
|
||||
|
||||
```
|
||||
Event received from relay
|
||||
↓
|
||||
LocalCache.consume(event, relay, wasVerified)
|
||||
↓
|
||||
getOrCreateNote(event.id) or getOrCreateAddressableNote(address)
|
||||
↓
|
||||
justVerify(event) → checkSignature()
|
||||
↓
|
||||
note.loadEvent(event, author, replyTo)
|
||||
↓
|
||||
Update indices (replies, reactions, boosts)
|
||||
↓
|
||||
refreshNewNoteObservers(note) → emit to SharedFlow
|
||||
↓
|
||||
UI updates
|
||||
```
|
||||
|
||||
## Common Event Patterns
|
||||
|
||||
### Reply Threading
|
||||
```kotlin
|
||||
// Root event (top of thread)
|
||||
val rootEvent = TextNoteEvent.build("Thread root") { }
|
||||
|
||||
// Reply to root
|
||||
val reply1 = TextNoteEvent.build("First reply", replyingTo = rootEvent) {
|
||||
// Automatically adds:
|
||||
// ["e", <root_id>, <relay>, "root"]
|
||||
// ["e", <root_id>, <relay>, "reply"]
|
||||
}
|
||||
|
||||
// Reply to reply (nested)
|
||||
val reply2 = TextNoteEvent.build("Nested reply", replyingTo = reply1) {
|
||||
// Automatically adds:
|
||||
// ["e", <root_id>, <relay>, "root"]
|
||||
// ["e", <reply1_id>, <relay>, "reply"]
|
||||
}
|
||||
```
|
||||
|
||||
### Replaceable Events
|
||||
```kotlin
|
||||
// Metadata update (kind 0) - newest wins
|
||||
val metadata1 = MetadataEvent.createNew(name = "Alice", picture = "url1")
|
||||
Thread.sleep(1000)
|
||||
val metadata2 = MetadataEvent.createNew(name = "Alice Updated", picture = "url2")
|
||||
|
||||
// LocalCache keeps only metadata2 (higher createdAt)
|
||||
```
|
||||
|
||||
### Event Deletion
|
||||
```kotlin
|
||||
// Delete events
|
||||
val deletion = DeletionEvent.create(
|
||||
deleteEvents = listOf(eventId1, eventId2),
|
||||
reason = "Spam",
|
||||
signer = signer
|
||||
)
|
||||
|
||||
// LocalCache marks events as deleted, but doesn't remove (for verification)
|
||||
```
|
||||
|
||||
## 63+ Event Classes
|
||||
|
||||
Full list at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip*/` - one class per event type across 60+ NIP implementations.
|
||||
179
.claude/skills/nostr-expert/references/nip-catalog.md
Normal file
179
.claude/skills/nostr-expert/references/nip-catalog.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz
|
||||
|
||||
## Standard NIPs by Category
|
||||
|
||||
### Core/Basic Protocol
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 01 | `nip01Core/` | Event.kt, Kind.kt, Tag.kt | Core protocol, event structure, kinds, tags |
|
||||
| 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) |
|
||||
| 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps |
|
||||
| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) |
|
||||
| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification |
|
||||
| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation |
|
||||
| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) |
|
||||
| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata |
|
||||
| 13 | `nip13Pow/` | ProofOfWork.kt | Proof of work |
|
||||
| 14 | `nip14Subject/` | Subject tags | Subject tags for text notes |
|
||||
| 17 | `nip17Dm/` | GiftWrapEvent.kt, SealedGossipEvent.kt | Private DMs (replacem
|
||||
|
||||
ent for NIP-04) |
|
||||
| 21 | `nip21UriScheme/` | URI scheme (`nostr:`) | URI scheme parsing |
|
||||
| 42 | `nip42RelayAuth/` | RelayAuthEvent.kt | Relay authentication (kind 22242) |
|
||||
| 44 | `nip44Encryption/` | Nip44.kt, Nip44v2.kt | Modern encryption (ChaCha20) |
|
||||
| 49 | `nip49PrivKeyEnc/` | NIP-49Ncryptsec.kt | Private key encryption format |
|
||||
|
||||
### Content Types
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 10 | `nip10Notes/` | TextNoteEvent.kt | Text notes with threading (kind 1) |
|
||||
| 18 | `nip18Reposts/` | RepostEvent.kt, GenericRepostEvent.kt | Reposts (kind 6, 16) |
|
||||
| 22 | `nip22Comments/` | CommentEvent.kt | Comments (kind 1111) |
|
||||
| 23 | `nip23LongContent/` | LongTextNoteEvent.kt | Long-form content (kind 30023) |
|
||||
| 25 | `nip25Reactions/` | ReactionEvent.kt | Reactions (kind 7) |
|
||||
| 31 | `nip31Alts/` | Alt tags | Alt description tags |
|
||||
| 36 | `nip36SensitiveContent/` | Content warnings | Content warning tags |
|
||||
| 37 | `nip37Drafts/` | DraftEvent.kt | Drafts (kind 31234) |
|
||||
| 50 | `nip50Search/` | Search filters | Full-text search |
|
||||
|
||||
### Encoding & Standards
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 19 | `nip19Bech32/` | Nip19.kt | Bech32 encoding (npub, nsec, note, nevent, nprofile, naddr) |
|
||||
| 40 | `nip40Expiration/` | Expiration tags | Event expiration |
|
||||
| 48 | `nip48ProxyTags/` | Proxy tags | Proxy tags for delegation |
|
||||
| 62 | `nip62RequestToVanish/` | RequestToVanishEvent.kt | Request to vanish (kind 12) |
|
||||
| 98 | `nip98HttpAuth/` | HTTP authorization | HTTP auth header |
|
||||
|
||||
### Lists & Management
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 51 | `nip51Lists/` | 18 list types | Named lists (mute, bookmarks, pins, communities, etc.) (kinds 10000-30004) |
|
||||
| 65 | `nip65RelayList/` | AdvertisedRelayListEvent.kt | Relay lists (kind 10002) |
|
||||
|
||||
### Social & Identity
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 39 | `nip39ExtIdentities/` | External identities | External identity claims |
|
||||
| 46 | `nip46RemoteSigner/` | NostrConnectEvent.kt | Remote signer protocol (bunker) |
|
||||
| 47 | `nip47WalletConnect/` | Nostr Wallet Connect | Wallet connection protocol |
|
||||
| 56 | `nip56Reports/` | ReportEvent.kt | Reports (kind 1984) |
|
||||
| 57 | `nip57Zaps/` | LnZapEvent.kt, LnZapRequestEvent.kt | Lightning zaps (kinds 9734, 9735) |
|
||||
| 58 | `nip58Badges/` | Badge events | Badge definitions & awards (kinds 30009, 8) |
|
||||
| 59 | `nip59Giftwrap/` | GiftWrapEvent.kt | Gift-wrapped events for privacy |
|
||||
| 75 | `nip75ZapGoals/` | ZapGoalEvent.kt | Zap goals (kind 9041) |
|
||||
|
||||
### Specialized Content
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 28 | `nip28PublicChat/` | ChannelCreateEvent.kt, ChannelMessageEvent.kt | Public chat channels (kinds 40-44) |
|
||||
| 30 | `nip30CustomEmoji/` | EmojiUrl.kt | Custom emoji |
|
||||
| 34 | `nip34Git/` | Git patch/issue events | Git repository tracking (kinds 30617, 30618, 1617, 1621, 1622, 1630, 1633) |
|
||||
| 35 | `nip35Torrents/` | Torrent events | Torrent tracking |
|
||||
| 52 | `nip52Calendar/` | Calendar events | Calendar time-based/date-based (kinds 31922-31925) |
|
||||
| 53 | `nip53LiveActivities/` | LiveActivitiesEvent.kt | Live events/streaming (kind 30311) |
|
||||
| 54 | `nip54Wiki/` | WikiNoteEvent.kt | Wiki pages (kind 30818) |
|
||||
| 68 | `nip68Picture/` | Picture metadata | Picture metadata |
|
||||
| 71 | `nip71Video/` | 7 video event types | Video events (kinds 34235, 35235, 1234, 1235) |
|
||||
| 72 | `nip72ModCommunities/` | Community events | Moderated communities (kinds 34550, 34551, 9041) |
|
||||
| 84 | `nip84Highlights/` | HighlightEvent.kt | Highlights (kind 9802) |
|
||||
| 89 | `nip89AppHandlers/` | AppDefinitionEvent.kt | App recommendations (kinds 31990, 31989) |
|
||||
| 90 | `nip90Dvms/` | DVM job events | Data Vending Machines (DVMs) (kinds 5000-7000) |
|
||||
| 92 | `nip92IMeta/` | IMeta tags | Image metadata tags |
|
||||
| 94 | `nip94FileMetadata/` | FileHeaderEvent.kt, FileStorageEvent.kt | File metadata (kind 1063) |
|
||||
| 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage |
|
||||
| 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) |
|
||||
| A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events |
|
||||
| B7 | `nipB7Blossom/` | Blossom server URLs | Blossom file storage |
|
||||
|
||||
### Web/Storage/Other
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 38 | `nip38UserStatus/` | StatusEvent.kt | User status (kind 30315) |
|
||||
| 60 | `nip60Payment/` | Wallet events | Wallet info (kind 13194) |
|
||||
| 61 | `nip61PaymentRequest/` | Nut zaps | Cashu payment requests |
|
||||
| 64 | `nip64Chess/` | Chess moves | Chess move events |
|
||||
| 66 | `nip66Monitoring/` | Relay monitor events | Relay monitoring |
|
||||
| 67 | `nip67Invoices/` | Invoice tags | Lightning invoice tags |
|
||||
| 69 | `nip69Offers/` | BOLT-12 offers | BOLT-12 offer tags |
|
||||
| 70 | `nip70ProtectedEvts/` | Protected events | Protected event types |
|
||||
| 73 | `nip73ExternalIds/` | External content IDs | External content identifiers |
|
||||
| 78 | `nip78AppData/` | AppDataEvent.kt | Application data (kind 30078) |
|
||||
| 79 | `nip79Labels/` | Label events | Labeling (kinds 1985, 1986) |
|
||||
| 80-88 | Various | Various protocols | Relationship, preferences, polls, surveys, social graphs, etc. |
|
||||
| 91 | `nip91Feed/` | Feed display events | Feed definitions |
|
||||
| 93 | `nip93Gallery/` | Gallery events | Gallery collections |
|
||||
| 95 | `nip95Storage/` | Storage event tags | Storage events |
|
||||
| 97 | `nip97Nests/` | Audio rooms | Audio room events |
|
||||
|
||||
## Experimental NIPs (18 packages)
|
||||
|
||||
Located at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/`:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `audio/` | Audio content, track events |
|
||||
| `bounties/` | Bounty/funding events |
|
||||
| `decoupling/` | Decoupling setup |
|
||||
| `edits/` | Event edit tracking |
|
||||
| `ephemChat/` | Ephemeral encrypted chat |
|
||||
| `forks/` | Fork tracking |
|
||||
| `inlineMetadata/` | Inline metadata |
|
||||
| `interactiveStories/` | Interactive story events |
|
||||
| `limits/` | Limit enforcement |
|
||||
| `medical/` | Medical data |
|
||||
| `nip95/` | File storage support |
|
||||
| `nipA3/` | A3 protocol extension |
|
||||
| `nns/` | Nostr Name System |
|
||||
| `profileGallery/` | Profile gallery lists |
|
||||
| `publicMessages/` | Public message lists |
|
||||
| `relationshipStatus/` | Relationship status events |
|
||||
| `trustedAssertions/` | Trust/assertion events |
|
||||
| `zapPolls/` | Zap-based polling |
|
||||
|
||||
## Quick Lookup by Kind
|
||||
|
||||
| Kind | Event Type | NIP |
|
||||
|------|------------|-----|
|
||||
| 0 | Metadata | 01 |
|
||||
| 1 | Text Note | 01, 10 |
|
||||
| 3 | Follow List | 02 |
|
||||
| 4 | Encrypted DM (legacy) | 04 |
|
||||
| 5 | Deletion | 09 |
|
||||
| 6 | Repost | 18 |
|
||||
| 7 | Reaction | 25 |
|
||||
| 8 | Badge Award | 58 |
|
||||
| 16 | Generic Repost | 18 |
|
||||
| 40-44 | Channel Events | 28 |
|
||||
| 1063 | File Metadata | 94 |
|
||||
| 1111 | Comment | 22 |
|
||||
| 1617, 1621, 1622, 1630, 1633 | Git | 34 |
|
||||
| 1984 | Report | 56 |
|
||||
| 1985, 1986 | Label | 79 |
|
||||
| 9734 | Zap Request | 57 |
|
||||
| 9735 | Zap Receipt | 57 |
|
||||
| 9802 | Highlight | 84 |
|
||||
| 10000-20000 | Replaceable Lists | 51 |
|
||||
| 10002 | Relay List | 65 |
|
||||
| 13194 | Wallet Info | 60 |
|
||||
| 22242 | Relay Auth | 42 |
|
||||
| 23194, 23195 | NWC Payment | 47 |
|
||||
| 30000-40000 | Addressable Events | Various |
|
||||
| 30009 | Badge Definition | 58 |
|
||||
| 30023 | Long-Form Content | 23 |
|
||||
| 30078 | App Data | 78 |
|
||||
| 30311 | Live Event | 53 |
|
||||
| 30315 | User Status | 38 |
|
||||
| 30402 | Classifieds | 99 |
|
||||
| 30818 | Wiki | 54 |
|
||||
| 31234 | Draft | 37 |
|
||||
| 31922-31925 | Calendar | 52 |
|
||||
| 31989, 31990 | App Handlers | 89 |
|
||||
| 34235, 34550-34551 | Video/Communities | 71, 72 |
|
||||
| 5000-7000 | DVM Jobs | 90 |
|
||||
|
||||
## File Location Pattern
|
||||
|
||||
All NIPs located at: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip<NN><Name>/`
|
||||
|
||||
Example: NIP-57 → `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/`
|
||||
336
.claude/skills/nostr-expert/references/tag-patterns.md
Normal file
336
.claude/skills/nostr-expert/references/tag-patterns.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# Tag Patterns in Quartz
|
||||
|
||||
Tags are the primary way events reference other events, users, and metadata in Nostr.
|
||||
|
||||
## Tag Structure
|
||||
|
||||
```kotlin
|
||||
typealias Tag = Array<String> // ["tag_name", "value", "optional_param", ...]
|
||||
typealias TagArray = Array<Tag>
|
||||
```
|
||||
|
||||
**Pattern**: `[name, value, ...optionalParams]`
|
||||
|
||||
## TagArrayBuilder DSL
|
||||
|
||||
```kotlin
|
||||
fun tagArray(initializer: TagArrayBuilder<T>.() -> Unit): TagArray
|
||||
```
|
||||
|
||||
**Methods**:
|
||||
- `add(tag)` - Append tag
|
||||
- `addFirst(tag)` - Prepend tag
|
||||
- `addUnique(tag)` - Replace all tags with this name
|
||||
- `remove(tagName)` - Remove all tags with name
|
||||
- `removeIf(predicate, toCompare)` - Conditional removal
|
||||
|
||||
**Example**:
|
||||
```kotlin
|
||||
val tags = tagArray<TextNoteEvent> {
|
||||
add(arrayOf("e", eventId, relayHint, "reply"))
|
||||
add(arrayOf("p", pubkey))
|
||||
addUnique(arrayOf("subject", "Hello"))
|
||||
}
|
||||
```
|
||||
|
||||
## Core Tag Types (NIP-01)
|
||||
|
||||
### e-tag (Event Reference)
|
||||
```kotlin
|
||||
// ["e", <event-id>, <relay-hint>, <marker>]
|
||||
arrayOf("e", eventId, "wss://relay.damus.io", "reply")
|
||||
```
|
||||
|
||||
**Markers** (NIP-10):
|
||||
- `root` - Root of thread
|
||||
- `reply` - Direct reply target
|
||||
- `mention` - Mentioned event (not reply)
|
||||
|
||||
**Extensions**:
|
||||
```kotlin
|
||||
// nip01Core/tags/
|
||||
fun TagArrayBuilder.eTag(eventId: HexKey, relay: String? = null, marker: String? = null)
|
||||
```
|
||||
|
||||
### p-tag (Pubkey Reference)
|
||||
```kotlin
|
||||
// ["p", <pubkey>, <relay-hint>]
|
||||
arrayOf("p", pubkey, "wss://relay.damus.io")
|
||||
```
|
||||
|
||||
**Usage**: Tag users, indicate recipients
|
||||
|
||||
**Extensions**:
|
||||
```kotlin
|
||||
fun TagArrayBuilder.pTag(pubkey: HexKey, relay: String? = null)
|
||||
```
|
||||
|
||||
### a-tag (Addressable Event Reference)
|
||||
```kotlin
|
||||
// ["a", <kind>:<pubkey>:<d-tag>, <relay-hint>]
|
||||
arrayOf("a", "30023:${authorPubkey}:${dtag}", "wss://relay.damus.io")
|
||||
```
|
||||
|
||||
**Usage**: Reference replaceable/addressable events (kinds 10000-20000, 30000-40000)
|
||||
|
||||
**Extensions**:
|
||||
```kotlin
|
||||
fun TagArrayBuilder.aTag(kind: Int, pubkey: HexKey, dTag: String, relay: String? = null)
|
||||
```
|
||||
|
||||
### d-tag (Identifier)
|
||||
```kotlin
|
||||
// ["d", <identifier>]
|
||||
arrayOf("d", "my-article-slug")
|
||||
```
|
||||
|
||||
**Usage**: Unique identifier for addressable events
|
||||
|
||||
## Common Tag Extensions
|
||||
|
||||
### Subject (NIP-14)
|
||||
```kotlin
|
||||
// nip14Subject/
|
||||
fun Event.subject(): String?
|
||||
fun TagArrayBuilder.subject(text: String)
|
||||
```
|
||||
|
||||
### Content Warning (NIP-36)
|
||||
```kotlin
|
||||
// nip36SensitiveContent/
|
||||
fun Event.contentWarning(): String?
|
||||
fun TagArrayBuilder.contentWarning(reason: String = "")
|
||||
```
|
||||
|
||||
### Expiration (NIP-40)
|
||||
```kotlin
|
||||
// nip40Expiration/
|
||||
fun Event.expiration(): Long?
|
||||
fun TagArrayBuilder.expiration(unixTimestamp: Long)
|
||||
```
|
||||
|
||||
### Alt Description (NIP-31)
|
||||
```kotlin
|
||||
// nip31Alts/
|
||||
fun Event.alt(): String?
|
||||
fun TagArrayBuilder.alt(description: String)
|
||||
```
|
||||
|
||||
## Specialized Tags
|
||||
|
||||
### Zap Tags (NIP-57)
|
||||
```kotlin
|
||||
// nip57Zaps/tags/
|
||||
class BoltTag(val bolt11: String, val preimage: String?)
|
||||
class DescriptionTag(val zapRequestJson: String)
|
||||
```
|
||||
|
||||
### Imeta Tags (NIP-92)
|
||||
```kotlin
|
||||
// nip92IMeta/
|
||||
class IMetaTag(val url: String, val metadata: Map<String, String>)
|
||||
|
||||
// Usage: Image metadata
|
||||
IMetaTag("https://example.com/image.jpg", mapOf(
|
||||
"m" to "image/jpeg",
|
||||
"dim" to "1920x1080",
|
||||
"blurhash" to "..."
|
||||
))
|
||||
```
|
||||
|
||||
### Relay Tags (NIP-65)
|
||||
```kotlin
|
||||
// nip65RelayList/
|
||||
class RelayTag(val url: String, val type: RelayType)
|
||||
enum class RelayType { READ, WRITE, BOTH }
|
||||
```
|
||||
|
||||
## Tag Query Patterns
|
||||
|
||||
### Finding Tags
|
||||
```kotlin
|
||||
// Extension functions on TagArray
|
||||
fun TagArray.firstTag(name: String): Tag?
|
||||
fun TagArray.allTags(name: String): List<Tag>
|
||||
fun TagArray.tagValue(name: String): String?
|
||||
fun TagArray.tagValues(name: String): List<String>
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```kotlin
|
||||
val event: TextNoteEvent = ...
|
||||
val subject = event.tags.tagValue("subject")
|
||||
val mentions = event.tags.allTags("p").mapNotNull { it.getOrNull(1) }
|
||||
```
|
||||
|
||||
### Parsing Tags
|
||||
```kotlin
|
||||
// Pattern: Companion object with parse methods
|
||||
object ETag {
|
||||
fun parse(tag: Tag): ETag? {
|
||||
if (tag.getOrNull(0) != "e") return null
|
||||
return ETag(
|
||||
eventId = tag.getOrNull(1) ?: return null,
|
||||
relay = tag.getOrNull(2),
|
||||
marker = tag.getOrNull(3)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
val eTags = event.tags.mapNotNull(ETag::parse)
|
||||
```
|
||||
|
||||
## Event Builder Pattern
|
||||
|
||||
Combining TagArrayBuilder with event creation:
|
||||
|
||||
```kotlin
|
||||
fun createTextNote(content: String, replyTo: Event?): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 1,
|
||||
content = content,
|
||||
tags = tagArray {
|
||||
replyTo?.let {
|
||||
eTag(it.id, marker = "reply")
|
||||
pTag(it.pubKey)
|
||||
it.rootEvent()?.let { root ->
|
||||
eTag(root.id, marker = "root")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Hint System
|
||||
|
||||
Tags can provide "hints" - optional relay URLs for fetching referenced content:
|
||||
|
||||
```kotlin
|
||||
// Event references
|
||||
["e", eventId, "wss://relay.example.com"] // relay hint
|
||||
|
||||
// Pubkey references
|
||||
["p", pubkey, "wss://relay.example.com"] // relay hint
|
||||
|
||||
// Addressable references
|
||||
["a", "30023:pubkey:dtag", "wss://relay.example.com"] // relay hint
|
||||
```
|
||||
|
||||
**Pattern**: Third parameter (index 2) is always the relay hint
|
||||
|
||||
## Tag Validation
|
||||
|
||||
```kotlin
|
||||
// Common validations
|
||||
fun validateETag(tag: Tag): Boolean {
|
||||
return tag.getOrNull(0) == "e" && tag.getOrNull(1)?.isValidHex() == true
|
||||
}
|
||||
|
||||
fun validatePTag(tag: Tag): Boolean {
|
||||
return tag.getOrNull(0) == "p" && tag.getOrNull(1)?.isValidHex() == true
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### Tag Indexing
|
||||
```kotlin
|
||||
// TagArrayBuilder keeps an index by tag name
|
||||
private val tagList = mutableMapOf<String, MutableList<Tag>>()
|
||||
|
||||
// Fast lookup by name
|
||||
fun remove(tagName: String) {
|
||||
tagList.remove(tagName)
|
||||
}
|
||||
```
|
||||
|
||||
### Lazy Parsing
|
||||
```kotlin
|
||||
// Don't parse all tags upfront
|
||||
class TextNoteEvent(...) {
|
||||
private val _mentions by lazy {
|
||||
tags.mapNotNull(PTag::parse)
|
||||
}
|
||||
|
||||
fun mentions() = _mentions
|
||||
}
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Creating a Reply
|
||||
```kotlin
|
||||
fun replyTo(original: TextNoteEvent, content: String): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 1,
|
||||
content = content,
|
||||
tags = tagArray {
|
||||
// Reply to this event
|
||||
eTag(original.id, marker = "reply")
|
||||
|
||||
// Copy root marker if exists, or mark original as root
|
||||
original.rootEvent()?.let {
|
||||
eTag(it.id, marker = "root")
|
||||
} ?: eTag(original.id, marker = "root")
|
||||
|
||||
// Tag author
|
||||
pTag(original.pubKey)
|
||||
|
||||
// Tag all mentioned users
|
||||
original.mentions().forEach { pTag(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Creating a Reaction
|
||||
```kotlin
|
||||
fun createReaction(targetEvent: Event, emoji: String): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 7,
|
||||
content = emoji,
|
||||
tags = tagArray {
|
||||
eTag(targetEvent.id)
|
||||
pTag(targetEvent.pubKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Creating an Addressable Event
|
||||
```kotlin
|
||||
fun createArticle(title: String, content: String, slug: String): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 30023,
|
||||
content = content,
|
||||
tags = tagArray {
|
||||
addUnique(arrayOf("d", slug)) // Unique identifier
|
||||
add(arrayOf("title", title))
|
||||
add(arrayOf("published_at", "${TimeUtils.now()}"))
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Tag | NIP | Usage | Example |
|
||||
|-----|-----|-------|---------|
|
||||
| e | 01 | Event reference | `["e", eventId, relay, marker]` |
|
||||
| p | 01 | Pubkey reference | `["p", pubkey, relay]` |
|
||||
| a | 01 | Addressable event | `["a", "kind:pubkey:d"]` |
|
||||
| d | 01 | Identifier | `["d", "unique-id"]` |
|
||||
| subject | 14 | Subject line | `["subject", "Hello"]` |
|
||||
| content-warning | 36 | Content warning | `["content-warning", "nsfw"]` |
|
||||
| expiration | 40 | Expiration time | `["expiration", "1234567890"]` |
|
||||
| bolt11 | 57 | Lightning invoice | `["bolt11", "lnbc..."]` |
|
||||
| imeta | 92 | Media metadata | `["imeta", "url", "m", "image/jpeg"]` |
|
||||
| relay | 65 | User relays | `["relay", "wss://...", "read"]` |
|
||||
|
||||
## Resources
|
||||
|
||||
- Tag builders: `quartz/src/commonMain/.../nip01Core/tags/`
|
||||
- Tag extensions: Look for `TagArrayExt.kt`, `TagArrayBuilderExt.kt` in each NIP package
|
||||
- Event parsing: Each event class has tag parsing methods
|
||||
58
.claude/skills/nostr-expert/scripts/nip-lookup.sh
Executable file
58
.claude/skills/nostr-expert/scripts/nip-lookup.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# Find NIP implementation files by NIP number or search term
|
||||
|
||||
set -e
|
||||
|
||||
QUARTZ_PATH="${QUARTZ_PATH:-./quartz/src/commonMain/kotlin/com/vitorpamplona/quartz}"
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <nip-number|search-term>"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 01 # Find NIP-01 files"
|
||||
echo " $0 44 # Find NIP-44 files"
|
||||
echo " $0 encryption # Search for 'encryption' in NIP packages"
|
||||
echo ""
|
||||
echo "Set QUARTZ_PATH to override default quartz location"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SEARCH_TERM="$1"
|
||||
|
||||
# Check if it's a number (NIP number)
|
||||
if [[ "$SEARCH_TERM" =~ ^[0-9]+$ ]]; then
|
||||
# Pad to 2 digits
|
||||
NIP_NUM=$(printf "%02d" "$SEARCH_TERM")
|
||||
echo "Searching for NIP-$NIP_NUM implementation..."
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Find directories matching nip##*
|
||||
find "$QUARTZ_PATH" -type d -name "nip${NIP_NUM}*" | while read -r dir; do
|
||||
echo "📁 $(basename "$dir")/"
|
||||
find "$dir" -name "*.kt" -type f | while read -r file; do
|
||||
rel_path="${file#$QUARTZ_PATH/}"
|
||||
echo " └─ $rel_path"
|
||||
done
|
||||
echo ""
|
||||
done
|
||||
else
|
||||
# Text search
|
||||
echo "Searching for '$SEARCH_TERM' in NIP packages..."
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
find "$QUARTZ_PATH" -type d -name "nip*" | while read -r dir; do
|
||||
if grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | head -1 > /dev/null; then
|
||||
echo "📁 $(basename "$dir")/"
|
||||
grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | while read -r file; do
|
||||
rel_path="${file#$QUARTZ_PATH/}"
|
||||
matches=$(grep -c -i "$SEARCH_TERM" "$file" 2>/dev/null || echo "0")
|
||||
echo " └─ $rel_path ($matches matches)"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
671
.claude/skills/quartz-integration/SKILL.md
Normal file
671
.claude/skills/quartz-integration/SKILL.md
Normal file
@@ -0,0 +1,671 @@
|
||||
---
|
||||
name: quartz-integration
|
||||
description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects.
|
||||
---
|
||||
|
||||
# Quartz Integration Guide
|
||||
|
||||
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
|
||||
|
||||
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.2` (Maven Central)
|
||||
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
|
||||
**License**: MIT
|
||||
|
||||
---
|
||||
|
||||
## 1. Gradle Setup
|
||||
|
||||
### Version Catalog (`libs.versions.toml`)
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
quartz = "1.06.2"
|
||||
|
||||
[libraries]
|
||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||
```
|
||||
|
||||
### `build.gradle.kts` (KMP project)
|
||||
|
||||
```kotlin
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.quartz)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Android-only project
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.06.2")
|
||||
}
|
||||
```
|
||||
|
||||
### Transitive dependencies pulled in automatically
|
||||
|
||||
Quartz exposes these as `api` (you get them transitively):
|
||||
|
||||
| Dependency | Used for |
|
||||
|-----------|----------|
|
||||
| `fr.acinq.secp256k1:secp256k1-kmp-*` | Schnorr signing |
|
||||
| `com.github.anthonynsimon:rfc3986-normalizer` | Relay URL normalization |
|
||||
| `com.fasterxml.jackson.module:jackson-module-kotlin` | Event JSON parsing |
|
||||
|
||||
For Android, add to `build.gradle.kts`:
|
||||
```kotlin
|
||||
android {
|
||||
packaging {
|
||||
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Key Concepts
|
||||
|
||||
### Core Types
|
||||
|
||||
```kotlin
|
||||
typealias HexKey = String // 64-char hex string (pubkey, event id, sig)
|
||||
typealias Kind = Int // Event kind number
|
||||
typealias TagArray = Array<Array<String>>
|
||||
```
|
||||
|
||||
### Event Anatomy
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
open class Event(
|
||||
val id: HexKey, // SHA-256 of canonical JSON (64 hex chars)
|
||||
val pubKey: HexKey, // Author public key (64 hex chars)
|
||||
val createdAt: Long, // Unix timestamp (seconds)
|
||||
val kind: Kind, // Event type
|
||||
val tags: TagArray, // [["e","eventid"], ["p","pubkey"], ...]
|
||||
val content: String,
|
||||
val sig: HexKey, // Schnorr signature (128 hex chars)
|
||||
)
|
||||
```
|
||||
|
||||
### Kind Classification
|
||||
|
||||
```kotlin
|
||||
// Regular events — stored by relays forever
|
||||
val isRegular = kind in 1..9999
|
||||
|
||||
// Replaceable events — relay keeps only latest per (pubkey, kind)
|
||||
val isReplaceable = kind == 0 || kind == 3 || kind in 10000..19999
|
||||
|
||||
// Addressable events — relay keeps latest per (pubkey, kind, d-tag)
|
||||
val isAddressable = kind in 30000..39999
|
||||
|
||||
// Ephemeral events — relays don't persist
|
||||
val isEphemeral = kind in 20000..29999
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Management
|
||||
|
||||
### Generate a new keypair
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
|
||||
// Generate fresh random keys
|
||||
val keyPair = KeyPair()
|
||||
|
||||
// From existing private key bytes
|
||||
val keyPair = KeyPair(privKey = myPrivKeyBytes)
|
||||
|
||||
// Read-only (public key only, cannot sign)
|
||||
val keyPair = KeyPair(pubKey = myPubKeyBytes)
|
||||
|
||||
// Access
|
||||
val pubKeyHex: String = keyPair.pubKey.toHexKey()
|
||||
val privKeyHex: String? = keyPair.privKey?.toHexKey()
|
||||
```
|
||||
|
||||
### Convert between formats
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
|
||||
// ByteArray → hex
|
||||
val hex = byteArray.toHexKey()
|
||||
|
||||
// hex → ByteArray
|
||||
val bytes = HexKey.decodeHex(hex)
|
||||
|
||||
// Bech32 import (npub, nsec)
|
||||
val parsed = Nip19Parser.uriToRoute("npub1abc...")
|
||||
// or
|
||||
val parsed = Nip19Parser.uriToRoute("nsec1abc...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Signing Events
|
||||
|
||||
### `NostrSignerInternal` (local key, JVM + Android)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
|
||||
val keyPair = KeyPair()
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
|
||||
// Sign any EventTemplate
|
||||
val template = TextNoteEvent.build("Hello Nostr!")
|
||||
val signedEvent: TextNoteEvent = signer.sign(template)
|
||||
```
|
||||
|
||||
### `NostrSignerSync` (synchronous, for testing)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
|
||||
val signerSync = NostrSignerSync(keyPair)
|
||||
val event = signerSync.sign<TextNoteEvent>(
|
||||
createdAt = TimeUtils.now(),
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "Hello!"
|
||||
)
|
||||
```
|
||||
|
||||
### NostrSigner interface (for custom signers)
|
||||
|
||||
```kotlin
|
||||
abstract class NostrSigner(val pubKey: HexKey) {
|
||||
abstract fun isWriteable(): Boolean
|
||||
abstract suspend fun <T : Event> sign(createdAt: Long, kind: Int, tags: Array<Array<String>>, content: String): T
|
||||
abstract suspend fun nip04Encrypt(plaintext: String, toPublicKey: HexKey): String
|
||||
abstract suspend fun nip04Decrypt(ciphertext: String, fromPublicKey: HexKey): String
|
||||
abstract suspend fun nip44Encrypt(plaintext: String, toPublicKey: HexKey): String
|
||||
abstract suspend fun nip44Decrypt(ciphertext: String, fromPublicKey: HexKey): String
|
||||
abstract suspend fun deriveKey(nonce: HexKey): HexKey
|
||||
abstract fun hasForegroundSupport(): Boolean
|
||||
// Convenience: auto-detects NIP-04 vs NIP-44 by ciphertext format
|
||||
suspend fun decrypt(encryptedContent: String, fromPublicKey: HexKey): String
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Creating Events
|
||||
|
||||
### Using typed event builders (recommended)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
|
||||
// Kind 1 — Text note
|
||||
val template = TextNoteEvent.build("Hello Nostr!")
|
||||
val event: TextNoteEvent = signer.sign(template)
|
||||
|
||||
// Kind 1 — Reply
|
||||
val replyTemplate = TextNoteEvent.build(
|
||||
note = "Interesting thread!",
|
||||
replyingTo = originalEventHintBundle
|
||||
)
|
||||
|
||||
// Kind 7 — Reaction
|
||||
val reactionTemplate = ReactionEvent.build(
|
||||
content = "+", // "+" = like, "-" = dislike, emoji = custom
|
||||
originalNote = targetEvent
|
||||
)
|
||||
```
|
||||
|
||||
### Using low-level `Event.build` DSL
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
val template = Event.build(
|
||||
kind = 1,
|
||||
content = "Hello world",
|
||||
createdAt = TimeUtils.now()
|
||||
) {
|
||||
// TagArrayBuilder DSL
|
||||
add(arrayOf("p", mentionedPubKey))
|
||||
add(arrayOf("t", "nostr"))
|
||||
add(arrayOf("subject", "Greeting"))
|
||||
}
|
||||
|
||||
val event: Event = signer.sign(template)
|
||||
```
|
||||
|
||||
### TagArrayBuilder DSL methods
|
||||
|
||||
```kotlin
|
||||
// In the DSL lambda:
|
||||
add(arrayOf("tagname", "value")) // append
|
||||
addFirst(arrayOf("tagname", "value")) // prepend
|
||||
addUnique(arrayOf("d", "my-slug")) // replace all tags with same name
|
||||
addAll(listOf(arrayOf("t", "tag1"), ...)) // bulk add
|
||||
remove("tagname") // remove all with this name
|
||||
```
|
||||
|
||||
## 6. Relay Client Setup (JVM / Android)
|
||||
|
||||
The relay client requires an OkHttp WebSocket builder (available on JVM + Android).
|
||||
|
||||
### Minimal setup
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
// Build the WebSocket factory
|
||||
val okHttpClient = OkHttpClient.Builder().build()
|
||||
val wsBuilder = BasicOkHttpWebSocket.Builder { _ -> okHttpClient }
|
||||
|
||||
// Create client (manages its own CoroutineScope internally)
|
||||
val nostrClient = NostrClient(websocketBuilder = wsBuilder)
|
||||
nostrClient.connect()
|
||||
```
|
||||
|
||||
### With custom OkHttpClient per relay
|
||||
|
||||
```kotlin
|
||||
val wsBuilder = BasicOkHttpWebSocket.Builder { normalizedUrl ->
|
||||
if (normalizedUrl.url.contains(".onion")) {
|
||||
torEnabledOkHttpClient // Tor proxy for .onion relays
|
||||
} else {
|
||||
regularOkHttpClient
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With custom CoroutineScope
|
||||
|
||||
```kotlin
|
||||
val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
val nostrClient = NostrClient(wsBuilder, scope = appScope)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Subscribing to Events
|
||||
|
||||
### Normalize relay URLs first
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
// Returns NormalizedRelayUrl (wrapper with validated wss:// URL)
|
||||
val relayUrl = RelayUrlNormalizer.normalize("wss://relay.damus.io")
|
||||
val relayUrlOrNull = RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io")
|
||||
|
||||
// Handles common fixes: https:// → wss://, strips whitespace, etc.
|
||||
```
|
||||
|
||||
### Build a Filter
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
// Fetch a user's notes
|
||||
val filter = Filter(
|
||||
authors = listOf(pubKeyHex),
|
||||
kinds = listOf(1),
|
||||
limit = 50
|
||||
)
|
||||
|
||||
// Since a timestamp
|
||||
val filter = Filter(
|
||||
kinds = listOf(1, 6),
|
||||
since = System.currentTimeMillis() / 1000 - 3600 // last hour
|
||||
)
|
||||
|
||||
// By event tags
|
||||
val filter = Filter(
|
||||
kinds = listOf(7),
|
||||
tags = mapOf("e" to listOf(eventId)) // reactions to an event
|
||||
)
|
||||
|
||||
// AND tag filter (NIP-91)
|
||||
val filter = Filter(
|
||||
kinds = listOf(1),
|
||||
tagsAll = mapOf(
|
||||
"t" to listOf("nostr"),
|
||||
"p" to listOf(specificPubKey)
|
||||
)
|
||||
)
|
||||
|
||||
// Full-text search (NIP-50)
|
||||
val filter = Filter(
|
||||
kinds = listOf(1),
|
||||
search = "bitcoin lightning"
|
||||
)
|
||||
```
|
||||
|
||||
### Open a subscription
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
|
||||
val relay = RelayUrlNormalizer.normalize("wss://relay.damus.io")
|
||||
|
||||
val subId = "my-sub-${System.currentTimeMillis()}"
|
||||
val filtersMap = mapOf(relay to listOf(filter))
|
||||
|
||||
nostrClient.openReqSubscription(
|
||||
subId = subId,
|
||||
filters = filtersMap,
|
||||
listener = object : IRequestListener {
|
||||
override fun onEvent(subId: String, event: Event, relay: IRelayClient) {
|
||||
println("Got event: ${event.id}")
|
||||
}
|
||||
override fun onEOSE(subId: String, relay: IRelayClient) {
|
||||
println("End of stored events from ${relay.url}")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Close when done
|
||||
nostrClient.close(subId)
|
||||
```
|
||||
|
||||
### Global relay listener
|
||||
|
||||
```kotlin
|
||||
nostrClient.subscribe(object : IRelayClientListener {
|
||||
override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
|
||||
when (msg) {
|
||||
is EventMessage -> handleEvent(msg.subscriptionId, msg.event)
|
||||
is EoseMessage -> handleEose(msg.subscriptionId)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) {
|
||||
println("Connected to ${relay.url} in ${pingMillis}ms")
|
||||
}
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
println("Disconnected from ${relay.url}")
|
||||
}
|
||||
// other callbacks: onConnecting, onSent, onCannotConnect
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Publishing Events
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
val relaySet = setOf(
|
||||
RelayUrlNormalizer.normalize("wss://relay.damus.io"),
|
||||
RelayUrlNormalizer.normalize("wss://nos.lol"),
|
||||
)
|
||||
|
||||
// Sign the event
|
||||
val template = TextNoteEvent.build("Hello Nostr!")
|
||||
val event: TextNoteEvent = signer.sign(template)
|
||||
|
||||
// Send to relays (handles retry + reconnect automatically)
|
||||
nostrClient.send(event, relaySet)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Event Serialization
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
// Serialize to JSON string
|
||||
val json: String = event.toJson()
|
||||
|
||||
// Parse from JSON string
|
||||
val event: Event = Event.fromJson(json)
|
||||
|
||||
// Null-safe parse
|
||||
val event: Event? = Event.fromJsonOrNull(json)
|
||||
|
||||
// Specific typed parse (returns base Event, cast if needed)
|
||||
val textNote = Event.fromJson(json) as? TextNoteEvent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Bech32 Encoding / Decoding (NIP-19)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
|
||||
// Decode any bech32 entity
|
||||
val result = Nip19Parser.uriToRoute("npub1abc...")
|
||||
// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null
|
||||
|
||||
when (val r = Nip19Parser.uriToRoute(input)) {
|
||||
is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}")
|
||||
is Nip19Parser.Return.Note -> println("event id: ${r.hex}")
|
||||
is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}")
|
||||
is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}")
|
||||
is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}")
|
||||
null -> println("not a valid bech32 entity")
|
||||
else -> {}
|
||||
}
|
||||
|
||||
// The parser also handles nostr: URI scheme
|
||||
val result = Nip19Parser.uriToRoute("nostr:npub1abc...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Encryption
|
||||
|
||||
### NIP-44 (modern, recommended)
|
||||
|
||||
```kotlin
|
||||
// Via signer (preferred)
|
||||
val encrypted = signer.nip44Encrypt(
|
||||
plaintext = "Secret message",
|
||||
toPublicKey = recipientPubKeyHex
|
||||
)
|
||||
val decrypted = signer.nip44Decrypt(
|
||||
ciphertext = encrypted,
|
||||
fromPublicKey = senderPubKeyHex
|
||||
)
|
||||
|
||||
// Auto-detect format (NIP-04 or NIP-44)
|
||||
val plaintext = signer.decrypt(encryptedContent, fromPublicKeyHex)
|
||||
```
|
||||
|
||||
### NIP-04 (legacy, avoid for new code)
|
||||
|
||||
```kotlin
|
||||
val encrypted = signer.nip04Encrypt(plaintext, recipientPubKeyHex)
|
||||
val decrypted = signer.nip04Decrypt(ciphertext, senderPubKeyHex)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Common NIP Event Builders
|
||||
|
||||
### NIP-02 — Follow list (kind 3)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
|
||||
val template = ContactListEvent.build(
|
||||
follows = listOf(
|
||||
ContactListEvent.Contact(pubKey = alicePubKey, relayUrl = "wss://relay.damus.io", petname = "alice"),
|
||||
ContactListEvent.Contact(pubKey = bobPubKey)
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### NIP-25 — Reaction (kind 7)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
|
||||
val like = ReactionEvent.build("+", targetEvent)
|
||||
val dislike = ReactionEvent.build("-", targetEvent)
|
||||
val custom = ReactionEvent.build("🤙", targetEvent)
|
||||
```
|
||||
|
||||
### NIP-57 — Zap request (kind 9734)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
|
||||
val template = LnZapRequestEvent.build(
|
||||
message = "Great post!",
|
||||
relays = listOf("wss://relay.damus.io"),
|
||||
target = targetEvent,
|
||||
zapType = LnZapRequestEvent.ZapType.PUBLIC
|
||||
)
|
||||
val zapRequest: LnZapRequestEvent = signer.sign(template)
|
||||
```
|
||||
|
||||
### NIP-59 — Gift wrap / sealed DM (kind 1059 + 14)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
|
||||
// Creates sealed rumor + gift wrap pair
|
||||
val (dmEvent, giftWrap) = NIP17Factory.create(
|
||||
msg = "Private message",
|
||||
fromSigner = senderSigner,
|
||||
toUsers = listOf(recipientPubKey),
|
||||
relayList = listOf("wss://relay.damus.io")
|
||||
)
|
||||
```
|
||||
|
||||
### NIP-23 — Long-form article (kind 30023)
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
|
||||
val template = LongTextNoteEvent.build(
|
||||
body = markdownContent,
|
||||
title = "My Article",
|
||||
image = "https://example.com/cover.jpg",
|
||||
summary = "A brief summary",
|
||||
slug = "my-article" // d-tag
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Platform-Specific Notes
|
||||
|
||||
### JVM / Desktop
|
||||
|
||||
```kotlin
|
||||
// jvmMain dependencies needed in consuming project:
|
||||
// secp256k1-kmp-jni-jvm and lazysodium-java are transitive from quartz
|
||||
// But you need JNA on the classpath for libsodium:
|
||||
implementation("net.java.dev.jna:jna:5.18.1")
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
```kotlin
|
||||
// androidMain dependencies (transitive from quartz):
|
||||
// secp256k1-kmp-jni-android, lazysodium-android, jna (aar)
|
||||
// No extra setup needed beyond the maven dependency.
|
||||
|
||||
// For NIP-55 (Android external signer apps):
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
The library produces an XCFramework named `quartz-kmpKit`.
|
||||
|
||||
```bash
|
||||
# Build XCFramework
|
||||
./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework
|
||||
# Output: quartz/build/XCFrameworks/release/quartz-kmpKit.xcframework
|
||||
```
|
||||
|
||||
In Xcode: drag & drop the `.xcframework` into your project, then use from Swift via Kotlin/Native interop.
|
||||
|
||||
---
|
||||
|
||||
## 14. Event Store (Android only)
|
||||
|
||||
SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62):
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.store.EventStore
|
||||
import android.content.Context
|
||||
|
||||
val store = EventStore()
|
||||
|
||||
// Insert
|
||||
store.insert(event)
|
||||
|
||||
// Query
|
||||
val events = store.query(
|
||||
Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50)
|
||||
)
|
||||
|
||||
// Count (NIP-45)
|
||||
val count = store.count(Filter(kinds = listOf(1)))
|
||||
|
||||
// Full-text search (NIP-50)
|
||||
val results = store.query(Filter(search = "bitcoin"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Quick Reference
|
||||
|
||||
| Task | API | Package |
|
||||
|------|-----|---------|
|
||||
| Generate keys | `KeyPair()` | `nip01Core.crypto` |
|
||||
| Create signer | `NostrSignerInternal(keyPair)` | `nip01Core.signers` |
|
||||
| Build event | `TextNoteEvent.build(...)` or `Event.build(kind, content) { tags }` | `nip10Notes`, `nip01Core.core` |
|
||||
| Sign event | `signer.sign(template)` | `nip01Core.signers` |
|
||||
| Serialize | `event.toJson()` | `nip01Core.core` |
|
||||
| Parse | `Event.fromJson(json)` | `nip01Core.core` |
|
||||
| Normalize relay URL | `RelayUrlNormalizer.normalize("wss://...")` | `nip01Core.relay.normalizer` |
|
||||
| Setup relay client | `NostrClient(BasicOkHttpWebSocket.Builder { okhttp })` | `nip01Core.relay.client` |
|
||||
| Subscribe | `client.openReqSubscription(subId, mapOf(relay to filters), listener)` | `nip01Core.relay.client` |
|
||||
| Publish | `client.send(event, setOf(relayUrl))` | `nip01Core.relay.client` |
|
||||
| NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` |
|
||||
| Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` |
|
||||
| Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` |
|
||||
|
||||
## Common Event Kinds
|
||||
|
||||
| Kind | Event Type | NIP | Quartz class |
|
||||
|------|-----------|-----|-------------|
|
||||
| 0 | User metadata | 01 | `MetadataEvent` |
|
||||
| 1 | Text note | 10 | `TextNoteEvent` |
|
||||
| 3 | Follow list | 02 | `ContactListEvent` |
|
||||
| 4 | Legacy DM | 04 | `PrivateDmEvent` |
|
||||
| 5 | Deletion | 09 | `DeletionEvent` |
|
||||
| 6 | Repost | 18 | `RepostEvent` |
|
||||
| 7 | Reaction | 25 | `ReactionEvent` |
|
||||
| 14 | Chat message (sealed) | 17 | `NIP17GroupMessage` |
|
||||
| 1059 | Gift wrap | 59 | `GiftWrapEvent` |
|
||||
| 9734 | Zap request | 57 | `LnZapRequestEvent` |
|
||||
| 9735 | Zap receipt | 57 | `LnZapEvent` |
|
||||
| 10002 | Relay list | 65 | `AdvertisedRelayListEvent` |
|
||||
| 30023 | Long-form content | 23 | `LongTextNoteEvent` |
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **nostr-expert** — Internal Quartz patterns for Amethyst development
|
||||
- **kotlin-multiplatform** — KMP source sets, expect/actual patterns
|
||||
- **kotlin-coroutines** — Flow patterns for relay event streams
|
||||
142
.claude/skills/quartz-integration/references/gradle-setup.md
Normal file
142
.claude/skills/quartz-integration/references/gradle-setup.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Quartz Gradle Dependency Setup
|
||||
|
||||
## Current version
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.06.2
|
||||
```
|
||||
|
||||
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
|
||||
|
||||
---
|
||||
|
||||
## KMP Project Setup
|
||||
|
||||
### `gradle/libs.versions.toml`
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
quartz = "1.06.2"
|
||||
|
||||
[libraries]
|
||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||
```
|
||||
|
||||
### `build.gradle.kts` (library/app module)
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidKotlinMultiplatformLibrary) // or androidLibrary/androidApplication
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
androidLibrary {
|
||||
namespace = "com.example.myapp"
|
||||
compileSdk = 35
|
||||
minSdk = 21
|
||||
}
|
||||
// optional: iOS targets
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.quartz)
|
||||
}
|
||||
// No platform-specific deps needed — Quartz provides them transitively
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Android-only Project
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts (app module)
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.06.2")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JVM (Desktop) standalone
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.06.2")
|
||||
// JNA needed for libsodium (NIP-44) on JVM
|
||||
implementation("net.java.dev.jna:jna:5.18.1")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transitive Dependencies (what you get automatically)
|
||||
|
||||
### All platforms (`commonMain`)
|
||||
- `org.jetbrains.kotlin:kotlin-stdlib`
|
||||
- `org.jetbrains.kotlinx:kotlinx-coroutines-core`
|
||||
- `org.jetbrains.kotlinx:kotlinx-collections-immutable`
|
||||
- `org.jetbrains.kotlinx:kotlinx-serialization-json`
|
||||
- `androidx.collection:collection` (LruCache)
|
||||
- `androidx.compose.runtime:runtime-annotation` (@Immutable/@Stable)
|
||||
- `fr.acinq.secp256k1:secp256k1-kmp` (Schnorr crypto — common)
|
||||
|
||||
### JVM + Android (`jvmAndroid`)
|
||||
- `com.github.anthonynsimon:rfc3986-normalizer` (URL normalization)
|
||||
- `com.fasterxml.jackson.module:jackson-module-kotlin` (JSON)
|
||||
- `com.squareup.okhttp3:okhttp` (WebSocket)
|
||||
- `ru.gildor.coroutines:kotlin-coroutines-okhttp`
|
||||
- `nl.bommber:kchesslib` (NIP-64 chess, version pinned to 1.0.0)
|
||||
|
||||
### JVM only
|
||||
- `fr.acinq.secp256k1:secp256k1-kmp-jni-jvm`
|
||||
- `com.goterl:lazysodium-java` (NIP-44 encryption)
|
||||
- `net.java.dev.jna:jna`
|
||||
|
||||
### Android only
|
||||
- `fr.acinq.secp256k1:secp256k1-kmp-jni-android`
|
||||
- `com.goterl:lazysodium-android`
|
||||
- `net.java.dev.jna:jna` (aar)
|
||||
- `androidx.core:core-ktx`
|
||||
|
||||
---
|
||||
|
||||
## Packaging / ProGuard
|
||||
|
||||
Quartz ships consumer ProGuard rules automatically (`publish = true` in the library).
|
||||
You don't need to add manual keep rules for Quartz classes in your app.
|
||||
|
||||
For Android release builds, these classes are preserved:
|
||||
- All `com.vitorpamplona.quartz.**` event and model classes
|
||||
- Jackson serialization annotations
|
||||
|
||||
---
|
||||
|
||||
## Common Build Errors
|
||||
|
||||
### `Duplicate class kotlin.collections.jdk8`
|
||||
Add to `gradle.properties`:
|
||||
```properties
|
||||
android.useFullClasspathForDexingTransform=true
|
||||
```
|
||||
|
||||
### `Could not find lazysodium-java` on JVM
|
||||
Ensure JNA is on the classpath:
|
||||
```kotlin
|
||||
implementation("net.java.dev.jna:jna:5.18.1")
|
||||
```
|
||||
|
||||
### iOS: `Framework not found quartz-kmpKit`
|
||||
Build the XCFramework first:
|
||||
```bash
|
||||
./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework
|
||||
```
|
||||
Then drag `quartz/build/XCFrameworks/release/quartz-kmpKit.xcframework` into Xcode.
|
||||
23
.claude/skills/quartz-kmp.md
Normal file
23
.claude/skills/quartz-kmp.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Quartz KMP (Legacy Skill — Migration Complete)
|
||||
|
||||
> The KMP migration of Quartz is **complete**. This file is kept for historical reference.
|
||||
>
|
||||
> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead.
|
||||
> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill.
|
||||
|
||||
## What was migrated
|
||||
|
||||
The Quartz library was successfully converted from Android-only to full KMP supporting:
|
||||
- **commonMain** — All Nostr protocol logic, events, filters, tags
|
||||
- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers
|
||||
- **androidMain** — SQLite event store, NIP-55 Android signer
|
||||
- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm)
|
||||
- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`)
|
||||
|
||||
## Current artifact
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.06.2
|
||||
```
|
||||
|
||||
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
|
||||
@@ -12,7 +12,7 @@ echo "$JAVA_HOME"
|
||||
echo "$(java -version)"
|
||||
echo "Running test... "
|
||||
|
||||
./gradlew test
|
||||
./gradlew test --quiet
|
||||
|
||||
status=$?
|
||||
|
||||
156
.github/workflows/build.yml
vendored
156
.github/workflows/build.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Test/Build Android
|
||||
name: Test/Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -6,78 +6,190 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Linter (gradle)
|
||||
run: ./gradlew spotlessCheck
|
||||
|
||||
test:
|
||||
needs: lint
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Test (gradle)
|
||||
run: ./gradlew test --no-daemon
|
||||
|
||||
- name: Android Test Report
|
||||
uses: asadmansr/android-test-report-action@v1.2.0
|
||||
if: ${{ always() }} # IMPORTANT: run Android Test Report regardless
|
||||
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ always() && matrix.os == 'ubuntu-latest' }}
|
||||
with:
|
||||
name: Test Reports
|
||||
path: amethyst/build/reports
|
||||
|
||||
build-android:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Build APK (gradle)
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Upload Play APK
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Play Debug APK
|
||||
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
|
||||
|
||||
- name: Upload FDroid APK
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: FDroid Debug APK
|
||||
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
|
||||
|
||||
- name: Build APK (gradle)
|
||||
- name: Build Benchmark APK (gradle)
|
||||
run: ./gradlew assembleBenchmark
|
||||
|
||||
- name: Upload Play APK Benchmark
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Play Benchmark APK
|
||||
path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
|
||||
|
||||
- name: Upload FDroid APK Benchmark
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: FDroid Benchmark APK
|
||||
path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk
|
||||
|
||||
- name: Upload Compose Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Compose Reports
|
||||
path: amethyst/build/compose_compiler
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Test Reports
|
||||
path: amethyst/build/reports
|
||||
build-desktop:
|
||||
needs: test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
task: packageDeb
|
||||
artifact-name: Desktop Linux DEB
|
||||
artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb
|
||||
- os: macos-latest
|
||||
task: packageDmg
|
||||
artifact-name: Desktop macOS DMG
|
||||
artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg
|
||||
- os: windows-latest
|
||||
task: packageMsi
|
||||
artifact-name: Desktop Windows MSI
|
||||
artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Build Desktop Distribution
|
||||
run: ./gradlew :desktopApp:${{ matrix.task }}
|
||||
|
||||
- name: Upload Desktop Distribution
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: ${{ matrix.artifact-path }}
|
||||
|
||||
134
.github/workflows/create-release.yml
vendored
134
.github/workflows/create-release.yml
vendored
@@ -6,23 +6,42 @@ on:
|
||||
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ github.ref }}
|
||||
draft: false
|
||||
prerelease: true
|
||||
|
||||
deploy-android:
|
||||
needs: create-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
@@ -38,7 +57,6 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Sign AAB (F-Droid)
|
||||
@@ -50,7 +68,6 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Build APK
|
||||
@@ -65,7 +82,6 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Sign APK (F-Droid)
|
||||
@@ -77,20 +93,8 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
# override default build-tools version (29.0.3) -- optional
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ github.ref }}
|
||||
draft: false
|
||||
prerelease: true
|
||||
|
||||
# Google Play APK
|
||||
- name: Upload Play APK Universal Asset
|
||||
id: upload-release-asset-play-universal-apk
|
||||
@@ -98,7 +102,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
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
|
||||
@@ -109,7 +113,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
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
|
||||
@@ -120,7 +124,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
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
|
||||
@@ -131,7 +135,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.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
|
||||
@@ -142,7 +146,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.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
|
||||
@@ -154,7 +158,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
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
|
||||
@@ -165,7 +169,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
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
|
||||
@@ -176,7 +180,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
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
|
||||
@@ -187,7 +191,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.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
|
||||
@@ -198,13 +202,11 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.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
|
||||
- name: Upload Google Play AAB Asset
|
||||
id: upload-release-asset-play-aab
|
||||
@@ -212,7 +214,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab
|
||||
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
|
||||
asset_content_type: application/zip
|
||||
@@ -224,7 +226,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab
|
||||
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
|
||||
asset_content_type: application/zip
|
||||
@@ -236,3 +238,65 @@ jobs:
|
||||
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}
|
||||
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}
|
||||
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
|
||||
|
||||
deploy-desktop:
|
||||
needs: create-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
task: packageDeb
|
||||
format: deb
|
||||
platform: linux
|
||||
- os: macos-latest
|
||||
task: packageDmg
|
||||
format: dmg
|
||||
platform: macos
|
||||
- os: windows-latest
|
||||
task: packageMsi
|
||||
format: msi
|
||||
platform: windows
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: 21
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Build Desktop Distribution
|
||||
run: ./gradlew :desktopApp:${{ matrix.task }}
|
||||
|
||||
- name: Find distribution file
|
||||
id: find-dist
|
||||
run: |
|
||||
DIST_FILE=$(find desktopApp/build/compose/binaries/main/${{ matrix.format }} -type f \( -name "*.deb" -o -name "*.dmg" -o -name "*.msi" \) | head -1)
|
||||
echo "path=$DIST_FILE" >> $GITHUB_OUTPUT
|
||||
echo "name=$(basename $DIST_FILE)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload Desktop Distribution to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: ${{ steps.find-dist.outputs.path }}
|
||||
asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }}
|
||||
asset_content_type: application/octet-stream
|
||||
|
||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -19,11 +19,24 @@
|
||||
/.idea/kotlinNotebook.xml
|
||||
/.idea/ChatHistory_schema_v3.xml
|
||||
/.idea/markdown.xml
|
||||
/.idea/AndroidProjectSystem.xml
|
||||
/.idea/deviceManager.xml
|
||||
/.idea/inspectionProfiles/
|
||||
/.idea/migrations.xml
|
||||
/commons/.idea/gradle.xml
|
||||
/commons/.idea/misc.xml
|
||||
/commons/.idea/workspace.xml
|
||||
/quartz/.idea/modules.xml
|
||||
/quartz/.idea/workspace.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.cxx
|
||||
|
||||
# superpowers skill
|
||||
.superpowers
|
||||
docs/brainstorms
|
||||
docs/superpowers
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
@@ -138,3 +151,14 @@ lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Local task tracking
|
||||
TASKS.md
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
# Downloaded VLC binaries (vlc-setup plugin)
|
||||
desktopApp/src/jvmMain/appResources/linux/
|
||||
desktopApp/src/jvmMain/appResources/macos/
|
||||
desktopApp/src/jvmMain/appResources/windows/
|
||||
|
||||
6
.idea/AndroidProjectSystem.xml
generated
6
.idea/AndroidProjectSystem.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
13
.idea/deviceManager.xml
generated
13
.idea/deviceManager.xml
generated
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
77
.idea/inspectionProfiles/Project_Default.xml
generated
77
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,77 +0,0 @@
|
||||
<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="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</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="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/kotlinNotebook.xml
generated
6
.idea/kotlinNotebook.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinNotebookOptionsProvider">
|
||||
<option name="shouldAddProjectLibrariesToClasspath" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
5
.idea/kotlinc.xml
generated
5
.idea/kotlinc.xml
generated
@@ -4,9 +4,10 @@
|
||||
<option name="moduleKind" value="plain" />
|
||||
</component>
|
||||
<component name="Kotlin2JvmCompilerArguments">
|
||||
<option name="jvmTarget" value="1.8" />
|
||||
<option name="jvmTarget" value="21" />
|
||||
</component>
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.1.0" />
|
||||
<option name="externalSystemId" value="Gradle" />
|
||||
<option name="version" value="2.3.20" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
410
CHANGELOG.md
410
CHANGELOG.md
@@ -1,3 +1,411 @@
|
||||
<a id="v1.06.0"></a>
|
||||
# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2025-03-21
|
||||
|
||||
Polls:
|
||||
- Adds support for creating and rendering NIP-85
|
||||
- Redesign of the poll and zap poll cards
|
||||
- Adds special notification card while the poll is running
|
||||
|
||||
Relay Feeds
|
||||
- Adds support for rendering relay feeds
|
||||
- Adds support for NIP-51 favorite relay feeds
|
||||
- Shows favorite relays in the top navigation filter
|
||||
- Clicking wss:// links shows the global feed for that relay.
|
||||
- New user account adds nostr.wine to favorite relay feeds
|
||||
|
||||
Media Player
|
||||
- Redesigned player controls for videos, audios, and picture-in-picture.
|
||||
- Adds our own buttons and indicators for the video playback
|
||||
- Adds Music support with waveform animations
|
||||
- Migrates to new Media3 content view frames
|
||||
- Improved Picture in Picture actions
|
||||
- Turn video controller creation into a flow to fix playback lifecycle issues
|
||||
- Adds support for uploading audio
|
||||
|
||||
NWC Wallets:
|
||||
- Adds support for NIP-47 Wallets and compete NWC spec
|
||||
- Adds views for Balance and Transactions
|
||||
- Add transaction filtering and pagination to wallet screen
|
||||
- Added several test cases from other repos to guarantee interoperability
|
||||
|
||||
Calendar:
|
||||
- Adds support for NIP-52 Calendar appointments
|
||||
- Adds proper display of calendar time slot and date slot events in the note feed
|
||||
- Refactored the early implementation on Quartz for easier use
|
||||
|
||||
Code Snippets:
|
||||
- Adds support for NIP-C0 Code Snippets
|
||||
- Replies using NIP-22
|
||||
|
||||
NIPs on Nostr
|
||||
- Adds support for event kind 30817
|
||||
- Replies using NIP-22
|
||||
|
||||
PayTo:
|
||||
- Adds support for NIP-A3 Payment targets by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
|
||||
Blossom BUD-10:
|
||||
- Adds support for "Blossom:" URIs on the post
|
||||
- Supports automatic discovery of blossom servers
|
||||
- Renders/Previews images, audios, videos, and documents
|
||||
- Includes support for encryption when using it in NIP-17 DMs.
|
||||
|
||||
Expirations
|
||||
- Adds enhanced support for custom expirations in any new post.
|
||||
- Displays expirations on posts and DMs
|
||||
|
||||
Relay Monitors:
|
||||
- Adds support for NIP-66 Relay monitor and discovery support to Quartz
|
||||
|
||||
Attestations:
|
||||
- Adds support for rendering Attestations (https://attestr.xyz/)
|
||||
- Recommendations, Requests and Attestor Declarations are also included.
|
||||
|
||||
Chess:
|
||||
- Adds basic support for Chess with Jester protocol
|
||||
- Full chess game implemented
|
||||
- Supports for game challenges and view external games
|
||||
- Running on debug only for now
|
||||
|
||||
DMs:
|
||||
- Removes NIP-04 DMs
|
||||
- Blocks DM sending if the receiver doesn't have NIP-17 relay lists.
|
||||
- Removed incognito icon from the new post field.
|
||||
|
||||
Push Notifications:
|
||||
- Adds support for inline reply
|
||||
- Adds support for notification grouping
|
||||
- Adds support for Async image Loading
|
||||
- Removed NIP-04 notifications
|
||||
|
||||
Long Form:
|
||||
- Adds support for writing Long Form/Markdown content
|
||||
- Includes support for automatic Draft saving and editing
|
||||
- Includes support for editing
|
||||
|
||||
Uploads:
|
||||
- Adds support to upload Documents to all new post screens.
|
||||
- Adds toggle to stip file metadata regardless of compression by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Adds encrypted file upload fallback option for NIP-17 chats
|
||||
- Removes support for NIP-96 and updates Blossom recommendations
|
||||
|
||||
Content Warning:
|
||||
- Adds an optional description field for sensitive content warnings in new posts.
|
||||
- Displays additional information on warning composables
|
||||
|
||||
Settings redesign:
|
||||
- Consolidate drawer settings into a single Settings hub screen
|
||||
- Redesigns Zap Amount and NWC setup screens
|
||||
- Redesigns Custom zap amount screens
|
||||
- Adds brand new Translation Settings screen
|
||||
- Adds blockchain explorer settings page for OTS verification
|
||||
- Adds reactions row settings (enable/disable, order, show/hide counters) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
- Tapping on Zap without any pre-configured amount opens the custom dialog
|
||||
|
||||
Content parsers:
|
||||
- URL/URI parser rewrite in Kotlin multiplatform (KMP)
|
||||
- Fixes characters attached to URLs or nostr URLs without a space
|
||||
- Massively increases parsing performance
|
||||
- Treat multibyte characters as URL terminators in RichTextParser by @npub1k0jrarx8um0lyw3nmysn50539ky4k8p7gfgzgrsvn8d7lccx3d0s38dczd
|
||||
- Adds a parser for blossom: uris
|
||||
|
||||
UI Improvements:
|
||||
- Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
|
||||
- New Material 3 UI for DropDowns by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- New Material 3 UI for feed filters by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Draft Screen requests confirmation before deleting drafts on swipe
|
||||
- Swipe to switch tabs. Main screen and messages by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Adds support for rendering Zap events when quoted inside of posts.
|
||||
- Adds a Broadcasting feedback pop-up in the Complete UI mode
|
||||
|
||||
Relay Management:
|
||||
- Adds relay search tooltip when adding relays
|
||||
- Adds the list of keys using each relay to the relay information
|
||||
- Adds active subscriptions and outbox event in the queue to relay information
|
||||
- Adds a complete list of event kind names to the subscription card to relay information
|
||||
- Tracks and displays connection success rate on relay settings
|
||||
- Adds relay settings export functionality
|
||||
- Adds NIP-45 count queries to show how many events each relay has.
|
||||
- Adds Relay sync utility to help users move posts between relays.
|
||||
|
||||
Search:
|
||||
- Breaks the search filter into two subscriptions to prioritize Metadata without punishing content.
|
||||
- Fixes the need to start user searches with @ in user fields
|
||||
- Fixes the stability of the search feed when the user navigates away and back.
|
||||
- Replaces about me for NIP-05 in the user search results
|
||||
- Adds relay URL search to the search page
|
||||
- Forces returning one user when searching by nip-05
|
||||
- Removes outdated versions of addressables from the search results
|
||||
|
||||
Profiles:
|
||||
- Adds support for NIP-39 External Identities with kind 10011
|
||||
- Adds a profile picture upload button when the user has no picture
|
||||
- Adds last seen to the user profile
|
||||
- Adds nprofile and npub copy options to the profile
|
||||
- Groups received zap amounts by sending the user in the profile tab
|
||||
- Increases the limit of Zap downloads for profiles to 1000
|
||||
- Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
|
||||
- Migrates profile galleries to display a thumbnail for videos
|
||||
- Fixes profile galleries' aspect ratios
|
||||
- Adds support for Namecoin .bit urls to NIP-05 and choice of ElectrumX server to resolve namecoins.
|
||||
|
||||
Onboarding
|
||||
- Adds bulk follow screens to search for a user and to copy his/her follow list
|
||||
|
||||
Voice message by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Adds voice anonymization
|
||||
- Change from "hold to record" to "click to start, click to stop"
|
||||
- Display kind 1 voice replies as an audio waveform
|
||||
- Increases max voice record duration to 600 seconds
|
||||
- Switches the public message event to use quoted posts for replies
|
||||
|
||||
Fixes:
|
||||
- Fixes "forked from" label rendering over the name
|
||||
- Avoids crashing when the `k` tag cannot be parsed to a number
|
||||
- Only use Voice Reply events when replying to voice notes. Others just receive a URL.
|
||||
- Fixes the lack of update in the follow count on the UserProfile page
|
||||
- Fixes out of memory when downloading large videos
|
||||
- Fixes Jackson deserialization for empty Filters and add regression test by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
- Fixes NullPointerException when the filter contains tags
|
||||
- Fixes download cancellations when screen components disappear
|
||||
- Migrates to use "title" instead of "name" tags for NIP-51 lists
|
||||
- Adds a longer crop for npubs so that we can see vanity keys better
|
||||
- Fixes the need to have tags and kinds for inbox.nostr.wine to work
|
||||
- Blocks the size of Relay Auth Status arrays from growing forever with auth messages
|
||||
- Fixes crash when getting OpenGraph tags of invalid URLs
|
||||
- Fixes NIP-44 key mutation in NIP-46 connect
|
||||
- Location permission watcher moved outside screens to avoid recreation
|
||||
- Solves the sorting contract crash on search by precaching all values before sorting users.
|
||||
- Fixes lingering relay connections from loading follows outbox's settings.
|
||||
- Enhance NIP-38 user status display with emoji support and metadata tags
|
||||
- Fixes bug on Show More calculations for very long texts without spaces
|
||||
- Fixing IO Dispatchers and coroutine scopes of choice
|
||||
- Fixes anySync parallel operation that was returning the first result, not the first positive "any".
|
||||
- Fixes Req onCannotConnect listeners to the relays that actually sent the req
|
||||
- Fixes hanging subscriptions when exceptions happen during NostrClient utility methods
|
||||
|
||||
Defaults:
|
||||
- Switches wss://nostr.band to wss://antiprimal.net, wss://relay.ditto.pub on app defaults
|
||||
- Adds wss://nostr.wine, wss://news.utxo.one as favorite relay feeds
|
||||
- Adds wss://directory.yabu.me and wss://profiles.nostr1.com as index relays
|
||||
- Adds electrumx.testls.space, nmc2.bitcoins.sk, 46.229.238.187 and i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion as ElectrumX servers
|
||||
|
||||
Quartz:
|
||||
- Adds Relay Server implementation with NIP-45 COUNT and NIP-42 AUTH support
|
||||
- Adds support for dynamic auth policies to the relay implementation.
|
||||
- Migrates Quartz EventStore from Android-only to KMP
|
||||
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
|
||||
- Adds a reqBypassingRelayLimits extension to the Nostr Client
|
||||
- Adds comprehensive NIP-46 Bunker support
|
||||
- Adds comprehensive support for NIP-47 non-payment methods.
|
||||
|
||||
Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
|
||||
- Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge.
|
||||
- Provide implementation for LargeCache, using a CacheMap
|
||||
- Provide implementation for fastFindURLs()
|
||||
- Provide implementation for makeAbsoluteIfRelativeUrl() in ServerInfoParser.ios.kt
|
||||
- Provide implementation for UrlEncoder
|
||||
- Provide implementation for UnicodeNormalizer
|
||||
- Provide implementation for GZip compression/decompression. Some small fixes in URLs.ios.kt
|
||||
- Provide implementation for AESCBC
|
||||
- Provide implementation for AESGCM
|
||||
- Provide implementation for DigestInstance
|
||||
- Provide implementation for LibSodium
|
||||
|
||||
Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
|
||||
- Adds NIP-46 Bunker Login
|
||||
- Adds Support for Chess
|
||||
- Adds Thread Screens
|
||||
- Adds advanced search with query engine and filter panel
|
||||
- Adds encrypted DMs (NIP-04/NIP-17)
|
||||
- Adds proper empty states with EOSE tracking
|
||||
- Adds multi-column deck layout
|
||||
- Adds Full media parity — images, video, audio, encrypted DMs, upload, lightbox
|
||||
- Adds advanced search with NIP-50, collapsible sections, and nav state preservation
|
||||
- Clear stored credentials on logout
|
||||
- Adds bunker heartbeat indicator
|
||||
- Adds QR-based signer pairing
|
||||
- Migrates lifecycle-viewmodel KMP dependencies to KMP/Commons
|
||||
- Migrates drawReplyLevel modifier to KMP/Commons
|
||||
- Migrates ThreadFilter to KMP/Commons
|
||||
- Migrates Card interface and CardFeedState to KMP/Commons
|
||||
- Migrates Channels (public chats, ephemeral channels, and live streams) Account modules to KMP/Commons
|
||||
- Migrates private chatroom models to KMP/Commons
|
||||
- Migrates reports states to KMP/Commons
|
||||
- Migrates Emoji State to KMP/Commons
|
||||
- Migrates lud06 to lud16 mapping to KMP/Quartz
|
||||
- Migrates the new LocalCache observables to KMP/Commons
|
||||
- Migrates rich text parser from JVM to KMP/Commons
|
||||
|
||||
Code Quality
|
||||
- Migrates to AGP 9.0
|
||||
- Adds Amethyst Desktop to CI/CD and Release builds
|
||||
- Removes the in-app memory counter methods
|
||||
- Refactors the old NIP-05 code on Quartz
|
||||
- Migrates contact list management to addressable notes
|
||||
- Creates new observable flows for LocalCache.
|
||||
- Moves metadata methods from User to UserCache objects
|
||||
- Separate Addressable vs Replaceable event class bases
|
||||
- Avoid dependency on AccountSettings for NwcSignerState
|
||||
- Finishes the transition to EventHint objects for building events.
|
||||
- Lots of code review fixes by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Large accessibility review by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Moves Top Nav Filter markers from Strings to full objects.
|
||||
- Removes support for feed definitions
|
||||
- AccountState refactoring
|
||||
|
||||
AI:
|
||||
- Add SKILL.md for AI agent customization
|
||||
- Add settings and hooks to setup Android Development for the agent
|
||||
|
||||
Updated translations:
|
||||
- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
|
||||
- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz
|
||||
- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm
|
||||
- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6
|
||||
- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
|
||||
- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
|
||||
- Chinese by hypnotichemionus4
|
||||
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
|
||||
- Russian by Anton Zhao
|
||||
|
||||
<a id="v1.05.1"></a>
|
||||
# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
|
||||
|
||||
- Fixed mixed DMs between logged in users.
|
||||
- Fixed draft screen click to edit post.
|
||||
|
||||
<a id="v1.05.0"></a>
|
||||
# [Release v1.05.0: Bookmark Lists and WoT Scores](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08
|
||||
|
||||
#Amethyst v1.05.0: Bookmark Lists, Voice Notes, and WoT Scores
|
||||
|
||||
This release introduces Bookmark List management, a complete overhaul of Voice Notes/YakBaks,
|
||||
and the debut of Web of Trust (WoT) scores for a safer social experience.
|
||||
|
||||
This version adds support for creating, managing, deleting, and viewing multiple bookmark lists,
|
||||
which include both public and private members. You will find an improved "Bookmarks" menu option in
|
||||
the sidebar and extra bookmark options in the context menu of each post, allowing you to add posts
|
||||
directly to one or more individual lists.
|
||||
|
||||
The Voice Notes UI has been redesigned to allow recording directly within the new Post Screen and a
|
||||
dedicated Voice Reply screen. Users can record a new voice message, preview it with waveform
|
||||
visualization, re-record if needed, select a media server, and post the reply. You now have full control.
|
||||
|
||||
Amethyst now supports Trusted Assertions. By connecting to a WoT provider, you can see trust scores
|
||||
and verified follower counts directly on user pictures. This helps filter signal from noise, identifying
|
||||
reputable accounts to follow, which DMs to open, and which notifications to prioritize. To activate
|
||||
this, you will need to find a provider capable of computing these scores. While providers are
|
||||
currently limited and resource-constrained, we hope more will bring their own algorithms to Nostr over time.
|
||||
|
||||
Quartz received a significantly improved database engine capable of sub-microsecond queries using Android's
|
||||
default SQLite database. The engine is optimized for mobile environments, using as little memory as
|
||||
possible to avoid impacting other apps.
|
||||
|
||||
In the background, we have begun building Amethyst Desktop. While much work remains, the goal is a
|
||||
standalone, mouse-first application that moves away from mobile-centric UI layouts.
|
||||
|
||||
New Features
|
||||
- Trusted Assertions: Added support for trust scores displayed on user profile pictures
|
||||
- WoT Followers: Displays verified follower counts in user profiles
|
||||
- Bookmark Lists: Full support for custom lists by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
|
||||
- Relay Information: New UI with expanded NIP-11 feature support
|
||||
- Voice Notes & Replies: Redesigned experience by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Profile Banner: New default banner by @npub1tx5ccpregnm9afq0xaj42hh93xl4qd3lfa7u74v5cdvyhwcnlanqplhd8g
|
||||
- Native Links: Intercept njump, yakihonne, primal, iris.to, zap.stream, and shosho.live to open directly on Amethyst by @npub1lu4l4wh7482u8vq0u63g2yuj8yqjdq8ne06r432n92rnd22lspcq0cxa32
|
||||
|
||||
Improvements:
|
||||
- New in-memory graph-based cache scheme; moved reports and WoT scores to this new system
|
||||
- Disabled top bar reappearance to prevent feed shifting when navigating between pages
|
||||
- Lenient Kotlin Serialization to prevent crashes from malformed JSON;
|
||||
- Removed expired addressable events from cache
|
||||
- Moves reports from the old caching system to the new Graph-based one.
|
||||
- Reverted to a 500-post load limit for Profile screens to handle high-reply accounts
|
||||
- Moved the QR Code screen from a Dialog to a full Route.
|
||||
- Re-adds name as a tagging name to the profile edit page.
|
||||
|
||||
Performance:
|
||||
- Faster event id checker by serializing, sha256 hashing, and ID comparison without creating any intermediary buffers.
|
||||
- Faster event JSON parsers by avoiding new variables and thus garbage collection calls
|
||||
- Faster tag array Deserializer
|
||||
- Manages the pool state without having to loop through relays, saving some milliseconds of processing.
|
||||
- Adds a cache system for WoT scores
|
||||
- Improved Compose stability for video UI
|
||||
|
||||
BugFixes:
|
||||
- Fixes JSON serialization of UTF-8 Emoji surrogates for compatibility with standard Nostr implementations
|
||||
- Improves error message on zap configuration errors with detailed NWC URI by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Centers QR dialog content and reduce excessive top spacing by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
|
||||
- Closes subscriptions when ending them on NostrClient instead of waiting for them to finish
|
||||
- Requires a relay to be an outbox/inbox relay to be able to NOTIFY a user of a payment
|
||||
- Improves the speed of parsing of invalid kinds inside an address string
|
||||
- Fixes count not working for LIMIT queries in the DB
|
||||
- Fixes icon bug with incorrect resource id by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Fixes missing updates to the feed when the top list is not yet available locally
|
||||
- Fixes List of supported NIPs as Integers on NIP-11 by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Fixes ConcurrentExceptions on event outboxes
|
||||
|
||||
Desktop:
|
||||
- Base Compose Multiplatform Desktop App with posts and global/following feeds by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
|
||||
|
||||
Web:
|
||||
- New website by @npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424
|
||||
|
||||
Quartz:
|
||||
- Adds support for Trust Provider lists and Contact Cards for NIP-85
|
||||
- Early support for Payment targets as per [NIP-A3](https://github.com/nostr-protocol/nips/pull/2119) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
- Initial support for NIP 46 by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
- Adds support for fast MurMur hash 3 64 bits
|
||||
- Adds a nextLong secure random method
|
||||
- Removing the generalist approach of ptag-mentions
|
||||
- Removes deprecated fields in UserMetadata
|
||||
- Removes compose bom from Quartz to avoid unnecessary dependencies.
|
||||
- Removes datetime dependencies from Quartz
|
||||
- Adds dependency on coroutines directly (instead of through compose runtime)
|
||||
- Removes old secp256 target dependencies
|
||||
- Adds Default scope for NostrClient and Relay Authenticator
|
||||
|
||||
Quartz-Event Store:
|
||||
- Moves from text tags to probabilistic 64-bit MurMur Hash3 integers for performance
|
||||
- Moves from range index queries to kind,pubkey queries by default.
|
||||
- Adds simpler SQL queries for specific simple Nostr filters
|
||||
- Expose SQL query plans, vacuum, and analyse to lib users
|
||||
- Implements AND Tag queries from [NIP-91](https://github.com/nostr-protocol/nips/pull/1365)
|
||||
- Implements GiftWrap deletions by p-Tag with deletions and vanish requests
|
||||
- Offers several indexing strategy options to users.
|
||||
- Adds several test cases that verify not only the SQL but also the indexes used
|
||||
- Exposes raw queries that return columns for relays that might not need the tag array
|
||||
- Forces the use of the index on Addressables and Replaceables on triggers
|
||||
- Fixes duplicated events being returned from the DB
|
||||
- Fixes unused Or condition in the SQL builder
|
||||
- Refine the structure of the module classes for the DB
|
||||
- Removes the Statement cache since statements are not thread safe
|
||||
- Creating interfaces for multiple EventStores
|
||||
|
||||
Code Quality:
|
||||
- Updates kotlin, compose, multiplatform, activity, serialization, media3, mockk, secp256, tor, androidxCamera, stdlib
|
||||
- Adds a compose stability plugin to allow traces in debug
|
||||
- Updates to the latest Zapstore config
|
||||
- Updates quarts instructions in the ReadMe.
|
||||
|
||||
Updated translations:
|
||||
- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm
|
||||
- Hungarian by @npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
|
||||
- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6
|
||||
- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
|
||||
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
|
||||
- Latvian by @npub1l60stxkwwmkts76kv02vdjppka9uy6y3paztck7paau7g64l687saaw6av
|
||||
- Dutch by @npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd
|
||||
- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz and Alexis Magzalci
|
||||
- Chinese by @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h
|
||||
|
||||
<a id="v1.04.2"></a>
|
||||
# [Release v1.04.2: Fix for Google Play](https://github.com/vitorpamplona/amethyst/releases/tag/v1.04.2) - 2025-11-15
|
||||
|
||||
Quick release for Google.
|
||||
|
||||
<a id="v1.04.1"></a>
|
||||
# [Release v1.04.1: Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.04.1) - 2025-11-15
|
||||
|
||||
@@ -6670,4 +7078,4 @@ First public version with:
|
||||
[v0.4]: https://github.com/vitorpamplona/amethyst/compare/v0.3...v0.4
|
||||
[v0.3]: https://github.com/vitorpamplona/amethyst/compare/v0.2...v0.3
|
||||
[v0.2]: https://github.com/vitorpamplona/amethyst/compare/v0.1...v0.2
|
||||
[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1
|
||||
[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1
|
||||
|
||||
151
README.md
151
README.md
@@ -42,13 +42,13 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Events / Relay Subscriptions (NIP-01)
|
||||
- [x] Follow List (NIP-02)
|
||||
- [x] OpenTimestamps Attestations (NIP-03)
|
||||
- [x] Private Messages (NIP-04)
|
||||
- [x] Private Messages (NIP-04 -- to be removed)
|
||||
- [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] Replies, mentions, Threads, and Notifications (NIP-10)
|
||||
- [x] Text Notes and Threads (NIP-10)
|
||||
- [x] Relay Information Document (NIP-11)
|
||||
- [x] Generic Tag Queries (NIP-12)
|
||||
- [x] Proof of Work Display (NIP-13)
|
||||
@@ -57,9 +57,9 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [x] Event Treatment (NIP-16)
|
||||
- [x] Private Direct Messages (NIP-17)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] Image/Video/Url/LnInvoice/Cashu Previews
|
||||
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
|
||||
- [x] Bech Encoding support (NIP-19)
|
||||
- [x] Bech32 Encoding support (NIP-19)
|
||||
- [x] Command Results (NIP-20)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Long-form Content (NIP-23) (view only)
|
||||
@@ -70,45 +70,59 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [ ] Relay-based Groups (NIP-29)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Event kind summaries (NIP-31)
|
||||
- [x] Event alt descriptors (NIP-31)
|
||||
- [ ] Labeling (NIP-32)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Git Stuff (NIP-34)
|
||||
- [ ] Torrents (NIP-35)
|
||||
- [x] Torrents (NIP-35)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] Edits (NIP-37/Draft)
|
||||
- [x] Drafts (NIP-37)
|
||||
- [x] User Status Event (NIP-38)
|
||||
- [x] External Identities (NIP-39)
|
||||
- [x] Expiration Support (NIP-40)
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [ ] Relay Access Metadata and Requests (NIP-43)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44)
|
||||
- [ ] Event Counts (NIP-45, Will not implement)
|
||||
- [ ] Nostr Connect (NIP-46)
|
||||
- [x] Wallet Connect API (NIP-47)
|
||||
- [x] Event Counts (NIP-45)
|
||||
- [o] Nostr Connect (NIP-46)
|
||||
- [o] Wallet Connect API (NIP-47)
|
||||
- [ ] Proxy Tags (NIP-48, Not applicable)
|
||||
- [x] Private key encryption for import/export (NIP-49)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Encryption for import/export (NIP-49)
|
||||
- [x] Relay Search (NIP-50)
|
||||
- [x] Lists (NIP-51)
|
||||
- [ ] Calendar Events (NIP-52)
|
||||
- [o] Calendar Events (NIP-52)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Wiki (NIP-54)
|
||||
- [x] Inline Metadata (NIP-55 - Draft)
|
||||
- [x] Android Signer (NIP-55)
|
||||
- [x] Reporting (NIP-56)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [x] Private Zaps
|
||||
- [x] Zap Splits (NIP-57)
|
||||
- [x] Private Zaps (NIP-57 / Draft)
|
||||
- [x] Zapraiser (NIP-57 / Draft)
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Gift Wraps & Seals (NIP-59)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [x] Polls (NIP-69)
|
||||
- [ ] Cashu Wallets (NIP-60)
|
||||
- [ ] Nutzaps (NIP-61)
|
||||
- [x] Request to Vanish (NIP-62)
|
||||
- [x] Chess (NIP-64)
|
||||
- [x] Relay List Metadata (NIP-65)
|
||||
- [x] Relay Discovery and Monitoring (NIP-66)
|
||||
- [x] Picture-first feeds (NIP-68)
|
||||
- [ ] Peer-to-peer Orders (NIP-69)
|
||||
- [o] Protected Events (NIP-70)
|
||||
- [x] Video Events (NIP-71)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [x] External Content IDs (NIP-73)
|
||||
- [ ] Zap Goals (NIP-75)
|
||||
- [ ] Negentropy Syncing (NIP-77)
|
||||
- [x] Arbitrary Custom App Data (NIP-78)
|
||||
- [ ] NIP-29 Threads (NIP-7D)
|
||||
- [x] Highlights (NIP-84)
|
||||
- [x] Notify Request (NIP-88/Draft)
|
||||
- [x] Trusted Assertions (NIP-85)
|
||||
- [ ] Relay Management API (NIP-86)
|
||||
- [ ] ECash Mint Discoverability (NIP-87)
|
||||
- [x] Polls (NIP-88)
|
||||
- [x] Relay Notify Request
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [x] Data Vending Machine (NIP-90)
|
||||
- [x] Inline Metadata (NIP-92)
|
||||
@@ -117,6 +131,14 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] HTTP File Storage Integration (NIP-96)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Classifieds (NIP-99)
|
||||
- [x] Voice Messages (NIP-A0)
|
||||
- [x] Public Messages (NIP-A4)
|
||||
- [ ] Web Bookmarks
|
||||
- [x] Blossom (NIP-B7)
|
||||
- [ ] Nostr BLE Communications Protocol (NIP-BE)
|
||||
- [x] Code Snippets (NIP-C0)
|
||||
- [ ] NIP-29 Chats (NIP-C7)
|
||||
- [ ] MLS Protocol (NIP-EE)
|
||||
- [x] Audio Tracks (zapstr.live) (kind:31337)
|
||||
- [x] Push Notifications (Google and Unified Push)
|
||||
- [x] In-Device Automatic Translations
|
||||
@@ -128,18 +150,15 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Markdown Support
|
||||
- [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)
|
||||
- [x] Edit Short Notes (NIP-xx/Draft)
|
||||
- [x] NIP Events (NIP-xx/Draft)
|
||||
- [ ] Relationship Status (NIP-xx/Draft)
|
||||
- [ ] Signed Filters (NIP-xx/Draft)
|
||||
- [ ] Key Migration (NIP-xx/Draft)
|
||||
- [ ] Time-based Sync (NIP-xx/Draft)
|
||||
- [x] Image Capture in the app
|
||||
- [x] Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] Workspaces
|
||||
- [ ] Infinity Scroll
|
||||
|
||||
## Privacy and Information Permanence
|
||||
|
||||
@@ -153,9 +172,11 @@ Information shared on Nostr can be re-broadcasted to other servers and should be
|
||||
|
||||
# Development Overview
|
||||
|
||||
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 repository is split between Amethyst, Quartz, Commons, and DesktopApp:
|
||||
- **Amethyst** - Native Android app with Kotlin and Jetpack Compose
|
||||
- **Quartz** - Nostr-commons KMP library for protocol classes shared across platforms
|
||||
- **Commons** - Kotlin Multiplatform module with shared UI components (icons, robohash, blurhash, composables)
|
||||
- **DesktopApp** - Compose Multiplatform Desktop application reusing commons and quartz
|
||||
|
||||
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.
|
||||
@@ -187,11 +208,24 @@ git clone https://github.com/vitorpamplona/amethyst.git
|
||||
Use an Android Studio build action to install and run the app on your device or a simulator.
|
||||
|
||||
## Building
|
||||
Build the app:
|
||||
|
||||
Build the Android app:
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Build and run the Desktop app (requires Java 21+):
|
||||
```bash
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
Full build (including tests)
|
||||
```bash
|
||||
./gradlew build
|
||||
```
|
||||
Requirements:
|
||||
- Xcode and iOS simulator
|
||||
- libsodium installed (e.g. via brew: `brew install libsodium`
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
./gradlew test
|
||||
@@ -239,21 +273,32 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
|
||||
|
||||
### Installing
|
||||
|
||||
Add Maven Central and Google Maven to your repositories:
|
||||
|
||||
```gradle
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
}
|
||||
```
|
||||
|
||||
Add the following line to your `commonMain` dependencies:
|
||||
|
||||
```gradle
|
||||
implementation('com.vitorpamplona.quartz:quartz:<Amethyst Version>')
|
||||
implementation('com.vitorpamplona.quartz:quartz:1:05.0')
|
||||
```
|
||||
|
||||
Variations to each platform are also available:
|
||||
|
||||
```gradle
|
||||
implementation('com.vitorpamplona.quartz:quartz-android:<Amethyst Version>')
|
||||
implementation('com.vitorpamplona.quartz:quartz-jvm:<Amethyst Version>')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iosarm64:<Amethyst Version>')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:<Amethyst Version>')
|
||||
implementation('com.vitorpamplona.quartz:quartz-android:1:05.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-jvm:1:05.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1:05.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1:05.0')
|
||||
```
|
||||
|
||||
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
|
||||
|
||||
### How to use
|
||||
|
||||
Manage logged in users with the `KeyPair` class
|
||||
@@ -281,8 +326,8 @@ val amberSigner = NostrSignerExternal(
|
||||
)
|
||||
```
|
||||
|
||||
Create a single NostrClient for the entire application and control which relays it will access by
|
||||
registering subscriptions and sending events. The pool will automatically changed based on filters +
|
||||
Create a single `NostrClient` for the entire application and control which relays it will access by
|
||||
registering subscriptions and sending events. The pool will automatically change based on filters +
|
||||
outbox events.
|
||||
|
||||
You will need a coroutine scope to process events and if you are using OKHttp, we offer a basic
|
||||
@@ -299,9 +344,11 @@ val client = NostrClient(socketBuilder, appScope)
|
||||
If you want to auth, given a logged-in `signer`:
|
||||
|
||||
```kt
|
||||
val authCoordinator = RelayAuthenticator(client, appScope) { challenge, relay ->
|
||||
val authedEvent = RelayAuthEvent.create(relay.url, challenge, signer)
|
||||
client.sendIfExists(authedEvent, relay.url)
|
||||
val authCoordinator = RelayAuthenticator(client, appScope) { authTemplate ->
|
||||
listOf(
|
||||
// for each signed-in user, return an event
|
||||
signer.sign(authTemplate)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -309,8 +356,8 @@ To make a request subscription simply do:
|
||||
|
||||
```kt
|
||||
val metadataSub = client.req(
|
||||
relays = listOf("wss://nos.lol", "wss://nostr.mom"),
|
||||
filters = Filter(
|
||||
relay = "wss://nos.lol",
|
||||
filter = Filter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
authors = listOf(signer.pubkey)
|
||||
)
|
||||
@@ -356,6 +403,28 @@ When your app goes to the background, you can use NostrClient's `connect` and `d
|
||||
methods to stop all communication to relays. Add the `connect` to your `onResume` and `disconnect`
|
||||
to `onPause` methods.
|
||||
|
||||
### Feature Parity Table
|
||||
|
||||
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
|
||||
| :--- | :--- | :---: | :---: | :--- |
|
||||
| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. |
|
||||
| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. |
|
||||
| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. |
|
||||
| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. |
|
||||
| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. |
|
||||
| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. |
|
||||
| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. |
|
||||
| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. |
|
||||
| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. |
|
||||
| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. |
|
||||
| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
|
||||
| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
|
||||
| **Utilities** | URL Encoding / Decoding | ✅ Full | ❌ No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. |
|
||||
| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. |
|
||||
| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
|
||||
| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst)
|
||||
|
||||
230
SKILL.md
Normal file
230
SKILL.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Amethyst Builder Skill
|
||||
|
||||
Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, and distribute your own version.
|
||||
|
||||
## Overview
|
||||
|
||||
[Amethyst](https://github.com/vitorpamplona/amethyst) is the premier Nostr client for Android. This skill enables you to:
|
||||
- Create rebranded versions (custom name, package, icons)
|
||||
- Build F-Droid-compatible releases (no Google Play dependencies)
|
||||
- Add or modify features
|
||||
- Sign and distribute APKs
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Tools
|
||||
|
||||
1. **Java 21** (via SDKMAN)
|
||||
```bash
|
||||
curl -s "https://get.sdkman.io" | bash
|
||||
source "$HOME/.sdkman/bin/sdkman-init.sh"
|
||||
sdk install java 21.0.5-tem
|
||||
sdk use java 21.0.5-tem
|
||||
```
|
||||
|
||||
2. **Android SDK**
|
||||
- Command-line tools from https://developer.android.com/studio#command-line-tools-only
|
||||
- Required components: build-tools, platform-tools, platforms;android-35
|
||||
|
||||
3. **Git** for cloning the repository
|
||||
|
||||
### Environment Setup
|
||||
|
||||
```bash
|
||||
export ANDROID_HOME=$HOME/Android/Sdk
|
||||
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin
|
||||
export PATH=$PATH:$ANDROID_HOME/platform-tools
|
||||
```
|
||||
|
||||
## Build Workflow
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
mkdir -p ~/projects/your-app-name
|
||||
cd ~/projects/your-app-name
|
||||
git clone https://github.com/vitorpamplona/amethyst.git .
|
||||
```
|
||||
|
||||
### 2. Create Signing Key
|
||||
|
||||
```bash
|
||||
keytool -genkeypair -v \
|
||||
-keystore ./release-key.jks \
|
||||
-alias your-app \
|
||||
-keyalg RSA -keysize 2048 \
|
||||
-validity 10000
|
||||
```
|
||||
|
||||
Create `keystore.properties` in project root:
|
||||
```properties
|
||||
storeFile=release-key.jks
|
||||
storePassword=your-password
|
||||
keyAlias=your-app
|
||||
keyPassword=your-password
|
||||
```
|
||||
|
||||
### 3. Configure Signing
|
||||
|
||||
Add to `amethyst/build.gradle` inside the `android {}` block:
|
||||
|
||||
```gradle
|
||||
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
def keystoreProperties = new Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
storeFile rootProject.file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update the release buildType to use the signing config:
|
||||
```gradle
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
// ... existing config
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Disable Google Services (Required for F-Droid)
|
||||
|
||||
**⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it.
|
||||
|
||||
Edit `amethyst/build.gradle`, comment out the plugin:
|
||||
```gradle
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.jetbrainsKotlinAndroid)
|
||||
// alias(libs.plugins.googleServices) // DISABLED for F-Droid
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
source "$HOME/.sdkman/bin/sdkman-init.sh"
|
||||
sdk use java 21.0.5-tem
|
||||
./gradlew assembleFdroidRelease
|
||||
```
|
||||
|
||||
**Build time:** ~9 minutes first build, faster on subsequent builds.
|
||||
|
||||
### 6. Locate APKs
|
||||
|
||||
Output directory: `amethyst/build/outputs/apk/fdroid/release/`
|
||||
|
||||
Files generated:
|
||||
- `amethyst-fdroid-arm64-v8a-release.apk` - ARM64 (most phones)
|
||||
- `amethyst-fdroid-universal-release.apk` - All architectures (larger)
|
||||
|
||||
## Customizations
|
||||
|
||||
### Change App Name
|
||||
|
||||
Edit `amethyst/src/main/res/values/strings.xml`:
|
||||
```xml
|
||||
<string name="app_name" translatable="false">YourAppName</string>
|
||||
<string name="app_name_debug" translatable="false">YourAppName Debug</string>
|
||||
```
|
||||
|
||||
### Change Package ID
|
||||
|
||||
Edit `amethyst/build.gradle`:
|
||||
```gradle
|
||||
android {
|
||||
defaultConfig {
|
||||
applicationId = "com.yourcompany.yourapp"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Change Project Name
|
||||
|
||||
Edit `settings.gradle`:
|
||||
```gradle
|
||||
rootProject.name = "YourAppName"
|
||||
```
|
||||
|
||||
### Change App Icon
|
||||
|
||||
Replace icon files in:
|
||||
- `amethyst/src/main/res/mipmap-*/ic_launcher.webp`
|
||||
- `amethyst/src/main/res/mipmap-*/ic_launcher_round.webp`
|
||||
|
||||
### Add Client Tag to Posts
|
||||
|
||||
Make your app identify itself on posts with `["client", "YourAppName"]`.
|
||||
|
||||
**1. Create tag builder extension:**
|
||||
|
||||
Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/clientTag/TagArrayBuilderExt.kt`:
|
||||
```kotlin
|
||||
package com.vitorpamplona.quartz.nip01Core.tags.clientTag
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.client(clientName: String) =
|
||||
addUnique(arrayOf(ClientTag.TAG_NAME, clientName))
|
||||
```
|
||||
|
||||
**2. Add to TextNoteEvent:**
|
||||
|
||||
Edit `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt`:
|
||||
|
||||
Add import:
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.clientTag.client
|
||||
```
|
||||
|
||||
In both `build()` functions, add after `alt(...)`:
|
||||
```kotlin
|
||||
client("YourAppName")
|
||||
```
|
||||
|
||||
### Modify Default Relays
|
||||
|
||||
Edit relay configuration in `quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/` or the UI settings files.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### google-services.json error
|
||||
Disable the Google Services plugin (see step 4).
|
||||
|
||||
### Java version error
|
||||
```bash
|
||||
sdk use java 21.0.5-tem
|
||||
java -version # Must show 21.x
|
||||
```
|
||||
|
||||
### Out of memory
|
||||
Edit `gradle.properties`:
|
||||
```properties
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m
|
||||
```
|
||||
|
||||
### Clean build
|
||||
```bash
|
||||
./gradlew --stop
|
||||
./gradlew clean
|
||||
./gradlew assembleFdroidRelease
|
||||
```
|
||||
|
||||
## Distribution
|
||||
|
||||
Deploy APKs via:
|
||||
- **Surge.sh:** `surge ./releases your-app.surge.sh`
|
||||
- **Zapstore:** Submit to zapstore.dev
|
||||
- **Direct download:** Host on any web server
|
||||
@@ -2,7 +2,6 @@ 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)
|
||||
@@ -36,6 +35,17 @@ def generateVersionName(String baseVersion) {
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
|
||||
afterEvaluate {
|
||||
def stabilityNames = tasks.names.findAll { it.contains("StabilityCheck") }
|
||||
def compileNames = tasks.names.findAll { it.matches("compile.*UnitTestKotlin") }
|
||||
stabilityNames.each { scName ->
|
||||
compileNames.each { ctName ->
|
||||
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = 'com.vitorpamplona.amethyst'
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInteger()
|
||||
@@ -44,9 +54,9 @@ android {
|
||||
applicationId = "com.vitorpamplona.amethyst"
|
||||
minSdk = libs.versions.android.minSdk.get().toInteger()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInteger()
|
||||
versionCode = 430
|
||||
versionName = generateVersionName("1.04.2")
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"3a03c75d85aaf6b181d3b232d064c4d4feea5c73f0bea2bd91ed61b8da7cd6a6\""
|
||||
versionCode = 434
|
||||
versionName = generateVersionName("1.06.2")
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -191,6 +201,7 @@ android {
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
resValues = true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
@@ -279,7 +290,7 @@ dependencies {
|
||||
// view videos
|
||||
implementation libs.androidx.media3.exoplayer
|
||||
implementation libs.androidx.media3.exoplayer.hls
|
||||
implementation libs.androidx.media3.ui
|
||||
implementation libs.androidx.media3.ui.compose.material3
|
||||
implementation libs.androidx.media3.session
|
||||
|
||||
// important for proxy / tor
|
||||
@@ -293,6 +304,10 @@ dependencies {
|
||||
implementation libs.coil.svg
|
||||
// enables network for coil
|
||||
implementation libs.coil.okhttp
|
||||
// loads thumbnails for media3
|
||||
// TODO: Replace this to the FrameExtractor in media 3
|
||||
// when FrameExtractor accepts custom data sources.
|
||||
implementation(libs.coil.video)
|
||||
|
||||
// Permission to upload pictures:
|
||||
implementation libs.accompanist.permissions
|
||||
@@ -331,9 +346,7 @@ dependencies {
|
||||
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
|
||||
@@ -347,6 +360,12 @@ dependencies {
|
||||
// Image compression lib
|
||||
implementation libs.zelory.image.compressor
|
||||
|
||||
// EXIF metadata stripping
|
||||
implementation libs.androidx.exifinterface
|
||||
|
||||
// Voice anonymization DSP
|
||||
implementation libs.tarsosdsp
|
||||
|
||||
// Cbor for cashuB format
|
||||
implementation libs.kotlinx.serialization.cbor
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -80,7 +80,7 @@ class ImageUploadTesting {
|
||||
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/${BuildConfig.VERSION_NAME}"))
|
||||
.build()
|
||||
|
||||
private suspend fun getBitmap(): ByteArray {
|
||||
private fun getBitmap(): ByteArray {
|
||||
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
|
||||
for (x in 0 until bitmap.width) {
|
||||
for (y in 0 until bitmap.height) {
|
||||
@@ -121,20 +121,20 @@ class ImageUploadTesting {
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
)
|
||||
|
||||
assertEquals("image/png", result.type)
|
||||
assertEquals(paylod.size.toLong(), result.size)
|
||||
assertEquals(initialHash, result.sha256)
|
||||
assertEquals("${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png"))
|
||||
assertEquals(server.baseUrl, "image/png", result.type)
|
||||
assertEquals(server.baseUrl, paylod.size.toLong(), result.size)
|
||||
assertEquals(server.baseUrl, initialHash, result.sha256)
|
||||
// assertEquals(server.baseUrl, "${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png"))
|
||||
|
||||
val imageData: ByteArray =
|
||||
ImageDownloader().waitAndGetImage(result.url!!, { client })?.bytes
|
||||
ImageDownloader().waitAndGetImage(result.url!!) { client }?.bytes
|
||||
?: run {
|
||||
fail("${server.name}: Should not be null")
|
||||
return
|
||||
}
|
||||
|
||||
val downloadedHash = sha256(imageData).toHexKey()
|
||||
assertEquals(initialHash, downloadedHash)
|
||||
assertEquals(server.baseUrl, initialHash, downloadedHash)
|
||||
}
|
||||
|
||||
private suspend fun testNip96(server: ServerName) {
|
||||
@@ -142,8 +142,7 @@ class ImageUploadTesting {
|
||||
ServerInfoRetriever()
|
||||
.loadInfo(
|
||||
server.baseUrl,
|
||||
{ client },
|
||||
)
|
||||
) { client }
|
||||
|
||||
val payload = getBitmap()
|
||||
val inputStream = payload.inputStream()
|
||||
@@ -172,7 +171,7 @@ class ImageUploadTesting {
|
||||
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
|
||||
|
||||
val imageData: ByteArray =
|
||||
ImageDownloader().waitAndGetImage(url, { client })?.bytes
|
||||
ImageDownloader().waitAndGetImage(url) { client }?.bytes
|
||||
?: run {
|
||||
fail("${server.name}: Should not be null")
|
||||
return
|
||||
@@ -207,8 +206,13 @@ class ImageUploadTesting {
|
||||
runBlocking {
|
||||
DEFAULT_MEDIA_SERVERS.forEach {
|
||||
// skip paid servers and primal server is buggy.
|
||||
if (!it.name.contains("Paid") && !it.name.contains("Primal")) {
|
||||
testBase(it)
|
||||
try {
|
||||
if (!it.name.contains("Paid") && !it.name.contains("Primal")) {
|
||||
testBase(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
fail("Could not upload to: ${it.baseUrl}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,14 +230,6 @@ class ImageUploadTesting {
|
||||
testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testSove() =
|
||||
runBlocking {
|
||||
testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Ignore("Not Working anymore")
|
||||
@Test()
|
||||
fun testNostrBuild() =
|
||||
runBlocking {
|
||||
@@ -241,10 +237,10 @@ class ImageUploadTesting {
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
@Ignore("Returns invalid hash")
|
||||
fun testSovbit() =
|
||||
runBlocking {
|
||||
testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96))
|
||||
testBase(ServerName("sovbit", "https://cdn.sovbit.host", ServerType.Blossom))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@@ -260,13 +256,6 @@ class ImageUploadTesting {
|
||||
testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore("Not Working anymore")
|
||||
fun testNostrOnch() =
|
||||
runBlocking {
|
||||
testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96))
|
||||
}
|
||||
|
||||
@Ignore("Changes sha256")
|
||||
fun testPrimalBlossom() =
|
||||
runBlocking {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.ThreadFeedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
@@ -174,7 +174,7 @@ class ThreadDualAxisChartAssemblerTest {
|
||||
null,
|
||||
)
|
||||
|
||||
val filter = ThreadFeedFilter(account, naddr.toTag())
|
||||
val filter = ThreadFeedFilter(account, naddr.toTag(), LocalCache)
|
||||
val calculatedFeed = filter.feed()
|
||||
|
||||
val expectedOrder =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -28,6 +28,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -77,8 +78,19 @@ class UrlUserTagTransformationTest {
|
||||
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
|
||||
.toHexKey(),
|
||||
)
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
user.metadata().newMetadata(
|
||||
UserMetadata().also {
|
||||
it.displayName = "Vitor Pamplona"
|
||||
},
|
||||
MetadataEvent(
|
||||
id = "",
|
||||
pubKey = "",
|
||||
createdAt = 0,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "",
|
||||
),
|
||||
)
|
||||
|
||||
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
|
||||
|
||||
@@ -185,8 +197,19 @@ class UrlUserTagTransformationTest {
|
||||
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
|
||||
.toHexKey(),
|
||||
)
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
user.metadata().newMetadata(
|
||||
UserMetadata().also {
|
||||
it.displayName = "Vitor Pamplona"
|
||||
},
|
||||
MetadataEvent(
|
||||
id = "",
|
||||
pubKey = "",
|
||||
createdAt = 0,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "",
|
||||
),
|
||||
)
|
||||
|
||||
val transformedText =
|
||||
buildAnnotatedStringWithUrlHighlighting(
|
||||
@@ -224,8 +247,20 @@ class UrlUserTagTransformationTest {
|
||||
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
|
||||
.toHexKey(),
|
||||
)
|
||||
user.info = UserMetadata()
|
||||
user.info?.displayName = "Vitor Pamplona"
|
||||
|
||||
user.metadata().newMetadata(
|
||||
UserMetadata().also {
|
||||
it.displayName = "Vitor Pamplona"
|
||||
},
|
||||
MetadataEvent(
|
||||
id = "",
|
||||
pubKey = "",
|
||||
createdAt = 0,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "",
|
||||
),
|
||||
)
|
||||
|
||||
val transformedText =
|
||||
buildAnnotatedStringWithUrlHighlighting(
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.Constants
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EventSyncTest {
|
||||
companion object {
|
||||
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
|
||||
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
|
||||
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
val rootClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
|
||||
.build()
|
||||
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSync() =
|
||||
runBlocking {
|
||||
val sync =
|
||||
EventSync(
|
||||
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
relayDb = {
|
||||
listOf(Constants.mom, Constants.nos)
|
||||
},
|
||||
outboxTargets = { setOf(vitor) },
|
||||
inboxTargets = { setOf(vitor) },
|
||||
dmTargets = { setOf(vitor) },
|
||||
clientBuilder = {
|
||||
NostrClient(socketBuilder, appScope)
|
||||
},
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
sync.runSync()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFiatjafSync() =
|
||||
runBlocking {
|
||||
val sync =
|
||||
EventSync(
|
||||
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
relayDb = { listOf(fiatjaf) },
|
||||
outboxTargets = { setOf(vitor) },
|
||||
inboxTargets = { setOf(vitor) },
|
||||
dmTargets = { setOf(vitor) },
|
||||
clientBuilder = {
|
||||
val newClient = NostrClient(socketBuilder, appScope)
|
||||
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
|
||||
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
|
||||
// Authenticates with relays.
|
||||
val auth =
|
||||
RelayAuthenticator(
|
||||
newClient,
|
||||
appScope,
|
||||
signWithAllLoggedInUsers = { authTemplate ->
|
||||
listOf(signer.sign(authTemplate))
|
||||
},
|
||||
)
|
||||
|
||||
newClient
|
||||
},
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
sync.runSync()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -26,10 +26,10 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
|
||||
|
||||
@Composable
|
||||
fun TranslatableRichTextViewer(
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
android:launchMode="singleInstance"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:taskAffinity=".service.playback.pip.PipVideoActivity"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
@@ -133,6 +132,42 @@
|
||||
<data android:host="njump.me" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="njump.to">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="njump.to" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="iris.to">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="iris.to" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="zap.stream">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="zap.stream" />
|
||||
<data android:pathPrefix="/p/" />
|
||||
<data android:pathPrefix="/" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="shosho.live">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="shosho.live" />
|
||||
<data android:pathPrefix="/profile/" />
|
||||
<data android:pathPrefix="/live/" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Primal">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -205,6 +240,11 @@
|
||||
<action android:name="com.shared.NOSTR" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".service.notifications.NotificationReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -25,11 +25,15 @@ import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
|
||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
@@ -44,6 +48,7 @@ import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
|
||||
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
@@ -53,10 +58,15 @@ import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
|
||||
@@ -65,12 +75,24 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@@ -86,7 +108,6 @@ class AppModules(
|
||||
}
|
||||
|
||||
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
|
||||
val applicationDefaultScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
|
||||
|
||||
// Blocking load of UI Preferences to avoid theme/language blinking
|
||||
val uiPrefs by lazy {
|
||||
@@ -98,6 +119,16 @@ class AppModules(
|
||||
TorSharedPreferences(appContext, applicationIOScope)
|
||||
}
|
||||
|
||||
// Namecoin ElectrumX server preferences (global, like Tor settings)
|
||||
val namecoinPrefs by lazy {
|
||||
NamecoinSharedPreferences(appContext, applicationIOScope)
|
||||
}
|
||||
|
||||
// OTS blockchain explorer preferences (global, like Tor settings)
|
||||
val otsPrefs by lazy {
|
||||
OtsSharedPreferences(appContext, applicationIOScope)
|
||||
}
|
||||
|
||||
// App services that should be run as soon as there are subscribers to their flows
|
||||
val locationManager = LocationState(appContext, applicationIOScope)
|
||||
val connManager = ConnectivityManager(appContext, applicationIOScope)
|
||||
@@ -124,19 +155,30 @@ class AppModules(
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
// manages all relay connections
|
||||
val okHttpClientForRelays =
|
||||
DualHttpClientManager(
|
||||
userAgent = appAgent,
|
||||
proxyPortProvider = torManager.activePortOrNull,
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
keyCache = keyCache,
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
// Offers easy methods to know when connections are happening through Tor or not
|
||||
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
|
||||
|
||||
// Custom fetcher that considers tor settings and avoids forwarding.
|
||||
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
|
||||
|
||||
val namecoinResolver =
|
||||
NamecoinNameResolver(
|
||||
electrumxClient =
|
||||
ElectrumXClient(
|
||||
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
|
||||
),
|
||||
serverListProvider = {
|
||||
// User-configured custom servers take priority
|
||||
namecoinPrefs.customServersOrNull
|
||||
?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
|
||||
TOR_ELECTRUMX_SERVERS
|
||||
} else {
|
||||
DEFAULT_ELECTRUMX_SERVERS
|
||||
}
|
||||
},
|
||||
)
|
||||
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
|
||||
|
||||
// Application-wide block height request cache
|
||||
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
|
||||
|
||||
@@ -145,6 +187,7 @@ class AppModules(
|
||||
roleBasedHttpClientBuilder::okHttpClientForMoney,
|
||||
roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations,
|
||||
otsBlockHeightCache,
|
||||
customExplorerUrl = { otsPrefs.current.normalizedUrl() },
|
||||
)
|
||||
|
||||
// Application-wide ots verification cache
|
||||
@@ -154,7 +197,16 @@ class AppModules(
|
||||
TorRelayState(
|
||||
okHttpClients,
|
||||
torPrefs.value,
|
||||
applicationDefaultScope,
|
||||
applicationIOScope,
|
||||
)
|
||||
|
||||
// manages all relay connections
|
||||
val okHttpClientForRelays =
|
||||
DualHttpClientManagerForRelays(
|
||||
userAgent = appAgent,
|
||||
proxyPortProvider = torManager.activePortOrNull,
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
// Connects the NostrClient class with okHttp
|
||||
@@ -168,7 +220,7 @@ class AppModules(
|
||||
val cache: LocalCache = LocalCache
|
||||
|
||||
// Provides a relay pool
|
||||
val client: INostrClient = NostrClient(websocketBuilder, applicationDefaultScope)
|
||||
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
|
||||
|
||||
// Watches for changes on Tor and Relay List Settings
|
||||
val relayProxyClientConnector =
|
||||
@@ -178,7 +230,7 @@ class AppModules(
|
||||
connManager,
|
||||
torManager,
|
||||
client,
|
||||
applicationDefaultScope,
|
||||
applicationIOScope,
|
||||
)
|
||||
|
||||
// Verifies and inserts in the cache from all relays, all subscriptions
|
||||
@@ -188,10 +240,10 @@ class AppModules(
|
||||
val notifyCoordinator = NotifyCoordinator(client)
|
||||
|
||||
// Authenticates with relays.
|
||||
val authCoordinator = AuthCoordinator(client, applicationDefaultScope)
|
||||
val authCoordinator = AuthCoordinator(client, applicationIOScope)
|
||||
|
||||
// Tries to verify new OTS events when they arrive.
|
||||
val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationDefaultScope)
|
||||
val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationIOScope)
|
||||
|
||||
// Tracks if it is possible to connect to relays.
|
||||
val failureTracker = RelayOfflineTracker(client)
|
||||
@@ -200,7 +252,7 @@ class AppModules(
|
||||
val relayStats = RelayStats(client)
|
||||
|
||||
// Logs debug messages when needed
|
||||
val detailedLogger = if (isDebug) RelayLogger(client, true, false) else null
|
||||
val detailedLogger = if (isDebug) RelayLogger(client, debugSending = false, debugReceiving = false) else null
|
||||
val relayReqStats = if (isDebug) RelayReqStats(client) else null
|
||||
val logger = if (isDebug) RelaySpeedLogger(client) else null
|
||||
|
||||
@@ -211,7 +263,7 @@ class AppModules(
|
||||
client,
|
||||
authCoordinator.receiver,
|
||||
failureTracker,
|
||||
applicationDefaultScope,
|
||||
applicationIOScope,
|
||||
)
|
||||
|
||||
// keeps all accounts live
|
||||
@@ -225,12 +277,59 @@ class AppModules(
|
||||
client = client,
|
||||
)
|
||||
|
||||
val sessionManager =
|
||||
AccountSessionManager(
|
||||
accountsCache = accountsCache,
|
||||
nip05Client = nip05Client,
|
||||
client = client,
|
||||
localPreferences = LocalPreferences,
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
fun subscribedFlow(
|
||||
address: Address,
|
||||
account: Account,
|
||||
): Flow<NoteState> {
|
||||
val note = cache.getOrCreateAddressableNote(address)
|
||||
|
||||
val userSub = UserFinderQueryState(note.author ?: cache.getOrCreateUser(address.pubKeyHex), account)
|
||||
val noteSub = EventFinderQueryState(note, account)
|
||||
|
||||
return note
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.onStart {
|
||||
sources.userFinder.subscribe(userSub)
|
||||
sources.eventFinder.subscribe(noteSub)
|
||||
}.onCompletion {
|
||||
sources.eventFinder.unsubscribe(noteSub)
|
||||
sources.userFinder.unsubscribe(userSub)
|
||||
}
|
||||
}
|
||||
|
||||
val blossomResolver =
|
||||
BlossomServerResolver(
|
||||
loggedInUsers = { listOfNotNull(sessionManager.loggedInAccount()?.pubKey) },
|
||||
blossomServers = { addressesToSubscribe ->
|
||||
val account = sessionManager.loggedInAccount() ?: return@BlossomServerResolver listOf()
|
||||
addressesToSubscribe.map { address ->
|
||||
subscribedFlow(address, account).transform {
|
||||
val event = it.note.event as? BlossomServersEvent
|
||||
if (event != null) {
|
||||
emit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
httpClientBuilder = roleBasedHttpClientBuilder,
|
||||
)
|
||||
|
||||
// Organizes cache clearing
|
||||
val trimmingService = MemoryTrimmingService(cache)
|
||||
|
||||
// as new accounts are loaded, updates the state of the TorRelaySettings, which produces new TorRelayEvaluator
|
||||
// and reconnects relays if the configuration has been changed.
|
||||
val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationDefaultScope)
|
||||
val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationIOScope)
|
||||
|
||||
// saves the .content of NIP-95 blobs in disk to save memory
|
||||
val nip95cache: File by lazy { Nip95CacheFactory.new(appContext) }
|
||||
@@ -255,7 +354,12 @@ class AppModules(
|
||||
fun contentResolverFn(): ContentResolver = appContext.contentResolver
|
||||
|
||||
fun setImageLoader() {
|
||||
ImageLoaderSetup.setup(appContext, { diskCache }, { memoryCache }) { url ->
|
||||
ImageLoaderSetup.setup(
|
||||
app = appContext,
|
||||
diskCache = { diskCache },
|
||||
memoryCache = { memoryCache },
|
||||
blossomServerResolver = blossomResolver,
|
||||
) { url ->
|
||||
okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url))
|
||||
}
|
||||
}
|
||||
@@ -268,6 +372,7 @@ class AppModules(
|
||||
applicationIOScope.launch {
|
||||
// loads main account quickly.
|
||||
LocalPreferences.loadAccountConfigFromEncryptedStorage()
|
||||
sessionManager.loginWithDefaultAccountIfLoggedOff()
|
||||
}
|
||||
|
||||
// forces initialization of uiPrefs in the main thread to avoid blinking themes
|
||||
@@ -287,17 +392,28 @@ class AppModules(
|
||||
|
||||
// registers to receive events
|
||||
pokeyReceiver.register(appContext)
|
||||
|
||||
// initializes diskcache on an IO thread.
|
||||
applicationIOScope.launch {
|
||||
// Sets Coil - Tor - OkHttp link
|
||||
delay(3000)
|
||||
videoCache
|
||||
}
|
||||
|
||||
applicationIOScope.launch {
|
||||
// Eagerly initialize OtsSharedPreferences off the main thread
|
||||
otsPrefs
|
||||
}
|
||||
}
|
||||
|
||||
fun terminate(appContext: Context) {
|
||||
pokeyReceiver.unregister(appContext)
|
||||
applicationIOScope.cancel("Application onTerminate $appContext")
|
||||
applicationDefaultScope.cancel("Application onTerminate $appContext")
|
||||
accountsCache.clear()
|
||||
}
|
||||
|
||||
fun trim() {
|
||||
applicationDefaultScope.launch {
|
||||
applicationIOScope.launch {
|
||||
val loggedIn = accountsCache.accounts.value.values
|
||||
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -26,8 +26,11 @@ import android.content.pm.ApplicationInfo
|
||||
import android.os.Debug
|
||||
import androidx.core.content.getSystemService
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
|
||||
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@@ -58,17 +61,6 @@ fun debugState(context: Context) {
|
||||
Log.d(STATE_DUMP_TAG, "Memory Class $memClass MB (largeHeap $isLargeHeap)")
|
||||
}
|
||||
|
||||
Log.d(
|
||||
STATE_DUMP_TAG,
|
||||
"Connected Relays: " +
|
||||
Amethyst.instance.client
|
||||
.relayStatusFlow()
|
||||
.value.connected.size + "/" +
|
||||
Amethyst.instance.client
|
||||
.relayStatusFlow()
|
||||
.value.available.size,
|
||||
)
|
||||
|
||||
Log.d(
|
||||
STATE_DUMP_TAG,
|
||||
"Indexed Relays: " +
|
||||
@@ -106,9 +98,7 @@ fun debugState(context: Context) {
|
||||
Log.d(
|
||||
STATE_DUMP_TAG,
|
||||
"Users: " +
|
||||
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
|
||||
LocalCache.users.filter { _, it -> it.metadataOrNull() != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.size(),
|
||||
)
|
||||
@@ -155,10 +145,8 @@ fun debugState(context: Context) {
|
||||
)
|
||||
Log.d(
|
||||
STATE_DUMP_TAG,
|
||||
"Observable Events: " +
|
||||
LocalCache.observablesByKindAndETag.size +
|
||||
" / " +
|
||||
LocalCache.observablesByKindAndAuthor.size,
|
||||
"Observables: " +
|
||||
LocalCache.observables.size,
|
||||
)
|
||||
|
||||
Log.d(
|
||||
@@ -239,3 +227,12 @@ inline fun debug(
|
||||
Log.d(tag, debugMessage())
|
||||
}
|
||||
}
|
||||
|
||||
fun Event.countMemory(): Int =
|
||||
7 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit)
|
||||
12 + // createdAt + kind
|
||||
id.bytesUsedInMemory() +
|
||||
pubKey.bytesUsedInMemory() +
|
||||
tags.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } +
|
||||
content.bytesUsedInMemory() +
|
||||
sig.bytesUsedInMemory()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -25,14 +25,15 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.core.content.edit
|
||||
import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
@@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
@@ -89,6 +91,7 @@ private object PrefKeys {
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val LOCAL_RELAY_SERVERS = "localRelayServers"
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
@@ -100,6 +103,7 @@ private object PrefKeys {
|
||||
const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList"
|
||||
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
|
||||
const val LATEST_INDEX_RELAY_LIST = "latestIndexRelayList"
|
||||
const val LATEST_RELAY_FEEDS_LIST = "latestRelayFeedsList"
|
||||
const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList"
|
||||
const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList"
|
||||
const val LATEST_MUTE_LIST = "latestMuteList"
|
||||
@@ -110,6 +114,7 @@ private object PrefKeys {
|
||||
const val LATEST_HASHTAG_LIST = "latestHashtagList"
|
||||
const val LATEST_GEOHASH_LIST = "latestGeohashList"
|
||||
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
|
||||
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
|
||||
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
|
||||
@@ -124,6 +129,7 @@ private object PrefKeys {
|
||||
|
||||
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
|
||||
const val SHARED_SETTINGS = "shared_settings"
|
||||
const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets"
|
||||
}
|
||||
|
||||
object LocalPreferences {
|
||||
@@ -246,8 +252,8 @@ object LocalPreferences {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
File(prefsDir, it).delete()
|
||||
if (it.contains(npub) && !File(prefsDir, it).delete()) {
|
||||
Log.w("LocalPreferences", "Failed to delete preference file: $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,16 +322,13 @@ object LocalPreferences {
|
||||
PrefKeys.DEFAULT_FILE_SERVER,
|
||||
JsonMapper.toJson(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,
|
||||
)
|
||||
|
||||
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
|
||||
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultDiscoveryFollowList.value))
|
||||
|
||||
putOrRemove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, settings.zapPaymentRequest.value?.denormalize())
|
||||
|
||||
@@ -336,6 +339,7 @@ object LocalPreferences {
|
||||
putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList)
|
||||
putOrRemove(PrefKeys.LATEST_SEARCH_RELAY_LIST, settings.backupSearchRelayList)
|
||||
putOrRemove(PrefKeys.LATEST_INDEX_RELAY_LIST, settings.backupIndexRelayList)
|
||||
putOrRemove(PrefKeys.LATEST_RELAY_FEEDS_LIST, settings.backupRelayFeedsList)
|
||||
putOrRemove(PrefKeys.LATEST_BLOCKED_RELAY_LIST, settings.backupBlockedRelayList)
|
||||
putOrRemove(PrefKeys.LATEST_TRUSTED_RELAY_LIST, settings.backupTrustedRelayList)
|
||||
|
||||
@@ -354,6 +358,8 @@ object LocalPreferences {
|
||||
putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList)
|
||||
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
|
||||
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
|
||||
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
|
||||
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
|
||||
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
|
||||
@@ -451,17 +457,14 @@ object LocalPreferences {
|
||||
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
|
||||
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
|
||||
|
||||
val defaultHomeFollowList =
|
||||
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: ALL_FOLLOWS
|
||||
val defaultStoriesFollowList =
|
||||
getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultNotificationFollowList =
|
||||
getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultDiscoveryFollowList =
|
||||
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultHomeFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_HOME_FOLLOW_LIST) ?: TopFilter.AllFollows
|
||||
val defaultStoriesFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST) ?: TopFilter.Global
|
||||
val defaultNotificationFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST) ?: TopFilter.Global
|
||||
val defaultDiscoveryFollowList = parseOrNull<TopFilter>(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST) ?: TopFilter.Global
|
||||
|
||||
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
|
||||
val defaultFileServer = parseOrNull<ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0]
|
||||
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
|
||||
|
||||
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
|
||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||
@@ -472,6 +475,7 @@ object LocalPreferences {
|
||||
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
|
||||
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
|
||||
val latestIndexRelayList = parseEventOrNull<IndexerRelayListEvent>(PrefKeys.LATEST_INDEX_RELAY_LIST)
|
||||
val latestRelayFeedsList = parseEventOrNull<RelayFeedsListEvent>(PrefKeys.LATEST_RELAY_FEEDS_LIST)
|
||||
val latestBlockedRelayList = parseEventOrNull<BlockedRelayListEvent>(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
|
||||
val latestTrustedRelayList = parseEventOrNull<TrustedRelayListEvent>(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
|
||||
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
|
||||
@@ -482,6 +486,8 @@ object LocalPreferences {
|
||||
val latestHashtagList = parseEventOrNull<HashtagListEvent>(PrefKeys.LATEST_HASHTAG_LIST)
|
||||
val latestGeohashList = parseEventOrNull<GeohashListEvent>(PrefKeys.LATEST_GEOHASH_LIST)
|
||||
val latestEphemeralList = parseEventOrNull<EphemeralChatListEvent>(PrefKeys.LATEST_EPHEMERAL_LIST)
|
||||
val latestTrustProviderList = parseEventOrNull<TrustProviderListEvent>(PrefKeys.LATEST_TRUST_PROVIDER_LIST)
|
||||
val latestPaymentTargets = parseEventOrNull<PaymentTargetsEvent>(PrefKeys.LATEST_PAYMENT_TARGETS)
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
@@ -501,6 +507,7 @@ object LocalPreferences {
|
||||
externalSignerPackageName = externalSignerPackageName,
|
||||
localRelayServers = MutableStateFlow(localRelayServers),
|
||||
defaultFileServer = defaultFileServer,
|
||||
stripLocationOnUpload = stripLocationOnUpload,
|
||||
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
|
||||
@@ -515,6 +522,7 @@ object LocalPreferences {
|
||||
backupDMRelayList = latestDmRelayList,
|
||||
backupSearchRelayList = latestSearchRelayList,
|
||||
backupIndexRelayList = latestIndexRelayList,
|
||||
backupRelayFeedsList = latestRelayFeedsList,
|
||||
backupBlockedRelayList = latestBlockedRelayList,
|
||||
backupTrustedRelayList = latestTrustedRelayList,
|
||||
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
|
||||
@@ -525,9 +533,11 @@ object LocalPreferences {
|
||||
backupHashtagList = latestHashtagList,
|
||||
backupGeohashList = latestGeohashList,
|
||||
backupEphemeralChatList = latestEphemeralList,
|
||||
backupTrustProviderList = latestTrustProviderList,
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations),
|
||||
backupNipA3PaymentTargets = latestPaymentTargets,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -23,19 +23,29 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction
|
||||
import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
|
||||
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListState
|
||||
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountHomeRelayState
|
||||
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState
|
||||
import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState
|
||||
import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState
|
||||
import com.vitorpamplona.amethyst.model.nip02FollowLists.DeclaredFollowsPerOutboxRelay
|
||||
import com.vitorpamplona.amethyst.model.nip02FollowLists.DeclaredFollowsPerUsingRelay
|
||||
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays
|
||||
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListReusedOutboxOrProxyRelays
|
||||
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowsPerOutboxRelay
|
||||
@@ -43,13 +53,6 @@ import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
|
||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState
|
||||
import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState
|
||||
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip18Reposts.RepostAction
|
||||
import com.vitorpamplona.amethyst.model.nip25Reactions.ReactionAction
|
||||
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatListState
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.model.nip38UserStatuses.UserStatusAction
|
||||
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
|
||||
@@ -64,6 +67,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecry
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkListsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState
|
||||
@@ -71,27 +75,29 @@ import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListDecrypti
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds.RelayFeedListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip56Reports.ReportAction
|
||||
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
|
||||
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
|
||||
import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState
|
||||
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
|
||||
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithIndexRelayListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithSearchRelayListsState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedServerListState
|
||||
import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
|
||||
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
@@ -133,6 +139,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
@@ -145,6 +152,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
@@ -164,7 +172,8 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Response
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
@@ -186,6 +195,8 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import com.vitorpamplona.quartz.nip92IMeta.imetas
|
||||
@@ -199,8 +210,10 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -214,28 +227,34 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Stable
|
||||
class Account(
|
||||
val settings: AccountSettings = AccountSettings(KeyPair()),
|
||||
val signer: NostrSigner,
|
||||
override val signer: NostrSigner,
|
||||
val geolocationFlow: StateFlow<LocationState.LocationResult>,
|
||||
val nwcFilterAssembler: NWCPaymentFilterAssembler,
|
||||
val otsResolverBuilder: OtsResolverBuilder,
|
||||
val cache: LocalCache,
|
||||
val client: INostrClient,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
) : IAccount {
|
||||
private var userProfileCache: User? = null
|
||||
|
||||
fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it }
|
||||
override fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it }
|
||||
|
||||
// IAccount interface properties
|
||||
override val pubKey: String get() = signer.pubKey
|
||||
override val showSensitiveContent: Boolean? get() = hiddenUsers.flow.value.showSensitiveContent
|
||||
override val hiddenWordsCase: List<DualCase> get() = hiddenUsers.flow.value.hiddenWordsCase
|
||||
override val hiddenUsersHashCodes: Set<Int> get() = hiddenUsers.flow.value.hiddenUsersHashCodes
|
||||
override val spammersHashCodes: Set<Int> get() = hiddenUsers.flow.value.spammersHashCodes
|
||||
|
||||
val userMetadata = UserMetadataState(signer, cache, scope, settings)
|
||||
|
||||
val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings)
|
||||
override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings.zapPaymentRequest)
|
||||
|
||||
val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings)
|
||||
val localRelayList = LocalRelayListState(signer, cache, scope, settings)
|
||||
@@ -260,6 +279,9 @@ class Account(
|
||||
val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer)
|
||||
val indexerRelayList = IndexerRelayListState(signer, cache, indexerRelayListDecryptionCache, scope, settings)
|
||||
|
||||
val relayFeedsListDecryptionCache = RelayFeedsListDecryptionCache(signer)
|
||||
val relayFeedsList = RelayFeedListState(signer, cache, relayFeedsListDecryptionCache, scope, settings)
|
||||
|
||||
val blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer)
|
||||
val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings)
|
||||
|
||||
@@ -283,6 +305,9 @@ class Account(
|
||||
val muteListDecryptionCache = MuteListDecryptionCache(signer)
|
||||
val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings)
|
||||
|
||||
val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer)
|
||||
val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings)
|
||||
|
||||
val peopleListDecryptionCache = PeopleListDecryptionCache(signer)
|
||||
val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope)
|
||||
val peopleLists = PeopleListsState(signer, cache, peopleListDecryptionCache, scope)
|
||||
@@ -290,14 +315,13 @@ class Account(
|
||||
|
||||
val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings)
|
||||
|
||||
val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope)
|
||||
val bookmarkState = BookmarkListState(signer, cache, scope)
|
||||
val emoji = EmojiPackState(signer, cache, scope)
|
||||
|
||||
val appSpecific = AppSpecificState(signer, cache, scope, settings)
|
||||
|
||||
val blossomServers = BlossomServerListState(signer, cache, scope, settings)
|
||||
val fileStorageServers = FileStorageServerListState(signer, cache, scope, settings)
|
||||
val serverLists = MergedServerListState(fileStorageServers, blossomServers, scope)
|
||||
|
||||
// Relay settings
|
||||
val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope)
|
||||
@@ -318,7 +342,8 @@ class Account(
|
||||
val defaultGlobalRelays = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, scope)
|
||||
|
||||
// keeps a cache of the declared outbox relays for each author
|
||||
val declaredFollowsPerRelay = DeclaredFollowsPerOutboxRelay(kind3FollowList, cache, scope).flow
|
||||
val declaredFollowsPerOutboxRelay = DeclaredFollowsPerOutboxRelay(kind3FollowList, cache, scope).flow
|
||||
val declaredFollowsPerUsingRelay = DeclaredFollowsPerUsingRelay(kind3FollowList, cache, scope).flow
|
||||
|
||||
// keeps a cache of the outbox relays for each author
|
||||
val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow
|
||||
@@ -327,15 +352,17 @@ class Account(
|
||||
val allFollows = MergedFollowListsState(kind3FollowList, peopleLists, followLists, hashtagList, geohashList, communityList, scope)
|
||||
|
||||
val privateDMDecryptionCache = PrivateDMCache(signer)
|
||||
val privateZapsDecryptionCache = PrivateZapCache(signer)
|
||||
override val privateZapsDecryptionCache = PrivateZapCache(signer)
|
||||
val draftsDecryptionCache = DraftEventCache(signer)
|
||||
|
||||
val chatroomList = cache.getOrCreateChatroomList(signer.pubKey)
|
||||
override val chatroomList = cache.getOrCreateChatroomList(signer.pubKey)
|
||||
|
||||
val newNotesPreProcessor = EventProcessor(this, cache)
|
||||
|
||||
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
|
||||
|
||||
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
|
||||
|
||||
val feedDecryptionCaches =
|
||||
FeedDecryptionCaches(
|
||||
peopleListCache = peopleListDecryptionCache,
|
||||
@@ -410,7 +437,7 @@ class Account(
|
||||
|
||||
val liveNotificationFollowListsPerRelay = OutboxLoaderState(liveNotificationFollowLists, cache, scope).flow
|
||||
|
||||
fun isWriteable(): Boolean = settings.isWriteable()
|
||||
override fun isWriteable(): Boolean = settings.isWriteable()
|
||||
|
||||
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
|
||||
if (settings.updateWarnReports(warnReports)) {
|
||||
@@ -444,6 +471,12 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun changeReactionRowItems(items: List<ReactionRowItem>) {
|
||||
if (settings.changeReactionRowItems(items)) {
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateZapAmounts(
|
||||
amountSet: List<Long>,
|
||||
selectedZapType: LnZapEvent.ZapType,
|
||||
@@ -465,7 +498,17 @@ class Account(
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
|
||||
suspend fun updateTranslateTo(languageCode: Locale) {
|
||||
suspend fun addDontTranslateFrom(languageCode: String) {
|
||||
settings.addDontTranslateFrom(languageCode)
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
|
||||
suspend fun removeDontTranslateFrom(languageCode: String) {
|
||||
settings.removeDontTranslateFrom(languageCode)
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
|
||||
suspend fun updateTranslateTo(languageCode: String) {
|
||||
if (settings.updateTranslateTo(languageCode)) {
|
||||
sendNewAppSpecificData()
|
||||
}
|
||||
@@ -494,6 +537,37 @@ class Account(
|
||||
onPrivate = ::broadcastPrivately,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a reaction event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
* Returns null if note has already been reacted to or note has no event.
|
||||
*/
|
||||
suspend fun createReactionEvent(
|
||||
note: Note,
|
||||
reaction: String,
|
||||
): Pair<Event, Set<NormalizedRelayUrl>>? {
|
||||
if (!signer.isWriteable()) return null
|
||||
if (note.hasReacted(userProfile(), reaction)) return null
|
||||
|
||||
val eventHint = note.toEventHint<Event>() ?: return null
|
||||
|
||||
// For NIP-17 private groups, we don't support tracked mode (too complex)
|
||||
if (eventHint.event is NIP17Group) return null
|
||||
|
||||
val event = ReactionAction.reactTo(eventHint, reaction, signer)
|
||||
val relays = computeRelayListToBroadcast(event)
|
||||
|
||||
return event to relays
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a reaction event into local cache.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumeReactionEvent(event: Event) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
suspend fun createZapRequestFor(
|
||||
event: Event,
|
||||
pollOption: Int?,
|
||||
@@ -518,6 +592,14 @@ class Account(
|
||||
|
||||
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState)
|
||||
|
||||
suspend fun sendNwcRequest(
|
||||
request: Request,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse)
|
||||
client.send(event, setOf(relay))
|
||||
}
|
||||
|
||||
suspend fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
@@ -554,7 +636,8 @@ class Account(
|
||||
suspend fun report(
|
||||
user: User,
|
||||
type: ReportType,
|
||||
) = sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, userProfile(), signer))
|
||||
content: String = "",
|
||||
) = sendMyPublicAndPrivateOutbox(ReportAction.report(user, type, content, userProfile(), signer))
|
||||
|
||||
suspend fun delete(note: Note) = delete(listOf(note))
|
||||
|
||||
@@ -594,7 +677,7 @@ class Account(
|
||||
url: String,
|
||||
method: String,
|
||||
body: ByteArray? = null,
|
||||
): HTTPAuthorizationEvent? = signer.sign(HTTPAuthorizationEvent.build(url, method, body))
|
||||
): HTTPAuthorizationEvent = signer.sign(HTTPAuthorizationEvent.build(url, method, body))
|
||||
|
||||
suspend fun createBlossomUploadAuth(
|
||||
hash: HexKey,
|
||||
@@ -614,6 +697,35 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a boost event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
*/
|
||||
suspend fun createBoostEvent(note: Note): Pair<Event, Set<NormalizedRelayUrl>>? =
|
||||
RepostAction.repost(note, signer)?.let { event ->
|
||||
event to computeMyReactionToNote(note, event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a boost event and updates the local cache.
|
||||
* Used after tracked broadcasting completes.
|
||||
*/
|
||||
fun sendBoostEvent(
|
||||
event: Event,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
client.send(event, relays)
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local cache with a boost event.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumeBoostEvent(event: Event) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
fun computeMyReactionToNote(
|
||||
note: Note,
|
||||
reaction: Event,
|
||||
@@ -657,7 +769,7 @@ class Account(
|
||||
outboxRelays.flow.value
|
||||
} else {
|
||||
replyToAuthor.inboxRelays()?.ifEmpty { null }?.toSet()
|
||||
?: replyToAuthor.relaysBeingUsed.keys.ifEmpty { null }
|
||||
?: replyToAuthor.allUsedRelaysOrNull()
|
||||
?: cache.relayHints
|
||||
.hintsForKey(replyToAuthor.pubkeyHex)
|
||||
.ifEmpty { null }
|
||||
@@ -684,7 +796,7 @@ class Account(
|
||||
notificationRelays.flow.value
|
||||
} else {
|
||||
user.inboxRelays()?.ifEmpty { null }?.toSet()
|
||||
?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.relaysBeingUsed.keys)
|
||||
?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.allUsedRelays())
|
||||
}
|
||||
|
||||
private fun computeRelayListForLinkedUser(pubkey: HexKey): Set<NormalizedRelayUrl> =
|
||||
@@ -704,7 +816,7 @@ class Account(
|
||||
fun computeRelayListToBroadcast(event: Event): Set<NormalizedRelayUrl> {
|
||||
if (event is MetadataEvent || event is AdvertisedRelayListEvent) {
|
||||
// everywhere
|
||||
return followPlusAllMineWithIndex.flow.value + client.relayStatusFlow().value.available
|
||||
return followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value
|
||||
}
|
||||
if (event is GiftWrapEvent) {
|
||||
val receiver = event.recipientPubKey()
|
||||
@@ -715,12 +827,7 @@ class Account(
|
||||
.dmInboxRelayList()
|
||||
?.relays()
|
||||
?.ifEmpty { null }
|
||||
if (relayList != null) {
|
||||
client.send(event, relayList.toSet())
|
||||
} else {
|
||||
val publicRelayList = computeRelayListForLinkedUser(receiver)
|
||||
client.send(event, publicRelayList)
|
||||
}
|
||||
return relayList?.toSet() ?: computeRelayListForLinkedUser(receiver)
|
||||
} else {
|
||||
return emptySet()
|
||||
}
|
||||
@@ -739,7 +846,7 @@ class Account(
|
||||
} else {
|
||||
val relays =
|
||||
author.outboxRelays()?.ifEmpty { null }
|
||||
?: author.relaysBeingUsed.keys.ifEmpty { null }
|
||||
?: author.allUsedRelaysOrNull()
|
||||
?: cache.relayHints.hintsForKey(author.pubkeyHex)
|
||||
|
||||
relayList.addAll(relays)
|
||||
@@ -799,6 +906,10 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
if (event is PollEvent) {
|
||||
relayList.addAll(event.relays())
|
||||
}
|
||||
|
||||
relayList.addAll(computeRelaysForChannels(event))
|
||||
|
||||
return relayList
|
||||
@@ -824,12 +935,15 @@ class Account(
|
||||
note.relays.associateWith { relay ->
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(host.kind),
|
||||
tags = mapOf("p" to listOf(pubKey)),
|
||||
ids = listOf(host.id),
|
||||
),
|
||||
)
|
||||
},
|
||||
)?.let { downloadedEvent ->
|
||||
client.send(downloadedEvent, computeRelayListToBroadcast(downloadedEvent))
|
||||
val toRelays = computeRelayListToBroadcast(downloadedEvent)
|
||||
client.send(downloadedEvent, toRelays)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -840,6 +954,8 @@ class Account(
|
||||
|
||||
fun upgradeAttestations() = otsState.upgradeAttestationsIfNeeded(::sendAutomatic)
|
||||
|
||||
suspend fun follow(users: List<User>) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(users))
|
||||
|
||||
suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user))
|
||||
|
||||
suspend fun unfollow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.unfollow(user))
|
||||
@@ -904,10 +1020,27 @@ class Account(
|
||||
}
|
||||
|
||||
fun sendLiterallyEverywhere(event: Event) {
|
||||
client.send(event, followPlusAllMineWithIndex.flow.value + client.relayStatusFlow().value.available)
|
||||
client.send(event, followPlusAllMineWithIndex.flow.value + client.availableRelaysFlow().value)
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
suspend fun pollRespond(
|
||||
event: PollEvent,
|
||||
responses: Set<String>,
|
||||
) {
|
||||
val poll = cache.getOrCreateNote(event.id).toEventHint<PollEvent>()
|
||||
|
||||
if (poll != null) {
|
||||
val template = PollResponseEvent.build(poll, responses)
|
||||
|
||||
val signedEvent = signer.sign(template)
|
||||
|
||||
cache.justConsumeMyOwnEvent(signedEvent)
|
||||
|
||||
client.send(signedEvent, computeRelayListToBroadcast(signedEvent))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createNip95(
|
||||
byteArray: ByteArray,
|
||||
headerInfo: FileHeader,
|
||||
@@ -995,7 +1128,7 @@ class Account(
|
||||
hash: String,
|
||||
duration: Int,
|
||||
waveform: List<Float>,
|
||||
replyTo: EventHintBundle<VoiceEvent>,
|
||||
replyTo: EventHintBundle<BaseVoiceEvent>,
|
||||
) {
|
||||
signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo))
|
||||
}
|
||||
@@ -1130,7 +1263,7 @@ class Account(
|
||||
val event = signer.sign(template)
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
val relays = relayList(event)
|
||||
if (relays != null && relays.isNotEmpty()) {
|
||||
if (!relays.isNullOrEmpty()) {
|
||||
client.send(event, relays.toSet())
|
||||
} else {
|
||||
client.send(event, computeRelayListToBroadcast(event))
|
||||
@@ -1160,6 +1293,51 @@ class Account(
|
||||
return event
|
||||
}
|
||||
|
||||
suspend fun <T : Event> signAnonymouslyAndBroadcast(
|
||||
template: EventTemplate<T>,
|
||||
broadcast: List<Event> = emptyList(),
|
||||
): T {
|
||||
val anonymousSigner = NostrSignerInternal(KeyPair())
|
||||
val event = anonymousSigner.sign(template)
|
||||
|
||||
val relayList = nip65RelayList.outboxFlow.value.toSet()
|
||||
|
||||
client.send(event, relayList)
|
||||
broadcast.forEach { client.send(it, relayList) }
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a post event without sending it.
|
||||
* Returns the event, target relays, and extra events to broadcast.
|
||||
* For use with tracked broadcasting.
|
||||
*/
|
||||
suspend fun <T : Event> createPostEvent(
|
||||
template: EventTemplate<T>,
|
||||
extraNotesToBroadcast: List<Event> = emptyList(),
|
||||
): Triple<T, Set<NormalizedRelayUrl>, List<Event>> {
|
||||
val event = signer.sign(template)
|
||||
|
||||
// Use event-based relay computation (not note-based, since note is empty)
|
||||
val relayList = computeRelayListToBroadcast(event)
|
||||
|
||||
return Triple(event, relayList, extraNotesToBroadcast)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a post event into local cache and sends extra events.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumePostEvent(
|
||||
event: Event,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
extraNotesToBroadcast: List<Event>,
|
||||
) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
extraNotesToBroadcast.forEach { client.send(it, relays) }
|
||||
}
|
||||
|
||||
suspend fun createAndSendDraftIgnoreErrors(
|
||||
draftTag: String,
|
||||
template: EventTemplate<out Event>,
|
||||
@@ -1224,19 +1402,15 @@ class Account(
|
||||
}
|
||||
|
||||
suspend fun createInteractiveStoryReadingState(
|
||||
root: InteractiveStoryBaseEvent,
|
||||
rootRelay: NormalizedRelayUrl?,
|
||||
readingScene: InteractiveStoryBaseEvent,
|
||||
readingSceneRelay: NormalizedRelayUrl?,
|
||||
root: EventHintBundle<InteractiveStoryBaseEvent>,
|
||||
readingScene: EventHintBundle<InteractiveStoryBaseEvent>,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val template =
|
||||
InteractiveStoryReadingStateEvent.build(
|
||||
root = root,
|
||||
rootRelay = rootRelay,
|
||||
currentScene = readingScene,
|
||||
currentSceneRelay = readingSceneRelay,
|
||||
)
|
||||
|
||||
val event = signer.sign(template)
|
||||
@@ -1255,8 +1429,7 @@ class Account(
|
||||
|
||||
suspend fun updateInteractiveStoryReadingState(
|
||||
readingState: InteractiveStoryReadingStateEvent,
|
||||
readingScene: InteractiveStoryBaseEvent,
|
||||
readingSceneRelay: NormalizedRelayUrl?,
|
||||
readingScene: EventHintBundle<InteractiveStoryBaseEvent>,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1264,7 +1437,6 @@ class Account(
|
||||
InteractiveStoryReadingStateEvent.update(
|
||||
base = readingState,
|
||||
currentScene = readingScene,
|
||||
currentSceneRelay = readingSceneRelay,
|
||||
)
|
||||
|
||||
val event = signer.sign(template)
|
||||
@@ -1442,7 +1614,7 @@ class Account(
|
||||
broadcast.forEach { client.send(it, relayList) }
|
||||
}
|
||||
|
||||
suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
|
||||
override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val newEvent = signer.sign(eventTemplate)
|
||||
@@ -1453,18 +1625,25 @@ class Account(
|
||||
client.send(newEvent, outboxRelays.flow.value + destinationRelays)
|
||||
}
|
||||
|
||||
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer)
|
||||
broadcastPrivately(wraps)
|
||||
}
|
||||
|
||||
suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
|
||||
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
|
||||
val events = NIP17Factory().createMessageNIP17(template, signer)
|
||||
broadcastPrivately(events)
|
||||
}
|
||||
|
||||
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
|
||||
wraps.forEach { wrap ->
|
||||
val relayList = computeRelayListToBroadcast(wrap)
|
||||
client.send(wrap, relayList)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) {
|
||||
val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) }
|
||||
|
||||
@@ -1546,6 +1725,55 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(note: Note) {
|
||||
if (!isWriteable() || note.isDraft()) return
|
||||
|
||||
val event = bookmarkState.removeBookmark(note)
|
||||
if (event != null) {
|
||||
sendMyPublicAndPrivateOutbox(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bookmark event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
*/
|
||||
suspend fun createAddBookmarkEvent(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): Pair<Event, Set<NormalizedRelayUrl>>? {
|
||||
if (!isWriteable() || note.isDraft()) return null
|
||||
|
||||
val event = bookmarkState.addBookmark(note, isPrivate)
|
||||
val relays = outboxRelays.flow.value
|
||||
|
||||
return event to relays
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a remove bookmark event without sending it.
|
||||
* Returns the event and target relays for tracked broadcasting.
|
||||
*/
|
||||
suspend fun createRemoveBookmarkEvent(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
): Pair<Event, Set<NormalizedRelayUrl>>? {
|
||||
if (!isWriteable() || note.isDraft()) return null
|
||||
|
||||
val event = bookmarkState.removeBookmark(note, isPrivate) ?: return null
|
||||
val relays = outboxRelays.flow.value
|
||||
|
||||
return event to relays
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a bookmark event into local cache.
|
||||
* Called when tracked broadcasting succeeds.
|
||||
*/
|
||||
fun consumeBookmarkEvent(event: Event) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
suspend fun createAuthEvent(
|
||||
relay: NormalizedRelayUrl,
|
||||
challenge: String,
|
||||
@@ -1579,7 +1807,7 @@ class Account(
|
||||
|
||||
val relayList =
|
||||
dvmPublicKey.inboxRelays()?.toSet()?.ifEmpty { null }
|
||||
?: (dvmPublicKey.relaysBeingUsed.keys + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
|
||||
?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
|
||||
|
||||
cache.justConsumeMyOwnEvent(request)
|
||||
onReady(request)
|
||||
@@ -1632,11 +1860,11 @@ class Account(
|
||||
|
||||
fun isAllHidden(users: Set<HexKey>): Boolean = users.all { isHidden(it) }
|
||||
|
||||
fun isHidden(user: User) = isHidden(user.pubkeyHex)
|
||||
override fun isHidden(user: User) = isHidden(user.pubkeyHex)
|
||||
|
||||
fun isHidden(userHex: String): Boolean = hiddenUsers.flow.value.isUserHidden(userHex)
|
||||
|
||||
fun followingKeySet(): Set<HexKey> = kind3FollowList.flow.value.authors
|
||||
override fun followingKeySet(): Set<HexKey> = kind3FollowList.flow.value.authors
|
||||
|
||||
fun isAcceptable(user: User): Boolean {
|
||||
if (userProfile().pubkeyHex == user.pubkeyHex) {
|
||||
@@ -1648,15 +1876,21 @@ class Account(
|
||||
}
|
||||
|
||||
if (!settings.syncedSettings.security.warnAboutPostsWithReports) {
|
||||
return !isHidden(user) &&
|
||||
// if user hasn't hided this author
|
||||
user.reportsBy(userProfile()).isEmpty() // if user has not reported this post
|
||||
if (isHidden(user)) return false
|
||||
|
||||
val reports = user.reportsOrNull() ?: return true
|
||||
|
||||
return reports.reportsBy(userProfile()).isEmpty() // if user has not reported this post
|
||||
}
|
||||
return !isHidden(user) &&
|
||||
// if user hasn't hided this author
|
||||
user.reportsBy(userProfile()).isEmpty() &&
|
||||
|
||||
if (isHidden(user)) return false
|
||||
|
||||
val reports = user.reportsOrNull() ?: return true
|
||||
|
||||
// if user hasn't hided this author
|
||||
return reports.reportsBy(userProfile()).isEmpty() &&
|
||||
// if user has not reported this post
|
||||
user.countReportAuthorsBy(followingKeySet()) < 5
|
||||
reports.countReportAuthorsBy(followingKeySet()) < 5
|
||||
}
|
||||
|
||||
private fun isAcceptableDirect(note: Note): Boolean {
|
||||
@@ -1686,7 +1920,7 @@ class Account(
|
||||
|
||||
fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors
|
||||
|
||||
fun isAcceptable(note: Note): Boolean {
|
||||
override fun isAcceptable(note: Note): Boolean {
|
||||
return note.author?.let { isAcceptable(it) } ?: true &&
|
||||
// if user hasn't hided this author
|
||||
isAcceptableDirect(note) &&
|
||||
@@ -1702,14 +1936,14 @@ class Account(
|
||||
fun getRelevantReports(note: Note): Set<Note> {
|
||||
val innerReports =
|
||||
if (note.event is RepostEvent || note.event is GenericRepostEvent) {
|
||||
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
|
||||
note.replyTo?.flatMap { getRelevantReports(it) } ?: emptyList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return (
|
||||
note.reportsBy(kind3FollowList.flow.value.authorsPlusMe) +
|
||||
(note.author?.reportsBy(kind3FollowList.flow.value.authorsPlusMe) ?: emptyList()) +
|
||||
(note.author?.reportsOrNull()?.reportsBy(kind3FollowList.flow.value.authorsPlusMe) ?: emptyList()) +
|
||||
innerReports
|
||||
).toSet()
|
||||
}
|
||||
@@ -1728,12 +1962,16 @@ class Account(
|
||||
|
||||
suspend fun saveTrustedRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays))
|
||||
|
||||
suspend fun saveRelayFeedsList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(relayFeedsList.saveRelayList(trustedRelays))
|
||||
|
||||
suspend fun followRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(relayFeedsList.addRelay(url))
|
||||
|
||||
suspend fun unfollowRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(relayFeedsList.removeRelay(url))
|
||||
|
||||
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
|
||||
|
||||
suspend fun sendNip65RelayList(relays: List<AdvertisedRelayInfo>) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays))
|
||||
|
||||
suspend fun sendFileServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(fileStorageServers.saveFileServersList(servers))
|
||||
|
||||
suspend fun sendBlossomServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers))
|
||||
|
||||
fun markAsRead(
|
||||
@@ -1777,6 +2015,7 @@ class Account(
|
||||
newNotesPreProcessor.runNew(newNotes)
|
||||
peopleLists.newNotes(newNotes)
|
||||
followLists.newNotes(newNotes)
|
||||
labeledBookmarkLists.newNotes(newNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1787,11 +2026,13 @@ class Account(
|
||||
newNotesPreProcessor.runDeleted(deletedNotes)
|
||||
peopleLists.deletedNotes(deletedNotes)
|
||||
followLists.deletedNotes(deletedNotes)
|
||||
labeledBookmarkLists.deletedNotes(deletedNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
settings.saveable.debounce(1000).collect {
|
||||
if (it.accountSettings != null) {
|
||||
LocalPreferences.saveToEncryptedStorage(it.accountSettings)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -21,10 +21,15 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -42,6 +47,7 @@ import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
|
||||
@@ -56,7 +62,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Locale
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
val DefaultChannels =
|
||||
listOf(
|
||||
@@ -75,11 +81,13 @@ val DefaultNIP65List =
|
||||
AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH),
|
||||
)
|
||||
|
||||
val DefaultGlobalRelays = listOf(Constants.wine, Constants.news)
|
||||
|
||||
val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos)
|
||||
|
||||
val DefaultSearchRelayList = setOf(Constants.band, Constants.wine, Constants.where, Constants.nostoday)
|
||||
val DefaultSearchRelayList = setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal, Constants.ditto)
|
||||
|
||||
val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds)
|
||||
val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds, Constants.yabu, Constants.nostr1)
|
||||
|
||||
val DefaultSignerPermissions =
|
||||
listOf(
|
||||
@@ -92,20 +100,58 @@ val DefaultSignerPermissions =
|
||||
Permission(CommandType.DECRYPT_ZAP_EVENT),
|
||||
)
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val GLOBAL_FOLLOWS = " Global "
|
||||
@Serializable
|
||||
sealed class TopFilter(
|
||||
val code: String,
|
||||
) {
|
||||
@Serializable
|
||||
object Global : TopFilter(" Global ")
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val ALL_FOLLOWS = " All Follows "
|
||||
@Serializable
|
||||
object AllFollows : TopFilter(" All Follows ")
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val ALL_USER_FOLLOWS = " All User Follows "
|
||||
@Serializable
|
||||
object AllUserFollows : TopFilter(" All User Follows ")
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val KIND3_FOLLOWS = " Main User Follows "
|
||||
@Serializable
|
||||
object DefaultFollows : TopFilter(" Main User Follows ")
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val AROUND_ME = " Around Me "
|
||||
@Serializable
|
||||
object AroundMe : TopFilter(" Around Me ")
|
||||
|
||||
@Serializable
|
||||
object Chess : TopFilter(" Chess ")
|
||||
|
||||
@Serializable
|
||||
class PeopleList(
|
||||
val address: Address,
|
||||
) : TopFilter(address.toValue())
|
||||
|
||||
@Serializable
|
||||
class MuteList(
|
||||
val address: Address,
|
||||
) : TopFilter(address.toValue())
|
||||
|
||||
@Serializable
|
||||
class Community(
|
||||
val address: Address,
|
||||
) : TopFilter("Community/${address.toValue()}")
|
||||
|
||||
@Serializable
|
||||
class Hashtag(
|
||||
val tag: String,
|
||||
) : TopFilter("Hashtag/$tag")
|
||||
|
||||
@Serializable
|
||||
class Geohash(
|
||||
val tag: String,
|
||||
) : TopFilter("Geohash/$tag")
|
||||
|
||||
@Serializable
|
||||
class Relay(
|
||||
val url: String,
|
||||
) : TopFilter("Relay/$url")
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AccountSettings(
|
||||
@@ -114,11 +160,12 @@ class AccountSettings(
|
||||
var externalSignerPackageName: String? = null,
|
||||
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
|
||||
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(ALL_FOLLOWS),
|
||||
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
var zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
|
||||
var stripLocationOnUpload: Boolean = true,
|
||||
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP17WarningDialog: Boolean = false,
|
||||
@@ -128,6 +175,7 @@ class AccountSettings(
|
||||
var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
|
||||
var backupSearchRelayList: SearchRelayListEvent? = null,
|
||||
var backupIndexRelayList: IndexerRelayListEvent? = null,
|
||||
var backupRelayFeedsList: RelayFeedsListEvent? = null,
|
||||
var backupBlockedRelayList: BlockedRelayListEvent? = null,
|
||||
var backupTrustedRelayList: TrustedRelayListEvent? = null,
|
||||
var backupMuteList: MuteListEvent? = null,
|
||||
@@ -138,10 +186,13 @@ class AccountSettings(
|
||||
var backupHashtagList: HashtagListEvent? = null,
|
||||
var backupGeohashList: GeohashListEvent? = null,
|
||||
var backupEphemeralChatList: EphemeralChatListEvent? = null,
|
||||
var backupTrustProviderList: TrustProviderListEvent? = null,
|
||||
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 hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
|
||||
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
|
||||
) : EphemeralChatRepository,
|
||||
PublicChatListRepository {
|
||||
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
|
||||
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
|
||||
|
||||
@@ -186,6 +237,15 @@ class AccountSettings(
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeReactionRowItems(newItems: List<ReactionRowItem>): Boolean {
|
||||
if (syncedSettings.reactions.reactionRowItems.value != newItems) {
|
||||
syncedSettings.reactions.reactionRowItems.tryEmit(newItems.toImmutableList())
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean {
|
||||
if (zapPaymentRequest.value != newServer) {
|
||||
zapPaymentRequest.tryEmit(newServer)
|
||||
@@ -206,6 +266,13 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeStripLocationOnUpload(strip: Boolean) {
|
||||
if (stripLocationOnUpload != strip) {
|
||||
stripLocationOnUpload = strip
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// list names
|
||||
// ---
|
||||
@@ -214,7 +281,7 @@ class AccountSettings(
|
||||
changeDefaultHomeFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultHomeFollowList(name: String) {
|
||||
fun changeDefaultHomeFollowList(name: TopFilter) {
|
||||
if (defaultHomeFollowList.value != name) {
|
||||
defaultHomeFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
@@ -225,7 +292,7 @@ class AccountSettings(
|
||||
changeDefaultStoriesFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultStoriesFollowList(name: String) {
|
||||
fun changeDefaultStoriesFollowList(name: TopFilter) {
|
||||
if (defaultStoriesFollowList.value != name) {
|
||||
defaultStoriesFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
@@ -236,7 +303,7 @@ class AccountSettings(
|
||||
changeDefaultNotificationFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultNotificationFollowList(name: String) {
|
||||
fun changeDefaultNotificationFollowList(name: TopFilter) {
|
||||
if (defaultNotificationFollowList.value != name) {
|
||||
defaultNotificationFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
@@ -247,7 +314,7 @@ class AccountSettings(
|
||||
changeDefaultDiscoveryFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultDiscoveryFollowList(name: String) {
|
||||
fun changeDefaultDiscoveryFollowList(name: TopFilter) {
|
||||
if (defaultDiscoveryFollowList.value != name) {
|
||||
defaultDiscoveryFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
@@ -262,9 +329,21 @@ class AccountSettings(
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) = syncedSettings.languages.translateTo.contains(languageCode.language)
|
||||
fun addDontTranslateFrom(languageCode: String) {
|
||||
syncedSettings.languages.addDontTranslateFrom(languageCode)
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale): Boolean {
|
||||
fun removeDontTranslateFrom(languageCode: String) {
|
||||
syncedSettings.languages.removeDontTranslateFrom(languageCode)
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: String) =
|
||||
syncedSettings.languages.translateTo.value
|
||||
.contains(languageCode)
|
||||
|
||||
fun updateTranslateTo(languageCode: String): Boolean {
|
||||
if (syncedSettings.languages.updateTranslateTo(languageCode)) {
|
||||
saveAccountSettings()
|
||||
return true
|
||||
@@ -337,6 +416,16 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
|
||||
if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupNipA3PaymentTargets?.id != newNIPA3PaymentTargets.id) {
|
||||
backupNipA3PaymentTargets = newNIPA3PaymentTargets
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
|
||||
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
|
||||
|
||||
@@ -357,6 +446,16 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) {
|
||||
if (newRelayFeedList == null || newRelayFeedList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupRelayFeedsList?.id != newRelayFeedList.id) {
|
||||
backupRelayFeedsList = newRelayFeedList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) {
|
||||
if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return
|
||||
|
||||
@@ -387,7 +486,9 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateChannelListTo(newChannelList: ChannelListEvent?) {
|
||||
override fun channelList() = backupChannelList
|
||||
|
||||
override fun updateChannelListTo(newChannelList: ChannelListEvent?) {
|
||||
if (newChannelList == null || newChannelList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
@@ -427,7 +528,9 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
|
||||
override fun ephemeralChatList() = backupEphemeralChatList
|
||||
|
||||
override fun updateEphemeralChatListTo(newEphemeralChatList: EphemeralChatListEvent?) {
|
||||
if (newEphemeralChatList == null || newEphemeralChatList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
@@ -437,6 +540,16 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
|
||||
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupTrustProviderList?.id != trustProviderList.id) {
|
||||
backupTrustProviderList = trustProviderList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMuteList(newMuteList: MuteListEvent?) {
|
||||
if (newMuteList == null || newMuteList.tags.isEmpty()) return
|
||||
|
||||
@@ -520,7 +633,7 @@ class AccountSettings(
|
||||
route: String,
|
||||
timestampInSecs: Long,
|
||||
): MutableStateFlow<Long> =
|
||||
MutableStateFlow<Long>(timestampInSecs).also { newFlow ->
|
||||
MutableStateFlow(timestampInSecs).also { newFlow ->
|
||||
lastReadPerRoute.update { it + Pair(route, newFlow) }
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -27,13 +27,16 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.Locale
|
||||
|
||||
@Stable
|
||||
class AccountSyncedSettings(
|
||||
internalSettings: AccountSyncedSettingsInternal,
|
||||
) {
|
||||
val reactions = AccountReactionPreferences(MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()))
|
||||
val reactions =
|
||||
AccountReactionPreferences(
|
||||
MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()),
|
||||
MutableStateFlow(internalSettings.reactions.reactionRowItems.toImmutableList()),
|
||||
)
|
||||
val zaps =
|
||||
AccountZapPreferences(
|
||||
MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()),
|
||||
@@ -41,9 +44,9 @@ class AccountSyncedSettings(
|
||||
)
|
||||
val languages =
|
||||
AccountLanguagePreferences(
|
||||
internalSettings.languages.dontTranslateFrom,
|
||||
internalSettings.languages.languagePreferences,
|
||||
internalSettings.languages.translateTo,
|
||||
MutableStateFlow(internalSettings.languages.dontTranslateFrom),
|
||||
MutableStateFlow(internalSettings.languages.languagePreferences),
|
||||
MutableStateFlow(internalSettings.languages.translateTo),
|
||||
)
|
||||
val security =
|
||||
AccountSecurityPreferences(
|
||||
@@ -54,7 +57,7 @@ class AccountSyncedSettings(
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
AccountSyncedSettingsInternal(
|
||||
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value),
|
||||
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value, reactions.reactionRowItems.value),
|
||||
zaps =
|
||||
AccountZapPreferencesInternal(
|
||||
zaps.zapAmountChoices.value,
|
||||
@@ -62,9 +65,9 @@ class AccountSyncedSettings(
|
||||
),
|
||||
languages =
|
||||
AccountLanguagePreferencesInternal(
|
||||
languages.dontTranslateFrom,
|
||||
languages.languagePreferences,
|
||||
languages.translateTo,
|
||||
languages.dontTranslateFrom.value,
|
||||
languages.languagePreferences.value,
|
||||
languages.translateTo.value,
|
||||
),
|
||||
security =
|
||||
AccountSecurityPreferencesInternal(
|
||||
@@ -80,6 +83,11 @@ class AccountSyncedSettings(
|
||||
reactions.reactionChoices.tryEmit(newReactionChoices)
|
||||
}
|
||||
|
||||
val newReactionRowItems = syncedSettingsInternal.reactions.reactionRowItems.toImmutableList()
|
||||
if (!equalImmutableLists(reactions.reactionRowItems.value, newReactionRowItems)) {
|
||||
reactions.reactionRowItems.tryEmit(newReactionRowItems)
|
||||
}
|
||||
|
||||
val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList()
|
||||
if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) {
|
||||
zaps.zapAmountChoices.tryEmit(newZapChoices)
|
||||
@@ -89,16 +97,16 @@ class AccountSyncedSettings(
|
||||
zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType)
|
||||
}
|
||||
|
||||
if (languages.dontTranslateFrom != syncedSettingsInternal.languages.dontTranslateFrom) {
|
||||
languages.dontTranslateFrom = syncedSettingsInternal.languages.dontTranslateFrom
|
||||
if (languages.dontTranslateFrom.value != syncedSettingsInternal.languages.dontTranslateFrom) {
|
||||
languages.dontTranslateFrom.value = syncedSettingsInternal.languages.dontTranslateFrom
|
||||
}
|
||||
|
||||
if (languages.languagePreferences != syncedSettingsInternal.languages.languagePreferences) {
|
||||
languages.languagePreferences = syncedSettingsInternal.languages.languagePreferences
|
||||
if (languages.languagePreferences.value != syncedSettingsInternal.languages.languagePreferences) {
|
||||
languages.languagePreferences.value = syncedSettingsInternal.languages.languagePreferences
|
||||
}
|
||||
|
||||
if (languages.translateTo != syncedSettingsInternal.languages.translateTo) {
|
||||
languages.translateTo = syncedSettingsInternal.languages.translateTo
|
||||
if (languages.translateTo.value != syncedSettingsInternal.languages.translateTo) {
|
||||
languages.translateTo.value = syncedSettingsInternal.languages.translateTo
|
||||
}
|
||||
|
||||
if (security.showSensitiveContent.value != syncedSettingsInternal.security.showSensitiveContent) {
|
||||
@@ -114,12 +122,13 @@ class AccountSyncedSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom - getLanguagesSpokenByUser()
|
||||
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AccountReactionPreferences(
|
||||
var reactionChoices: MutableStateFlow<ImmutableList<String>>,
|
||||
var reactionRowItems: MutableStateFlow<ImmutableList<ReactionRowItem>>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
@@ -130,27 +139,36 @@ class AccountZapPreferences(
|
||||
|
||||
@Stable
|
||||
class AccountLanguagePreferences(
|
||||
var dontTranslateFrom: Set<String>,
|
||||
var languagePreferences: Map<String, String>,
|
||||
var translateTo: String,
|
||||
var dontTranslateFrom: MutableStateFlow<Set<String>>,
|
||||
var languagePreferences: MutableStateFlow<Map<String, String>>,
|
||||
var translateTo: MutableStateFlow<String>,
|
||||
) {
|
||||
// ---
|
||||
// language services
|
||||
// ---
|
||||
fun toggleDontTranslateFrom(languageCode: String) {
|
||||
dontTranslateFrom =
|
||||
if (!dontTranslateFrom.contains(languageCode)) {
|
||||
dontTranslateFrom.plus(languageCode)
|
||||
dontTranslateFrom.update {
|
||||
if (it.contains(languageCode)) {
|
||||
it - languageCode
|
||||
} else {
|
||||
dontTranslateFrom.minus(languageCode)
|
||||
it + languageCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
|
||||
fun addDontTranslateFrom(languageCode: String) {
|
||||
dontTranslateFrom.update { it + languageCode }
|
||||
}
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale): Boolean {
|
||||
if (translateTo != languageCode.language) {
|
||||
translateTo = languageCode.language
|
||||
fun removeDontTranslateFrom(languageCode: String) {
|
||||
dontTranslateFrom.update { it - languageCode }
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: String) = translateTo.value.contains(languageCode)
|
||||
|
||||
fun updateTranslateTo(languageCode: String): Boolean {
|
||||
if (translateTo.value != languageCode) {
|
||||
translateTo.tryEmit(languageCode)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -162,13 +180,15 @@ class AccountLanguagePreferences(
|
||||
preference: String,
|
||||
) {
|
||||
val key = "$source,$target"
|
||||
if (key !in languagePreferences) {
|
||||
languagePreferences = languagePreferences + Pair(key, preference)
|
||||
} else {
|
||||
if (languagePreferences.get(key) == preference) {
|
||||
languagePreferences = languagePreferences.minus(key)
|
||||
languagePreferences.update {
|
||||
if (key !in it) {
|
||||
it + Pair(key, preference)
|
||||
} else {
|
||||
languagePreferences = languagePreferences + Pair(key, preference)
|
||||
if (it.get(key) == preference) {
|
||||
it.minus(key)
|
||||
} else {
|
||||
it + Pair(key, preference)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +196,7 @@ class AccountLanguagePreferences(
|
||||
fun preferenceBetween(
|
||||
source: String,
|
||||
target: String,
|
||||
): String? = languagePreferences["$source,$target"]
|
||||
): String? = languagePreferences.value["$source,$target"]
|
||||
}
|
||||
|
||||
@Stable
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -39,6 +39,31 @@ val DefaultReactions =
|
||||
|
||||
val DefaultZapAmounts = listOf(100L, 500L, 1000L)
|
||||
|
||||
@Serializable
|
||||
enum class ReactionRowAction {
|
||||
Reply,
|
||||
Boost,
|
||||
Like,
|
||||
Zap,
|
||||
Share,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ReactionRowItem(
|
||||
val action: ReactionRowAction,
|
||||
val enabled: Boolean = true,
|
||||
val showCounter: Boolean = true,
|
||||
)
|
||||
|
||||
val DefaultReactionRowItems =
|
||||
listOf(
|
||||
ReactionRowItem(ReactionRowAction.Reply),
|
||||
ReactionRowItem(ReactionRowAction.Boost),
|
||||
ReactionRowItem(ReactionRowAction.Like),
|
||||
ReactionRowItem(ReactionRowAction.Zap),
|
||||
ReactionRowItem(ReactionRowAction.Share, showCounter = false),
|
||||
)
|
||||
|
||||
fun getLanguagesSpokenByUser(): Set<String> {
|
||||
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||
val codedList = mutableSetOf<String>()
|
||||
@@ -59,6 +84,7 @@ class AccountSyncedSettingsInternal(
|
||||
@Serializable
|
||||
class AccountReactionPreferencesInternal(
|
||||
var reactionChoices: List<String> = DefaultReactions,
|
||||
var reactionRowItems: List<ReactionRowItem> = DefaultReactionRowItems,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -95,7 +95,7 @@ class AntiSpamFilter {
|
||||
if (spammer.shouldHide() && relay != null) {
|
||||
Amethyst.instance.relayStats
|
||||
.get(relay)
|
||||
.newSpam("$link1 $link2")
|
||||
.newSpam(link1, link2)
|
||||
}
|
||||
|
||||
flowSpam.tryEmit(AntiSpamState(this))
|
||||
@@ -106,12 +106,11 @@ class AntiSpamFilter {
|
||||
recentAddressables.put(hash, address)
|
||||
} else {
|
||||
// normal event
|
||||
val existingEvent = recentEventIds[hash]
|
||||
if (
|
||||
(recentEventIds[hash] != null && recentEventIds[hash] != event.id) ||
|
||||
(existingEvent != null && existingEvent != event.id) ||
|
||||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id))
|
||||
) {
|
||||
val existingEvent = recentEventIds[hash]
|
||||
|
||||
val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay))
|
||||
val link2 = njumpLink(NEvent.create(event.id, null, null, relay))
|
||||
|
||||
@@ -123,7 +122,7 @@ class AntiSpamFilter {
|
||||
if (spammer.shouldHide() && relay != null) {
|
||||
Amethyst.instance.relayStats
|
||||
.get(relay)
|
||||
.newSpam("$link1 $link2")
|
||||
.newSpam(link1, link2)
|
||||
}
|
||||
|
||||
flowSpam.tryEmit(AntiSpamState(this))
|
||||
@@ -171,7 +170,7 @@ class AntiSpamFilter {
|
||||
}
|
||||
}
|
||||
|
||||
val flowSpam = MutableStateFlow<AntiSpamState>(AntiSpamState(this))
|
||||
val flowSpam = MutableStateFlow(AntiSpamState(this))
|
||||
}
|
||||
|
||||
class AntiSpamState(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -28,7 +28,6 @@ object Constants {
|
||||
val primal = RelayUrlNormalizer.normalize("wss://relay.primal.net")
|
||||
val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io")
|
||||
val wine = RelayUrlNormalizer.normalize("wss://nostr.wine")
|
||||
val band = RelayUrlNormalizer.normalize("wss://relay.nostr.band")
|
||||
|
||||
val where = RelayUrlNormalizer.normalize("wss://relay.noswhere.com")
|
||||
val elites = RelayUrlNormalizer.normalize("wss://nostrelites.org")
|
||||
@@ -37,16 +36,20 @@ object Constants {
|
||||
val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev")
|
||||
|
||||
val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today")
|
||||
val antiprimal = RelayUrlNormalizer.normalize("wss://antiprimal.net")
|
||||
val ditto = RelayUrlNormalizer.normalize("wss://relay.ditto.pub")
|
||||
|
||||
val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com")
|
||||
val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com")
|
||||
|
||||
val news = RelayUrlNormalizer.normalize("wss://news.utxo.one")
|
||||
|
||||
val purplepages = RelayUrlNormalizer.normalize("wss://purplepag.es")
|
||||
val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social")
|
||||
val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es")
|
||||
val yabu = RelayUrlNormalizer.normalize("wss://directory.yabu.me")
|
||||
val nostr1 = RelayUrlNormalizer.normalize("wss://profiles.nostr1.com")
|
||||
|
||||
val bootstrapInbox = setOf(band, damus, primal, mom, nos, bitcoiner, oxtr)
|
||||
val eventFinderRelays = setOf(band, wine, damus, primal, mom, nos, bitcoiner, oxtr)
|
||||
|
||||
val defaultSearchRelaySet = setOf(band, wine, where)
|
||||
val bootstrapInbox = setOf(damus, primal, mom, nos, bitcoiner, oxtr, yabu)
|
||||
val eventFinderRelays = setOf(wine, damus, primal, mom, nos, bitcoiner, oxtr)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -45,6 +45,7 @@ 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.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.ui.components.HashTag
|
||||
@@ -52,7 +53,6 @@ import com.vitorpamplona.amethyst.ui.components.RenderRegular
|
||||
import com.vitorpamplona.amethyst.ui.components.RenderTextParagraph
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
@@ -25,14 +25,13 @@ import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.ConcurrentSkipListMap
|
||||
import java.util.function.BiConsumer
|
||||
|
||||
class LargeSoftCache<K, V> : CacheOperations<K, V> {
|
||||
protected val cache = ConcurrentSkipListMap<K, WeakReference<V>>()
|
||||
class LargeSoftCache<K : Any, V : Any> : CacheOperations<K, V> {
|
||||
private val cache = ConcurrentSkipListMap<K, WeakReference<V>>()
|
||||
|
||||
fun keys() = cache.keys
|
||||
|
||||
fun get(key: K): V? {
|
||||
val softRef = cache.get(key)
|
||||
if (softRef == null) return null
|
||||
val softRef = cache.get(key) ?: return null
|
||||
val value = softRef.get()
|
||||
|
||||
return if (value != null) {
|
||||
@@ -99,7 +98,7 @@ class LargeSoftCache<K, V> : CacheOperations<K, V> {
|
||||
// another thread may put in between
|
||||
cache.remove(key, softRef)
|
||||
val newObject = builder(key)
|
||||
return cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject
|
||||
cache.putIfAbsent(key, WeakReference(newObject))?.get() ?: newObject
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,7 +134,7 @@ class LargeSoftCache<K, V> : CacheOperations<K, V> {
|
||||
.forEach(BiConsumerWrapper(this, consumer))
|
||||
}
|
||||
|
||||
class BiConsumerWrapper<K, V>(
|
||||
class BiConsumerWrapper<K : Any, V : Any>(
|
||||
val cache: LargeSoftCache<K, V>,
|
||||
val inner: BiConsumer<K, V>,
|
||||
) : BiConsumer<K, WeakReference<V>> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user