Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1bd679046 | ||
|
|
34cfeb3250 | ||
|
|
6bbcb979c3 | ||
|
|
5ef621d26a | ||
|
|
d5bc33a280 | ||
|
|
d7c1c6bca6 | ||
|
|
4b968f3bed | ||
|
|
ffd46f1d29 | ||
|
|
70b4edcbb7 | ||
|
|
96d8bfdb73 | ||
|
|
bdb1c8ef1b | ||
|
|
ce66536dde | ||
|
|
829e1906d6 | ||
|
|
8f1153b05b | ||
|
|
92db1a4ce5 | ||
|
|
4ca4fe81f0 | ||
|
|
d00cf77d1d | ||
|
|
4d6eb9ff1d | ||
|
|
022de71c62 | ||
|
|
0fde488dde | ||
|
|
76c758d027 | ||
|
|
b847feae22 | ||
|
|
5a4efa33cf | ||
|
|
fb45ed99c2 | ||
|
|
9967a534bd | ||
|
|
9745243cda | ||
|
|
ab10e04c72 | ||
|
|
d9ebe79ec7 | ||
|
|
e2774e48cd | ||
|
|
a6c476a97a | ||
|
|
a4313bc401 | ||
|
|
e8b313605f | ||
|
|
c1a56e59dd | ||
|
|
e909f6e2bd | ||
|
|
8becbb7f53 | ||
|
|
81b4da3584 | ||
|
|
ea6f633d9b | ||
|
|
09dc4fc87b | ||
|
|
85a7d0df84 | ||
|
|
a602a22eb0 | ||
|
|
7a9cc78264 | ||
|
|
8152f4db96 | ||
|
|
f316a6ebaa | ||
|
|
ab1a9daefb | ||
|
|
bbf3261815 | ||
|
|
45715a6c45 | ||
|
|
635b508795 | ||
|
|
24f31f6fa2 | ||
|
|
73708a52d9 | ||
|
|
ef37928981 | ||
|
|
8f2fe76706 | ||
|
|
1c2deee063 | ||
|
|
fcdced1a00 | ||
|
|
ff17ab3e9b | ||
|
|
5458e174e6 | ||
|
|
7505832fb9 | ||
|
|
b3bd8c8db1 | ||
|
|
407ab3d17a | ||
|
|
d0a9d80341 | ||
|
|
2cd44707b7 | ||
|
|
2690525ab5 | ||
|
|
5dd467cb84 | ||
|
|
1641c896d7 | ||
|
|
6c702d4556 | ||
|
|
457ffc2066 | ||
|
|
7f171594f3 | ||
|
|
4cab5459dd | ||
|
|
0c31b66116 | ||
|
|
343e3d3bc7 | ||
|
|
5c8e6841ee | ||
|
|
f2ccc339aa | ||
|
|
eec16bdfbd | ||
|
|
0675a6addb | ||
|
|
72ec208253 | ||
|
|
54f293b0e7 | ||
|
|
36537a1edf | ||
|
|
c38ed8ff51 | ||
|
|
06a03157d3 | ||
|
|
e5ac0394b9 | ||
|
|
bc3ece6ca8 | ||
|
|
4367a2821f | ||
|
|
baa60254f0 | ||
|
|
bc9f9bdac4 | ||
|
|
f1d497a70c | ||
|
|
e1a6ccd9d0 | ||
|
|
b888e127d2 | ||
|
|
5fad9ccc45 | ||
|
|
92a0cb70b4 | ||
|
|
55733e47af | ||
|
|
843843e735 | ||
|
|
6f3685ec11 | ||
|
|
47cc883f25 | ||
|
|
7a0d8e785c | ||
|
|
3276c0e072 | ||
|
|
6772303bf5 | ||
|
|
561a651dc4 | ||
|
|
65c1122bb1 | ||
|
|
e57d0138a6 | ||
|
|
cac7614bc0 | ||
|
|
8414567a63 | ||
|
|
3b18b74f3a | ||
|
|
e7254ab764 | ||
|
|
2d1e1ece4d | ||
|
|
7af22a422e | ||
|
|
34034bee34 | ||
|
|
f398e9b27b | ||
|
|
b758f7843d | ||
|
|
8a9b7d5a6c | ||
|
|
b3b8e7f509 | ||
|
|
a083230d06 | ||
|
|
67f1c935ec | ||
|
|
f14052800e | ||
|
|
c409b72beb | ||
|
|
cbdb6c0934 | ||
|
|
8fc008bce9 | ||
|
|
654eb39877 | ||
|
|
8e67be7a35 | ||
|
|
45d19a669e | ||
|
|
d0cbe2a27e | ||
|
|
d86fda521f | ||
|
|
be666be9a6 | ||
|
|
1fe259a6f2 | ||
|
|
37c3c1cb84 | ||
|
|
2b35f6bb9a | ||
|
|
308489dc6c | ||
|
|
b0bd790141 | ||
|
|
53c96d21dd | ||
|
|
acc21e207f | ||
|
|
a7c55544c3 | ||
|
|
b621a8ebd0 | ||
|
|
b431832c83 | ||
|
|
828a8bb212 | ||
|
|
e4bcb276df | ||
|
|
4b5e4a43a3 | ||
|
|
216fee6757 | ||
|
|
8e66ed043d | ||
|
|
1f60cf7d1c | ||
|
|
02b61cd36a | ||
|
|
70e0b32265 | ||
|
|
65c6f8ff52 | ||
|
|
f37590f088 | ||
|
|
e5ea28ea34 | ||
|
|
a77e972b82 | ||
|
|
56c4974803 | ||
|
|
6ec8fe035c | ||
|
|
9287777b87 | ||
|
|
1abf793e19 | ||
|
|
e44b743016 | ||
|
|
789dae907a | ||
|
|
de0d7b28f8 | ||
|
|
19248c3a30 | ||
|
|
cdb72b7789 | ||
|
|
4a51dc8d64 | ||
|
|
388014d62b | ||
|
|
f5956aa44d | ||
|
|
1e09ce7a65 | ||
|
|
24990cde08 | ||
|
|
92ebb82a6a | ||
|
|
ad6d023f46 | ||
|
|
b0d5f65dfe | ||
|
|
50598cc3e7 | ||
|
|
b3c1985d45 | ||
|
|
ad2bc470a1 | ||
|
|
88ef01f129 | ||
|
|
5da858aa4c | ||
|
|
e50a8e8d68 | ||
|
|
b4069af1c7 | ||
|
|
31fbc8c95c | ||
|
|
cf5410622f | ||
|
|
ef09b241ea | ||
|
|
a08109d745 | ||
|
|
d86d12f2db | ||
|
|
bc9ad69260 | ||
|
|
314aba6d65 | ||
|
|
995eba2c11 | ||
|
|
4e73075728 | ||
|
|
f785031ad8 | ||
|
|
9c10cb02b8 | ||
|
|
2ab4001a43 | ||
|
|
94afe3c15f | ||
|
|
d48d1c44ea | ||
|
|
0fbee213a3 | ||
|
|
d2c2c73eeb | ||
|
|
db07a12be5 | ||
|
|
8448910628 | ||
|
|
a33fc389e3 | ||
|
|
5637d46488 | ||
|
|
6d71154589 | ||
|
|
b6540b10b9 | ||
|
|
a182265077 | ||
|
|
0e11815206 | ||
|
|
86455a2d46 | ||
|
|
abc5475bea | ||
|
|
cabf13fb3a | ||
|
|
79b03a4907 | ||
|
|
fcd27863a1 | ||
|
|
e3c5097035 | ||
|
|
b9c253733e | ||
|
|
1d8c4a42a8 | ||
|
|
48f45beeeb | ||
|
|
5c7f38c2dc | ||
|
|
c4e3b2f6dd | ||
|
|
f26e8be3f4 | ||
|
|
a42012a958 | ||
|
|
659676adc2 | ||
|
|
2cb30deeda | ||
|
|
ff3b4c7a27 | ||
|
|
1171fb17e6 | ||
|
|
0052312459 | ||
|
|
826347023b | ||
|
|
f2ea2993cc | ||
|
|
be95d0c198 | ||
|
|
f6d8d3c536 | ||
|
|
1abbd8baba | ||
|
|
cad53dcce5 | ||
|
|
4016c22289 | ||
|
|
56fd18be75 | ||
|
|
00c1977f87 | ||
|
|
b19a9267f6 | ||
|
|
5783210873 | ||
|
|
f242ffb2af | ||
|
|
60bf7ecde7 | ||
|
|
c52f7812f1 | ||
|
|
44d220b74c | ||
|
|
13b3abdcd4 | ||
|
|
282da93630 | ||
|
|
ccbcea162d | ||
|
|
5f227b2327 | ||
|
|
b233129c32 | ||
|
|
01ffc49e18 | ||
|
|
4a40192647 | ||
|
|
08e8993101 | ||
|
|
3be1d2ad87 | ||
|
|
1408bee041 | ||
|
|
9bf5ee90ef | ||
|
|
9e3f718ab9 | ||
|
|
b37707d933 | ||
|
|
ae1018f8c5 | ||
|
|
faa6fbcc39 | ||
|
|
2b68bd82a4 | ||
|
|
88f44bd63b | ||
|
|
ced3aa44f1 | ||
|
|
6f1a488b92 | ||
|
|
b229f1b1dc | ||
|
|
88a309712d | ||
|
|
3d93f9b04b | ||
|
|
f37682c154 | ||
|
|
4cd00a4b44 | ||
|
|
ce8d9f09fa | ||
|
|
82707f8347 | ||
|
|
eae231722f | ||
|
|
f269c0ae08 | ||
|
|
840361707a | ||
|
|
dbab82c533 | ||
|
|
fa605f5416 | ||
|
|
1983f1e864 | ||
|
|
cfc2680786 | ||
|
|
7eb7ee81ba | ||
|
|
420f792290 | ||
|
|
733fb5d96c | ||
|
|
640b64a407 | ||
|
|
0388010c8b | ||
|
|
ab6816be62 | ||
|
|
1d3278f837 | ||
|
|
ede879c550 | ||
|
|
ddc504eb05 | ||
|
|
8bf42fa46a | ||
|
|
879cb951a0 | ||
|
|
3939706f42 | ||
|
|
f5c3a950bf | ||
|
|
3021f96465 | ||
|
|
117c063f78 | ||
|
|
10232720ae | ||
|
|
d2feeedec1 | ||
|
|
42f2b25f5c | ||
|
|
c5360f264f | ||
|
|
9517531388 | ||
|
|
be282f3ac7 | ||
|
|
c5fafba7b5 | ||
|
|
704e59afdf | ||
|
|
be6e0c9498 | ||
|
|
2a543d3aad | ||
|
|
697e806dd8 | ||
|
|
a28d5085bb | ||
|
|
4cac015176 | ||
|
|
ae1770e4ac | ||
|
|
a5a710cbb1 | ||
|
|
3d7172d664 | ||
|
|
68431d4a25 | ||
|
|
5d5f602e80 | ||
|
|
aa6256b2d4 | ||
|
|
17ff1b4217 | ||
|
|
7709665538 | ||
|
|
00d15f5475 | ||
|
|
23c4ca5d3f | ||
|
|
c048ecf9cf | ||
|
|
d5e51366e9 | ||
|
|
94eb5ce091 | ||
|
|
055290f075 | ||
|
|
008c2171ff | ||
|
|
835d5aee82 | ||
|
|
bdf5cb7a2e | ||
|
|
81bf27c13e | ||
|
|
8c8af56e67 | ||
|
|
418d1b22d6 | ||
|
|
607e93e180 | ||
|
|
08adc87493 | ||
|
|
d42adbf85d | ||
|
|
6dc17f51ce | ||
|
|
87e0ede2be | ||
|
|
330c3a6d22 | ||
|
|
6b53f132d5 | ||
|
|
08c511f854 | ||
|
|
00c6cbd7a6 | ||
|
|
5718628d72 | ||
|
|
0ca4aadfb5 | ||
|
|
8e2d8fb89d | ||
|
|
ce34b1173d | ||
|
|
790e785dd3 | ||
|
|
97ac1033eb | ||
|
|
eee0737a1c | ||
|
|
83af1e1c53 | ||
|
|
4585757ac2 | ||
|
|
5990d27a07 | ||
|
|
f80c3a0090 | ||
|
|
385fdd1fae | ||
|
|
3a75dd75b5 | ||
|
|
fc3238a618 | ||
|
|
f7c6330910 | ||
|
|
e53471f85e | ||
|
|
4423d0f17f | ||
|
|
f10e2b79ac | ||
|
|
777ee3d408 | ||
|
|
e440ac088b | ||
|
|
7e7609ca1f | ||
|
|
fb2005fd83 | ||
|
|
7b87792d8a | ||
|
|
9d684536a1 | ||
|
|
75888c5ba6 | ||
|
|
f76628574a | ||
|
|
b2d6dd6897 | ||
|
|
b577ab01ae | ||
|
|
ad75065848 | ||
|
|
cf6ff4c315 | ||
|
|
58116fb451 | ||
|
|
f15f609832 | ||
|
|
799dd5adbe | ||
|
|
582fb5ba93 | ||
|
|
0f03ac28d8 | ||
|
|
4418404a71 | ||
|
|
8427cc52d8 | ||
|
|
d6bda5c3d7 | ||
|
|
9a79f3ba40 | ||
|
|
d08855585a | ||
|
|
6c523d006b | ||
|
|
9185c0e36b | ||
|
|
3013dcd9f3 | ||
|
|
ce7abc7afb | ||
|
|
f5bc2ebb59 | ||
|
|
5714ac881e | ||
|
|
160ebcf7f5 | ||
|
|
8638868fb7 | ||
|
|
883c27885f | ||
|
|
c2ee9032b9 | ||
|
|
ebc75bddca | ||
|
|
5971163361 | ||
|
|
639f814ef8 | ||
|
|
1733b3124d | ||
|
|
f3e632078a | ||
|
|
b174c20faf | ||
|
|
5d74c6787f | ||
|
|
095cb8d3a1 | ||
|
|
f745ff3e91 | ||
|
|
8b976cee3c | ||
|
|
6e77a5e94b | ||
|
|
c8052be29a | ||
|
|
7d83b9d86a | ||
|
|
b977113e34 | ||
|
|
027e3410d8 | ||
|
|
282cbf301a | ||
|
|
75a83606f2 | ||
|
|
cff16d17b5 | ||
|
|
859ab94104 | ||
|
|
b6390003b6 | ||
|
|
c4514ad4cd | ||
|
|
e7360e07ab | ||
|
|
875e4ea32c | ||
|
|
72d51d5c52 | ||
|
|
3d5ad28eb0 | ||
|
|
7bb8c5b8f4 | ||
|
|
a917917166 | ||
|
|
5c39c481b7 | ||
|
|
66a2e9ef69 | ||
|
|
41c64f4b02 | ||
|
|
be58017cac | ||
|
|
d82228483d | ||
|
|
8f249ec2a2 | ||
|
|
5815afafb7 | ||
|
|
6fd45a2608 | ||
|
|
f30fd72778 | ||
|
|
b455724b1c | ||
|
|
ddafaa60eb | ||
|
|
9bf7a97e55 | ||
|
|
7afff19fce | ||
|
|
9f88bf15ab | ||
|
|
25b3ad4f8e | ||
|
|
91cfb0b2d2 | ||
|
|
7da2cfd456 | ||
|
|
97eaf20dbc | ||
|
|
aff52e7d5f | ||
|
|
1a6174b1c8 | ||
|
|
98866ee407 | ||
|
|
703957e2b7 | ||
|
|
baada58e8c | ||
|
|
885c2a5865 | ||
|
|
5fef8a4d51 | ||
|
|
40c4c425f6 | ||
|
|
c0e0e53ee7 | ||
|
|
2029ceeee0 | ||
|
|
a9999aae2e | ||
|
|
b5a4787424 | ||
|
|
2b39717cab | ||
|
|
8f53a39f63 | ||
|
|
020976e257 | ||
|
|
cdfa20b9a7 | ||
|
|
b87e242de2 | ||
|
|
147f2d23a5 | ||
|
|
0fcf584b01 | ||
|
|
43361b3a2a | ||
|
|
df1db3d0ff | ||
|
|
2a4c58a680 | ||
|
|
0c1b20d3e7 | ||
|
|
9ce33bba75 | ||
|
|
23ec6ea9b1 | ||
|
|
1946b1c333 | ||
|
|
1251ae3bc0 | ||
|
|
6b3b35d4df | ||
|
|
9e943c82b9 | ||
|
|
9c4c0b1e56 | ||
|
|
a5538ece8e | ||
|
|
092599d167 | ||
|
|
af72415952 | ||
|
|
a2c058877a | ||
|
|
d4ecf97436 | ||
|
|
49d9ceb73a | ||
|
|
7c8cbc4b3f | ||
|
|
ff4a2a751f | ||
|
|
6fdf1d0ca0 | ||
|
|
8303159abf | ||
|
|
0a6371b159 | ||
|
|
c835a59cbf | ||
|
|
32bfdde04e | ||
|
|
f9fcb089a1 | ||
|
|
3bcdcbdac7 | ||
|
|
4f9d0c09ef | ||
|
|
ab3f112981 | ||
|
|
740d9e432a | ||
|
|
bca96ae215 | ||
|
|
b18de72bdd | ||
|
|
62d7590501 | ||
|
|
f3b02e8fc2 | ||
|
|
f17becff10 | ||
|
|
042b8f60d5 | ||
|
|
2dfcc4de8f | ||
|
|
33b238c646 | ||
|
|
a7bd050013 | ||
|
|
5ab5d1ece7 | ||
|
|
07930d416d | ||
|
|
929816c342 | ||
|
|
5cee5ad5d3 | ||
|
|
9d2a0acfcf | ||
|
|
b2719a263f | ||
|
|
3eead80cb4 | ||
|
|
9efc9d53a5 | ||
|
|
82b1887f6d | ||
|
|
dee0c54fde | ||
|
|
92cea96616 | ||
|
|
e1aa0e5bc6 | ||
|
|
57a2f67646 | ||
|
|
5f752ed251 | ||
|
|
2297eb0d1c | ||
|
|
7a8583e352 | ||
|
|
04658e99fd | ||
|
|
7e7eff6c8e | ||
|
|
2547d36254 | ||
|
|
45c3ec504f | ||
|
|
6b85bc51de | ||
|
|
19c5bac6d3 | ||
|
|
e9a55cc1db | ||
|
|
dc61aa07ac | ||
|
|
a36ca26720 | ||
|
|
a4e0c90fe9 | ||
|
|
bf46187d0a | ||
|
|
119ceb258e | ||
|
|
a737f7c7da | ||
|
|
a4f05c744e | ||
|
|
274f2d4c84 | ||
|
|
7c2528f4a7 | ||
|
|
7d1b36c7fb | ||
|
|
156c58e544 | ||
|
|
542b6c4e1a | ||
|
|
3cde52a3a5 | ||
|
|
8abb5cbd3a | ||
|
|
47648f6e2d | ||
|
|
d0b2bbee15 | ||
|
|
c7f0eab9c2 | ||
|
|
e504a2c591 | ||
|
|
76f613f82f | ||
|
|
623a9ffcb5 | ||
|
|
3641cd81cd | ||
|
|
e93e6f0061 | ||
|
|
d917f7c881 | ||
|
|
461b466bb8 | ||
|
|
39e912bb7f | ||
|
|
4922f3daf8 | ||
|
|
60620eccec | ||
|
|
343f7e7f56 | ||
|
|
d0d08f8a3c | ||
|
|
ada6a6f3ff | ||
|
|
c29edfd402 | ||
|
|
f16d557138 | ||
|
|
4f871534b0 | ||
|
|
1c6f6ad619 | ||
|
|
f55604f8fd | ||
|
|
09cb29c532 | ||
|
|
e3a931fab4 | ||
|
|
6ee7682547 | ||
|
|
24f1b1d377 | ||
|
|
f8b8ce0b5b | ||
|
|
6597f0058d | ||
|
|
e06f5380a1 | ||
|
|
c74079797a | ||
|
|
4f10e3f40a | ||
|
|
52a87c10b5 | ||
|
|
04f0aa1a7a | ||
|
|
9ca72341a8 | ||
|
|
0588cceee0 | ||
|
|
3e7b1a1de8 | ||
|
|
39d36eece6 | ||
|
|
2b2acd1311 | ||
|
|
b5c039eaba | ||
|
|
8633908301 | ||
|
|
425d6b7deb | ||
|
|
a6da85d2e5 | ||
|
|
fbbbacb121 | ||
|
|
5c0471d06e | ||
|
|
cf3ec3687c | ||
|
|
398c7ef80c | ||
|
|
2da5d6c28d | ||
|
|
8960130548 | ||
|
|
b4ff025836 | ||
|
|
9eae76442f | ||
|
|
e5a6c97058 | ||
|
|
ff4dfcac61 | ||
|
|
d2bc068f95 | ||
|
|
2778c88d3a | ||
|
|
5b66b440ce | ||
|
|
7b4e8a8f68 | ||
|
|
587f40f79f | ||
|
|
3cb1c4b027 | ||
|
|
5678215b25 | ||
|
|
5acab43669 | ||
|
|
974076c5f1 | ||
|
|
a4bac75d2a | ||
|
|
a0a899f999 | ||
|
|
92eecce9b0 | ||
|
|
a9b5521dfe | ||
|
|
be1921240a | ||
|
|
f602fe8d7d | ||
|
|
abe6c4c2b8 | ||
|
|
d01a7e18a2 | ||
|
|
276877119d | ||
|
|
5b77fdf54b | ||
|
|
b71bb9e48d | ||
|
|
05b5ecaa5f | ||
|
|
f7e0ba6544 | ||
|
|
836b5ef725 | ||
|
|
bc7eb9abaa | ||
|
|
dd177c5578 | ||
|
|
03b2bbd22c | ||
|
|
8729ad9cd1 | ||
|
|
fa9d964a32 | ||
|
|
dc12917b66 | ||
|
|
800c91c637 | ||
|
|
c3e73256bf | ||
|
|
84f45f523a | ||
|
|
43295a58eb | ||
|
|
10630944d3 | ||
|
|
1e2fef884f | ||
|
|
9839bf4014 | ||
|
|
dd680ecf70 | ||
|
|
1528231032 | ||
|
|
6f5d88a5c1 | ||
|
|
442293215c | ||
|
|
ab2ba579d1 | ||
|
|
c22ede7aa1 | ||
|
|
bfa4cd58a7 | ||
|
|
4b5c1e6d15 | ||
|
|
f367c79e8e | ||
|
|
c3cead5993 | ||
|
|
713706caff | ||
|
|
b84d6ac2ef | ||
|
|
78f15ac7d9 | ||
|
|
3f223f4306 | ||
|
|
a52c75601c | ||
|
|
8c162c75c5 | ||
|
|
bb8c07f232 | ||
|
|
3d117362ef | ||
|
|
023601fd09 | ||
|
|
5b1c7cbf5c | ||
|
|
4f2c5b1909 | ||
|
|
779ad2f8b9 | ||
|
|
b195c0d203 | ||
|
|
41521cca95 | ||
|
|
108a28384c | ||
|
|
b641cab624 | ||
|
|
b031d36fa2 | ||
|
|
97dc62afe1 | ||
|
|
e37d71fba6 | ||
|
|
1be10010c8 | ||
|
|
cec0e1bbcb | ||
|
|
7c4e4aea26 | ||
|
|
60f6108d35 | ||
|
|
43fb668c7d | ||
|
|
ca77d3cf2a | ||
|
|
906cfb5e43 | ||
|
|
20a38c662e | ||
|
|
1aa0934104 | ||
|
|
415d9e075d | ||
|
|
df74ff587a | ||
|
|
44dbcccd72 | ||
|
|
195dcf3fd7 | ||
|
|
ee9a10f39d | ||
|
|
188b15172f | ||
|
|
d2604c7848 | ||
|
|
a50057d80c | ||
|
|
8b28267b68 | ||
|
|
fa281cb400 | ||
|
|
1e3b321dcf | ||
|
|
7b0f7639e9 | ||
|
|
9d271eb932 | ||
|
|
103332e087 | ||
|
|
405a60b09b | ||
|
|
c903c7357a | ||
|
|
3c34adad82 | ||
|
|
2970cc5061 | ||
|
|
a2e71ee52a | ||
|
|
661d10d256 | ||
|
|
85a5c829e0 | ||
|
|
5782ae9438 | ||
|
|
600c93ed0f | ||
|
|
a38a52ebf3 | ||
|
|
dc83ad2dee | ||
|
|
d93910ba5a | ||
|
|
c54b375c06 | ||
|
|
e35a25b135 | ||
|
|
b4df9d87d8 | ||
|
|
0336af0624 | ||
|
|
1021604889 | ||
|
|
204e8d1b2b | ||
|
|
9f32f3a025 | ||
|
|
41dc71cf80 | ||
|
|
646ab03660 | ||
|
|
f3107ed19a | ||
|
|
978d373893 | ||
|
|
a6fe748b01 | ||
|
|
db26f4dbd9 | ||
|
|
d8435602d3 | ||
|
|
53643c9c5a | ||
|
|
d6d82248a8 | ||
|
|
3bd3f2dff8 | ||
|
|
8cf2dfc71b | ||
|
|
b7dc92d85a | ||
|
|
bd5647f4c7 | ||
|
|
b28eb09e3f | ||
|
|
9d22682626 | ||
|
|
0dabcec67c | ||
|
|
3c21002d32 | ||
|
|
20e45d4d42 | ||
|
|
da13725bea | ||
|
|
43c7bcced0 | ||
|
|
07d46605c7 | ||
|
|
daa3d07f7a | ||
|
|
729f59ba96 | ||
|
|
7da14338f9 | ||
|
|
cc73774085 | ||
|
|
6c83385af7 | ||
|
|
196940778f | ||
|
|
7544eb96fc | ||
|
|
0da85ba015 | ||
|
|
78484de959 | ||
|
|
829a6bbdc6 | ||
|
|
f71bd27a03 | ||
|
|
89aeb66a96 | ||
|
|
a4eb6b3cb3 | ||
|
|
a2eb97e504 | ||
|
|
bab3a53a53 | ||
|
|
837970147c | ||
|
|
0ecf21467b | ||
|
|
62c2cb3fb7 | ||
|
|
4fdb5a60d5 | ||
|
|
95334d2c8b | ||
|
|
6867d84ca9 | ||
|
|
baddf7e763 | ||
|
|
c21a2bf13a | ||
|
|
ac336efa33 | ||
|
|
52e010d79b | ||
|
|
91205a0b2a | ||
|
|
a510565324 | ||
|
|
3693bc39f4 | ||
|
|
aa7a732cce | ||
|
|
8d4535d6bf | ||
|
|
d6b4f2c682 | ||
|
|
1ba1701574 | ||
|
|
b30ab6a537 | ||
|
|
8462e5c763 | ||
|
|
0db3502855 | ||
|
|
c300a85204 | ||
|
|
a14484583d | ||
|
|
b68eda3051 | ||
|
|
36804a43bc | ||
|
|
2f70acd5d0 | ||
|
|
2d649d70be | ||
|
|
7b42915a1b | ||
|
|
590bd2dfd8 | ||
|
|
22abaa15c2 | ||
|
|
e1c9b34bbe | ||
|
|
5260f454ef | ||
|
|
b3ee3f44c4 | ||
|
|
64ac515ed0 | ||
|
|
29e72da327 | ||
|
|
96c25c428d | ||
|
|
699a475315 | ||
|
|
e7860e4f55 | ||
|
|
93985ee02d | ||
|
|
0806d71e96 | ||
|
|
a66186adc3 | ||
|
|
a3b1e02783 | ||
|
|
4a507e4d79 | ||
|
|
8808e0dfb8 | ||
|
|
9a00122525 | ||
|
|
ff775176a6 | ||
|
|
38943a74d5 | ||
|
|
63172ad79b | ||
|
|
06549bad2c | ||
|
|
63f5bea155 | ||
|
|
6e4a9c6a73 | ||
|
|
2c0cadf296 | ||
|
|
54a0cd2b50 | ||
|
|
4a363200a0 | ||
|
|
ee07f8391c | ||
|
|
7489100c26 | ||
|
|
7bb1fd6947 | ||
|
|
c8081da596 | ||
|
|
b970cfb305 | ||
|
|
cf2ce55d02 | ||
|
|
a847a110c7 | ||
|
|
3201bfe507 | ||
|
|
c8d2de60bb | ||
|
|
9df4d5f381 | ||
|
|
c75955a4a3 | ||
|
|
43e9b000d9 | ||
|
|
35bec0bed2 | ||
|
|
d84d99632e | ||
|
|
0eb4f74e36 | ||
|
|
44393643c9 | ||
|
|
1a1410eb1c | ||
|
|
34ba71e229 | ||
|
|
1c05556d98 | ||
|
|
94258b8d97 | ||
|
|
00310914da | ||
|
|
d42657b0a5 | ||
|
|
57f7a72d8d | ||
|
|
36474b9705 | ||
|
|
048e0d01de | ||
|
|
a2cb0f073c | ||
|
|
67a8826d24 | ||
|
|
e3301801b7 | ||
|
|
4ec76766d0 | ||
|
|
b3008e99dc | ||
|
|
1f76a177fe | ||
|
|
19817bf254 | ||
|
|
b5a3d6489b | ||
|
|
272f0d1d99 | ||
|
|
0ccd3b353d | ||
|
|
e0e28b5e35 | ||
|
|
48b3b83cae | ||
|
|
2407a0125e | ||
|
|
865b01c93b | ||
|
|
34ce5e9448 | ||
|
|
8df3db3607 | ||
|
|
b7124e5bda | ||
|
|
57d6855c78 | ||
|
|
64cc897f1a | ||
|
|
caa2811c0a | ||
|
|
c43e8b6b41 | ||
|
|
3ed1cdb261 | ||
|
|
bfd060e66e | ||
|
|
91fc3aa9fa | ||
|
|
153f94715d | ||
|
|
e8719f3796 | ||
|
|
f73d75dd6a | ||
|
|
6a3ede9102 | ||
|
|
b4aef5d14d | ||
|
|
32f8ea31e2 | ||
|
|
841759ddd8 | ||
|
|
bd6f7583c8 | ||
|
|
0c3b1d388f | ||
|
|
778faf2950 | ||
|
|
4581705d56 | ||
|
|
001fdab48b | ||
|
|
6c5778e547 | ||
|
|
2450512059 | ||
|
|
bb80b42426 | ||
|
|
717eefad97 | ||
|
|
644a8a6110 | ||
|
|
3d8935dc2b | ||
|
|
e16b4cb82d | ||
|
|
5ce951d599 | ||
|
|
0b5d7a1624 | ||
|
|
e256d988fc | ||
|
|
25ca5d52b2 | ||
|
|
e6942fe353 | ||
|
|
f9e17678b0 | ||
|
|
f385504c30 | ||
|
|
e34268c4c1 | ||
|
|
4b95927f5f | ||
|
|
2ccefb76a5 | ||
|
|
44d8ee902a | ||
|
|
4bc184f06c | ||
|
|
a3e0503613 | ||
|
|
fc06690ffd | ||
|
|
9606932a5c | ||
|
|
55e7ab5490 | ||
|
|
3d77f397f6 | ||
|
|
2c0b4fc072 | ||
|
|
6e246ed246 | ||
|
|
39e695dbcc | ||
|
|
3d42671277 | ||
|
|
28fd3f6f7e | ||
|
|
24bf7e5165 | ||
|
|
afe93e989f | ||
|
|
b6e01e71fa | ||
|
|
07d30410da | ||
|
|
79299ee95a | ||
|
|
eb01ca2b81 | ||
|
|
15d8f61e97 | ||
|
|
bfdc4b3183 | ||
|
|
405cf34193 | ||
|
|
d930182e54 | ||
|
|
4ddd01b77c | ||
|
|
5a4b2754e3 | ||
|
|
af88005cb5 | ||
|
|
6558ec7909 | ||
|
|
04f2b20503 | ||
|
|
d2ca717f48 | ||
|
|
a1237ab9b6 |
@@ -1,93 +0,0 @@
|
||||
# This .editorconfig section approximates ktfmt's formatting rules. You can include it in an
|
||||
# existing .editorconfig file or use it standalone by copying it to <project root>/.editorconfig
|
||||
# and making sure your editor is set to read settings from .editorconfig files.
|
||||
#
|
||||
# It includes editor-specific config options for IntelliJ IDEA.
|
||||
#
|
||||
# If any option is wrong, PR are welcome
|
||||
|
||||
[{*.kt,*.kts}]
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 100
|
||||
indent_size = 2
|
||||
ij_continuation_indent_size = 2
|
||||
ij_java_names_count_to_use_import_on_demand = 9999
|
||||
ij_kotlin_align_in_columns_case_branch = false
|
||||
ij_kotlin_align_multiline_binary_operation = false
|
||||
ij_kotlin_align_multiline_extends_list = false
|
||||
ij_kotlin_align_multiline_method_parentheses = false
|
||||
ij_kotlin_align_multiline_parameters = true
|
||||
ij_kotlin_align_multiline_parameters_in_calls = false
|
||||
ij_kotlin_allow_trailing_comma = true
|
||||
ij_kotlin_allow_trailing_comma_on_call_site = true
|
||||
ij_kotlin_assignment_wrap = normal
|
||||
ij_kotlin_blank_lines_after_class_header = 0
|
||||
ij_kotlin_blank_lines_around_block_when_branches = 0
|
||||
ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1
|
||||
ij_kotlin_block_comment_at_first_column = true
|
||||
ij_kotlin_call_parameters_new_line_after_left_paren = true
|
||||
ij_kotlin_call_parameters_right_paren_on_new_line = false
|
||||
ij_kotlin_call_parameters_wrap = on_every_item
|
||||
ij_kotlin_catch_on_new_line = false
|
||||
ij_kotlin_class_annotation_wrap = split_into_lines
|
||||
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
|
||||
ij_kotlin_continuation_indent_for_chained_calls = true
|
||||
ij_kotlin_continuation_indent_for_expression_bodies = true
|
||||
ij_kotlin_continuation_indent_in_argument_lists = true
|
||||
ij_kotlin_continuation_indent_in_elvis = false
|
||||
ij_kotlin_continuation_indent_in_if_conditions = false
|
||||
ij_kotlin_continuation_indent_in_parameter_lists = false
|
||||
ij_kotlin_continuation_indent_in_supertype_lists = false
|
||||
ij_kotlin_else_on_new_line = false
|
||||
ij_kotlin_enum_constants_wrap = off
|
||||
ij_kotlin_extends_list_wrap = normal
|
||||
ij_kotlin_field_annotation_wrap = split_into_lines
|
||||
ij_kotlin_finally_on_new_line = false
|
||||
ij_kotlin_if_rparen_on_new_line = false
|
||||
ij_kotlin_import_nested_classes = false
|
||||
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
|
||||
ij_kotlin_keep_blank_lines_before_right_brace = 2
|
||||
ij_kotlin_keep_blank_lines_in_code = 2
|
||||
ij_kotlin_keep_blank_lines_in_declarations = 2
|
||||
ij_kotlin_keep_first_column_comment = true
|
||||
ij_kotlin_keep_indents_on_empty_lines = false
|
||||
ij_kotlin_keep_line_breaks = true
|
||||
ij_kotlin_lbrace_on_next_line = false
|
||||
ij_kotlin_line_comment_add_space = false
|
||||
ij_kotlin_line_comment_at_first_column = true
|
||||
ij_kotlin_method_annotation_wrap = split_into_lines
|
||||
ij_kotlin_method_call_chain_wrap = normal
|
||||
ij_kotlin_method_parameters_new_line_after_left_paren = true
|
||||
ij_kotlin_method_parameters_right_paren_on_new_line = true
|
||||
ij_kotlin_method_parameters_wrap = on_every_item
|
||||
ij_kotlin_name_count_to_use_star_import = 9999
|
||||
ij_kotlin_name_count_to_use_star_import_for_members = 9999
|
||||
ij_kotlin_parameter_annotation_wrap = off
|
||||
ij_kotlin_space_after_comma = true
|
||||
ij_kotlin_space_after_extend_colon = true
|
||||
ij_kotlin_space_after_type_colon = true
|
||||
ij_kotlin_space_before_catch_parentheses = true
|
||||
ij_kotlin_space_before_comma = false
|
||||
ij_kotlin_space_before_extend_colon = true
|
||||
ij_kotlin_space_before_for_parentheses = true
|
||||
ij_kotlin_space_before_if_parentheses = true
|
||||
ij_kotlin_space_before_lambda_arrow = true
|
||||
ij_kotlin_space_before_type_colon = false
|
||||
ij_kotlin_space_before_when_parentheses = true
|
||||
ij_kotlin_space_before_while_parentheses = true
|
||||
ij_kotlin_spaces_around_additive_operators = true
|
||||
ij_kotlin_spaces_around_assignment_operators = true
|
||||
ij_kotlin_spaces_around_equality_operators = true
|
||||
ij_kotlin_spaces_around_function_type_arrow = true
|
||||
ij_kotlin_spaces_around_logical_operators = true
|
||||
ij_kotlin_spaces_around_multiplicative_operators = true
|
||||
ij_kotlin_spaces_around_range = false
|
||||
ij_kotlin_spaces_around_relational_operators = true
|
||||
ij_kotlin_spaces_around_unary_operator = false
|
||||
ij_kotlin_spaces_around_when_arrow = true
|
||||
ij_kotlin_variable_annotation_wrap = off
|
||||
ij_kotlin_while_on_new_line = false
|
||||
ij_kotlin_wrap_elvis_expressions = 1
|
||||
ij_kotlin_wrap_expression_body_functions = 1
|
||||
ij_kotlin_wrap_first_method_in_call_chain = false
|
||||
@@ -1,11 +1,2 @@
|
||||
# KMP removal
|
||||
3d496c99c14106a7f433d01a6a9a1301cc89844a
|
||||
e7f4c8a7ebe4e907f65af46e6dddfd3a542415c0
|
||||
ab2713154f846b3db6d44625d1022bf421b275d0
|
||||
cbdfa67d075ec1f8ceec0f3559498cbca01783a0
|
||||
|
||||
# Renamed Renovate config file
|
||||
f304304bc5b64edf189aaa473b05df912067a8bd
|
||||
|
||||
# Refactored database module into core and impl
|
||||
7b0b206905b8dc1f824a2d2a10e3d94c6b9dd11c
|
||||
cec0e1bbcb7eec6c8eb90807c105cbeab9c01c6c # Reformatted Gradle build files with 2 space indent
|
||||
c3cead59934c8c5221694940fc83be57fa50ac20 # Moved data module into app
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
**/*.html linguist-vendored
|
||||
android/src/main/baseline-prof.txt linguist-generated -diff
|
||||
android/src/main/generated/** linguist-generated -diff
|
||||
@@ -0,0 +1 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 435 KiB |
@@ -1,92 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":dependencyDashboard",
|
||||
"helpers:pinGitHubActionDigests",
|
||||
"github>msfjarvis/shared-workflows//renovate/automerge"
|
||||
],
|
||||
"ignorePresets": [
|
||||
"group:monorepos"
|
||||
],
|
||||
"branchConcurrentLimit": 15,
|
||||
"github-actions": {
|
||||
"managerFilePatterns": [
|
||||
"/.github/reusable-workflows/.+\\.ya?ml$/",
|
||||
],
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
matchDatasources: [
|
||||
"maven"
|
||||
],
|
||||
registryUrls: [
|
||||
"https://maven.google.com/",
|
||||
"https://repo1.maven.org/maven2",
|
||||
"https://plugins.gradle.org/m2/",
|
||||
"https://jitpack.io",
|
||||
],
|
||||
},
|
||||
{
|
||||
"extends": [
|
||||
"monorepo:kotlin"
|
||||
],
|
||||
"groupName": "Kotlin and Compose",
|
||||
"groupSlug": "kotlin-and-compose",
|
||||
"matchUpdateTypes": [
|
||||
"digest",
|
||||
"patch",
|
||||
"minor",
|
||||
"major",
|
||||
],
|
||||
"enabled": false,
|
||||
},
|
||||
{
|
||||
"matchDepNames": [
|
||||
"com.google.devtools.ksp",
|
||||
"dev.drewhamilton.poko",
|
||||
"org.jetbrains.kotlin:kotlin-gradle-plugin",
|
||||
"org.jetbrains.kotlin.plugin.compose",
|
||||
"org.jetbrains.kotlin.plugin.serialization",
|
||||
],
|
||||
"groupName": "Kotlin and Compose",
|
||||
"groupSlug": "kotlin-and-compose",
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"/^androidx.*/",
|
||||
],
|
||||
"groupName": "AndroidX"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"/^io.sentry/",
|
||||
"/^sentry/",
|
||||
],
|
||||
"groupName": "Sentry"
|
||||
},
|
||||
{
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
],
|
||||
"matchDepNames": [
|
||||
"msfjarvis/compose-lobsters"
|
||||
],
|
||||
"enabled": false,
|
||||
},
|
||||
],
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/SpotlessPlugin\.kt$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"KTFMT_VERSION = \"(?<currentValue>.*)\""
|
||||
],
|
||||
"datasourceTemplate": "maven",
|
||||
"depNameTemplate": "com.facebook:ktfmt",
|
||||
"registryUrlTemplate": "https://repo1.maven.org/maven2/",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
name: 'Setup Gradle'
|
||||
description: 'Checks out the repository and sets up Java and Gradle'
|
||||
inputs:
|
||||
token:
|
||||
description: 'token input for actions/checkout'
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
fetch-depth:
|
||||
description: 'fetch-depth input for actions/checkout'
|
||||
required: false
|
||||
default: 1
|
||||
cache-read-only:
|
||||
description: 'cache-read-only input for gradle/actions/setup-gradle'
|
||||
required: false
|
||||
default: ${{ github.event.repository != null && github.ref_name != github.event.repository.default_branch }}
|
||||
dependency-graph:
|
||||
description: 'dependency-graph input for gradle/actions/setup-gradle'
|
||||
required: false
|
||||
default: 'disabled'
|
||||
persist-credentials:
|
||||
description: 'Control the actions/checkout persist-credentials setting'
|
||||
required: false
|
||||
default: false
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: ${{ inputs.fetch-depth }}
|
||||
token: ${{ inputs.token }}
|
||||
lfs: true
|
||||
persist-credentials: ${{ inputs.persist-credentials }}
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
|
||||
- name: Accept Android SDK licenses
|
||||
shell: bash
|
||||
run: |
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses || true
|
||||
|
||||
- name: Set global Gradle properties
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ~/.gradle/
|
||||
echo "android.testoptions.manageddevices.emulator.gpu=swiftshader_indirect" >> ~/.gradle/gradle.properties
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
add-job-summary: always
|
||||
cache-read-only: ${{ inputs.cache-read-only }}
|
||||
dependency-graph: ${{ inputs.dependency-graph }}
|
||||
validate-wrappers: true
|
||||
@@ -1,80 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- renovate/**
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- name: Setup build environment
|
||||
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
|
||||
with:
|
||||
fetch-depth: 0
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
dependency-graph: generate-and-submit
|
||||
|
||||
- name: Check changelog format
|
||||
uses: mindsers/changelog-reader-action@1faaf50aa09d5793d9a100819973df801febfb31 # v2.4.0
|
||||
with:
|
||||
path: ./CHANGELOG.md
|
||||
validation_level: error
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew --no-configuration-cache --stacktrace :build-logic:check check -PslimTests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: (Fail-only) Upload test report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Test report
|
||||
path: '**/build/reports/tests/**'
|
||||
|
||||
dependency-review:
|
||||
runs-on: macos-26
|
||||
if: "github.event_name == 'push' && github.event.ref != 'refs/heads/main'"
|
||||
needs:
|
||||
- check
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
base-ref: refs/heads/main
|
||||
head-ref: ${{ github.ref }}
|
||||
|
||||
release-app:
|
||||
runs-on: macos-26
|
||||
needs:
|
||||
- check
|
||||
steps:
|
||||
- name: Setup build environment
|
||||
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Build release app
|
||||
run: |
|
||||
./scripts/setup-age.sh
|
||||
./scripts/signing-setup.sh "$AGE_SECRET_KEY"
|
||||
./gradlew --no-configuration-cache --stacktrace assembleRelease collectReleaseBundle
|
||||
./scripts/signing-cleanup.sh
|
||||
env:
|
||||
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
||||
@@ -0,0 +1,42 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
name: Deploy snapshot builds
|
||||
jobs:
|
||||
deploy-release-snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
steps:
|
||||
- uses: actions/setup-java@d202f5dbf7256730fb690ec59f6381650114feb2
|
||||
with:
|
||||
java-version: '11'
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
|
||||
|
||||
- name: Decrypt secrets
|
||||
run: scripts/signing-setup.sh "$ENCRYPT_KEY"
|
||||
env:
|
||||
ENCRYPT_KEY: ${{ secrets.ENCRYPT_KEY }}
|
||||
|
||||
- name: Copy CI gradle.properties
|
||||
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- name: Build release app
|
||||
uses: burrunan/gradle-cache-action@03c71a8ba93d670980695505f48f49daf43704a6
|
||||
with:
|
||||
arguments: :app:assembleRelease
|
||||
|
||||
- name: Clean secrets
|
||||
run: scripts/signing-cleanup.sh
|
||||
|
||||
- name: Deploy snapshot
|
||||
run: scripts/deploy-snapshot.sh
|
||||
env:
|
||||
ACTIONS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEPLOY_KEY }}
|
||||
SSH_USERNAME: ${{ secrets.SSH_USERNAME }}
|
||||
SERVER_ADDRESS: ${{ secrets.SERVER_ADDRESS }}
|
||||
SERVER_DESTINATION: ${{ secrets.SERVER_DESTINATION }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||
@@ -0,0 +1,55 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- staging
|
||||
- trying
|
||||
|
||||
jobs:
|
||||
run-unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/setup-java@d202f5dbf7256730fb690ec59f6381650114feb2
|
||||
with:
|
||||
java-version: '11'
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
|
||||
|
||||
- name: Copy CI gradle.properties
|
||||
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- uses: burrunan/gradle-cache-action@03c71a8ba93d670980695505f48f49daf43704a6
|
||||
name: Run unit tests
|
||||
with:
|
||||
arguments: test --stacktrace
|
||||
|
||||
- name: (Fail-only) upload test report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@27bce4eee761b5bc643f46a8dfb41b430c8d05f6
|
||||
with:
|
||||
name: Test report
|
||||
path: app/build/reports
|
||||
|
||||
stylecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/setup-java@d202f5dbf7256730fb690ec59f6381650114feb2
|
||||
with:
|
||||
java-version: '11'
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Copy CI gradle.properties
|
||||
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- uses: burrunan/gradle-cache-action@03c71a8ba93d670980695505f48f49daf43704a6
|
||||
name: Check codestyle with Spotless
|
||||
with:
|
||||
arguments: spotlessCheck
|
||||
@@ -1,177 +0,0 @@
|
||||
name: Release to Google Play
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
inAppUpdatePriority:
|
||||
description: 'In app update priority (0-5)'
|
||||
type: number
|
||||
default: 0
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
publish-google-play-release:
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- name: Setup build environment
|
||||
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
|
||||
with:
|
||||
token: ${{ secrets.POST_RELEASE_GH_TOKEN }}
|
||||
fetch-depth: 0
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
persist-credentials: true
|
||||
|
||||
- name: Set up Git author
|
||||
shell: bash
|
||||
run: |
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email noreply@github.com
|
||||
|
||||
- name: Decrypt secrets
|
||||
run: |
|
||||
./scripts/setup-age.sh
|
||||
./scripts/signing-setup.sh "$AGE_SECRET_KEY"
|
||||
env:
|
||||
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
||||
|
||||
- name: Get current version
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew -q --no-configuration-cache clearPreRelease
|
||||
VERSION="$(grep 'versioning-plugin.versionName' android/version.properties | cut -d = -f 2)"
|
||||
VERSION_CODE="$(grep 'versioning-plugin.versionCode' android/version.properties | cut -d = -f 2)"
|
||||
echo VERSION="${VERSION}" >> $GITHUB_ENV
|
||||
echo VERSION_CODE="${VERSION_CODE}" >> $GITHUB_ENV
|
||||
|
||||
- name: Update changelog
|
||||
uses: msfjarvis/keep-a-changelog-new-release@e60399f488b25149c0963a4e22fff928a9277ce7 # v2.2.0
|
||||
with:
|
||||
tag: v${{ env.VERSION }}
|
||||
version: ${{ env.VERSION }}
|
||||
|
||||
- name: Get changelog entry
|
||||
id: changelog_reader
|
||||
uses: mindsers/changelog-reader-action@1faaf50aa09d5793d9a100819973df801febfb31 # v2.4.0
|
||||
with:
|
||||
version: ${{ env.VERSION }}
|
||||
path: ./CHANGELOG.md
|
||||
|
||||
- name: Write changelog to fastlane metadata
|
||||
shell: bash
|
||||
env:
|
||||
CHANGELOG: ${{ steps.changelog_reader.outputs.changes }}
|
||||
run: |
|
||||
mkdir -p fastlane/metadata/android/en-US/changelogs
|
||||
printf '%s\n' "${CHANGELOG}" > fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt
|
||||
|
||||
- name: Commit changes
|
||||
shell: bash
|
||||
run: |
|
||||
git add .
|
||||
git commit -m 'feat(release): bump version'
|
||||
|
||||
- name: Calculate VCS info for Sentry
|
||||
shell: bash
|
||||
run: |
|
||||
# Get HEAD commit SHA
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
echo "SENTRY_VCS_HEAD_SHA=${HEAD_SHA}" >> $GITHUB_ENV
|
||||
|
||||
# Get parent info (format: commit parent1 [parent2...])
|
||||
PARENTS=$(git rev-list --parents -n 1 HEAD)
|
||||
PARENT_COUNT=$(echo "$PARENTS" | wc -w)
|
||||
|
||||
if [ "$PARENT_COUNT" -ge 3 ]; then
|
||||
# Merge commit (commit + 2+ parents): use first parent
|
||||
BASE_SHA=$(echo "$PARENTS" | awk '{print $2}')
|
||||
else
|
||||
# Normal commit: use last release
|
||||
LAST_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName' || echo "")
|
||||
if [ -n "$LAST_TAG" ]; then
|
||||
BASE_SHA=$(git rev-parse "${LAST_TAG}^{commit}")
|
||||
else
|
||||
BASE_SHA="${HEAD_SHA}"
|
||||
fi
|
||||
fi
|
||||
echo "SENTRY_VCS_BASE_SHA=${BASE_SHA}" >> $GITHUB_ENV
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build release assets
|
||||
shell: bash
|
||||
env:
|
||||
ZIPLINE_SIGNING_KEY: ${{ secrets.ZIPLINE_SIGNING_KEY }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_VCS_HEAD_SHA: ${{ env.SENTRY_VCS_HEAD_SHA }}
|
||||
SENTRY_VCS_BASE_SHA: ${{ env.SENTRY_VCS_BASE_SHA }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
./gradlew --no-configuration-cache assembleRelease uploadSentryProguardMappingsRelease collectReleaseApks collectReleaseBundle -PenableSentry
|
||||
|
||||
- name: Clean secrets
|
||||
run: scripts/signing-cleanup.sh
|
||||
|
||||
- name: Write release notes file
|
||||
shell: bash
|
||||
env:
|
||||
CHANGELOG: ${{ steps.changelog_reader.outputs.changes }}
|
||||
run: |
|
||||
mkdir -p distribution/whatsnew
|
||||
printf '%s' "${CHANGELOG}" >> distribution/whatsnew/whatsnew-en-GB
|
||||
|
||||
- name: Publish bundle to Google Play
|
||||
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
|
||||
with:
|
||||
mappingFile: android/bundle/mapping.txt
|
||||
packageName: dev.msfjarvis.claw.android
|
||||
releaseFiles: android/bundle/*.aab
|
||||
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
status: completed
|
||||
track: production
|
||||
whatsNewDirectory: distribution/whatsnew
|
||||
inAppUpdatePriority: ${{ inputs.inAppUpdatePriority }}
|
||||
|
||||
- name: Post-release work
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.POST_RELEASE_GH_TOKEN }}
|
||||
run: |
|
||||
# Tag the current version
|
||||
git tag "v${VERSION}" -F distribution/whatsnew/whatsnew-en-GB
|
||||
|
||||
# Push the tag to GitHub
|
||||
git push origin "v${VERSION}"
|
||||
|
||||
# Create GitHub release notes with footer
|
||||
RELEASE_NOTES=$(mktemp)
|
||||
{
|
||||
cat distribution/whatsnew/whatsnew-en-GB
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo '<a href="https://play.google.com/store/apps/details?id=dev.msfjarvis.claw.android">'
|
||||
echo ' <img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"'
|
||||
echo ' alt="Get it on Google Play"'
|
||||
echo ' height="80" />'
|
||||
echo '</a>'
|
||||
echo ""
|
||||
echo '<a href="https://android.izzysoft.de/repo/apk/dev.msfjarvis.claw.android">'
|
||||
echo ' <img src="./.github/izzy-badge.webp"'
|
||||
echo ' alt="Get it on IzzyOnDroid"'
|
||||
echo ' height="65" />'
|
||||
echo '</a>'
|
||||
} > "$RELEASE_NOTES"
|
||||
|
||||
# Create a GitHub release
|
||||
gh release create "v${VERSION}" --notes-file "$RELEASE_NOTES" --title "v${VERSION}" ./android/apk/*.apk ./android/bundle/*.aab
|
||||
|
||||
# Clean up temp file
|
||||
rm "$RELEASE_NOTES"
|
||||
|
||||
# Start the next development iteration
|
||||
./gradlew -q --no-configuration-cache bumpSnapshot
|
||||
git commit -am 'feat(release): start next development iteration'
|
||||
git push -u origin
|
||||
@@ -1,73 +0,0 @@
|
||||
name: Android release build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tramline-input:
|
||||
description: "Tramline input"
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
signed-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup build environment
|
||||
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
|
||||
with:
|
||||
cache-read-only: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Tramline
|
||||
id: tramline
|
||||
uses: tramlinehq/deploy-action@58cc98c188b9eda853e549ede6a8b2f58fe29516 # v0.1.7
|
||||
with:
|
||||
input: ${{ github.event.inputs.tramline-input }}
|
||||
|
||||
- name: Calculate VCS info for Sentry
|
||||
shell: bash
|
||||
run: |
|
||||
# Get HEAD commit SHA
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
echo "SENTRY_VCS_HEAD_SHA=${HEAD_SHA}" >> $GITHUB_ENV
|
||||
|
||||
# Get parent info (format: commit parent1 [parent2...])
|
||||
PARENTS=$(git rev-list --parents -n 1 HEAD)
|
||||
PARENT_COUNT=$(echo "$PARENTS" | wc -w)
|
||||
|
||||
if [ "$PARENT_COUNT" -ge 3 ]; then
|
||||
# Merge commit (commit + 2+ parents): use first parent
|
||||
BASE_SHA=$(echo "$PARENTS" | awk '{print $2}')
|
||||
else
|
||||
# Normal commit: use last release
|
||||
LAST_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName' || echo "")
|
||||
if [ -n "$LAST_TAG" ]; then
|
||||
BASE_SHA=$(git rev-parse "${LAST_TAG}^{commit}")
|
||||
else
|
||||
BASE_SHA="${HEAD_SHA}"
|
||||
fi
|
||||
fi
|
||||
echo "SENTRY_VCS_BASE_SHA=${BASE_SHA}" >> $GITHUB_ENV
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build release app
|
||||
run: |
|
||||
./scripts/setup-age.sh
|
||||
./scripts/signing-setup.sh "$AGE_SECRET_KEY"
|
||||
./gradlew --no-configuration-cache --stacktrace assembleRelease uploadSentryProguardMappingsRelease collectReleaseBundle -PenableSentry
|
||||
./scripts/signing-cleanup.sh
|
||||
env:
|
||||
ZIPLINE_SIGNING_KEY: ${{ secrets.ZIPLINE_SIGNING_KEY }}
|
||||
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_VCS_HEAD_SHA: ${{ env.SENTRY_VCS_HEAD_SHA }}
|
||||
SENTRY_VCS_BASE_SHA: ${{ env.SENTRY_VCS_BASE_SHA }}
|
||||
ORG_GRADLE_PROJECT_VERSION_NAME: ${{ steps.tramline.outputs.version_name }}
|
||||
ORG_GRADLE_PROJECT_VERSION_CODE: ${{ steps.tramline.outputs.version_code }}
|
||||
|
||||
- name: Upload app bundle
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-aab
|
||||
path: android/build/outputs/bundle/release/android-release.aab
|
||||
@@ -1,102 +1,10 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/androidstudio,gradle,intellij+all
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=androidstudio,gradle,intellij+all
|
||||
|
||||
### Intellij+all ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
### START CHANGES - We use Gradle auto-import ###
|
||||
.idea/artifacts
|
||||
.idea/compiler.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/modules.xml
|
||||
.idea/*.iml
|
||||
.idea/modules
|
||||
*.iml
|
||||
*.ipr
|
||||
### END CHANGES - We use Gradle auto-import ###
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### Intellij+all Patch ###
|
||||
# Ignore everything but code style settings and run configurations
|
||||
# that are supposed to be shared within teams.
|
||||
|
||||
.idea/*
|
||||
|
||||
### START CHANGES - We use Spotless to enforce code style ###
|
||||
.idea/codeStyles
|
||||
### END CHANGES - We use Spotless to enforce code style ###
|
||||
!.idea/runConfigurations
|
||||
# Created by https://www.gitignore.io/api/androidstudio,gradle
|
||||
# Edit at https://www.gitignore.io/?templates=androidstudio,gradle
|
||||
|
||||
### Gradle ###
|
||||
.gradle
|
||||
**/build/
|
||||
!src/**/build/
|
||||
build/
|
||||
|
||||
# Ignore Gradle GUI config
|
||||
gradle-app.setting
|
||||
@@ -104,21 +12,14 @@ gradle-app.setting
|
||||
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
|
||||
!gradle-wrapper.jar
|
||||
|
||||
# Avoid ignore Gradle wrappper properties
|
||||
!gradle-wrapper.properties
|
||||
|
||||
# Cache of project
|
||||
.gradletasknamecache
|
||||
|
||||
# Eclipse Gradle plugin generated files
|
||||
# Eclipse Core
|
||||
.project
|
||||
# JDT-specific (Eclipse Java Development Tools)
|
||||
.classpath
|
||||
# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
|
||||
# gradle/wrapper/gradle-wrapper.properties
|
||||
|
||||
### Gradle Patch ###
|
||||
# Java heap dump
|
||||
*.hprof
|
||||
**/build/
|
||||
|
||||
### AndroidStudio ###
|
||||
# Covers files to be ignored for android development using Android Studio.
|
||||
@@ -126,7 +27,6 @@ gradle-app.setting
|
||||
# Built application files
|
||||
*.apk
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
@@ -137,13 +37,15 @@ gradle-app.setting
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Signing files
|
||||
.signing/
|
||||
keystore.jks
|
||||
keystore.properties
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
@@ -166,13 +68,6 @@ captures/
|
||||
*~
|
||||
*.swp
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Android Patch
|
||||
gen-external-apklibs
|
||||
|
||||
@@ -184,6 +79,7 @@ obj/
|
||||
|
||||
# IntelliJ IDEA
|
||||
*.iml
|
||||
*.iws
|
||||
/out/
|
||||
|
||||
# User-specific configurations
|
||||
@@ -208,11 +104,20 @@ obj/
|
||||
.idea/dynamic.xml
|
||||
.idea/uiDesigner.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/gradle.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/navEditor.xml
|
||||
.idea/deploymentTargetDropDown.xml
|
||||
|
||||
# OS-specific files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Legacy Eclipse project files
|
||||
.classpath
|
||||
.project
|
||||
.cproject
|
||||
.settings/
|
||||
|
||||
@@ -229,23 +134,22 @@ hs_err_pid*
|
||||
## Plugin-specific files:
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/mongoSettings.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
### AndroidStudio Patch ###
|
||||
|
||||
!/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/androidstudio,gradle,intellij+all
|
||||
|
||||
keystore.properties
|
||||
distribution/
|
||||
screenshots/
|
||||
claw-export.json
|
||||
.kotlin/
|
||||
.worktrees/
|
||||
# End of https://www.gitignore.io/api/androidstudio,gradle
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
/artifacts/
|
||||
/gradle.xml
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JavaProjectCodeInsightSettings">
|
||||
<excluded-names>
|
||||
<name>android.app.LauncherActivity.ListItem</name>
|
||||
<name>android.graphics.Color</name>
|
||||
<name>android.graphics.drawable.Icon</name>
|
||||
<name>android.graphics.fonts.FontFamily</name>
|
||||
<name>android.inputmethodservice.Keyboard.Row</name>
|
||||
<name>android.text.layout.Alignment</name>
|
||||
<name>android.view.RoundedCorner</name>
|
||||
<name>android.view.Surface</name>
|
||||
<name>android.widget.GridLayout.Alignment</name>
|
||||
<name>java.lang.reflect.Modifier</name>
|
||||
<name>java.nio.file.WatchEvent.Modifier</name>
|
||||
<name>java.time.format.TextStyle</name>
|
||||
<name>java.util.Observable</name>
|
||||
<name>org.w3c.dom.Text</name>
|
||||
</excluded-names>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,143 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="LINE_SEPARATOR" value=" " />
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value />
|
||||
</option>
|
||||
<option name="PACKAGES_IMPORT_LAYOUT">
|
||||
<value>
|
||||
<package name="" alias="false" withSubpackages="true" />
|
||||
<package name="" alias="true" withSubpackages="true" />
|
||||
</value>
|
||||
</option>
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="100" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="100" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
<option name="FIELD_ANNOTATION_WRAP" value="1" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,20 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RemoteRepositoriesConfiguration">
|
||||
<remote-repository>
|
||||
<option name="id" value="central" />
|
||||
<option name="name" value="Maven Central repository" />
|
||||
<option name="url" value="https://repo1.maven.org/maven2" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="Google" />
|
||||
<option name="name" value="Google" />
|
||||
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="maven" />
|
||||
<option name="name" value="maven" />
|
||||
<option name="url" value="https://dl.bintray.com/kotlin/kotlinx" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="MavenRepo" />
|
||||
<option name="name" value="MavenRepo" />
|
||||
<option name="url" value="https://repo.maven.apache.org/maven2/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="BintrayJCenter" />
|
||||
<option name="name" value="BintrayJCenter" />
|
||||
<option name="url" value="https://jcenter.bintray.com/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="maven" />
|
||||
<option name="name" value="maven" />
|
||||
<option name="url" value="https://maven.pkg.jetbrains.space/public/p/compose/dev" />
|
||||
</remote-repository>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinScriptingSettings">
|
||||
<option name="suppressDefinitionsCheck" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KtfmtSettings">
|
||||
<option name="enableKtfmt" value="Enabled" />
|
||||
<option name="enabled" value="true" />
|
||||
<option name="uiFormatterStyle" value="Google (internal)" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,735 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.68.0] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Comment votes and tags in search results are now parsed correctly again (Already deployed via OTA)
|
||||
- Failing to fetch the remote HTML parser manifest now falls back to the embedded copy
|
||||
- Fixed tags failing to be fetched from the server
|
||||
|
||||
### Added
|
||||
|
||||
- Direct links to comments can now be shared via the context menu, tap on a comment to reveal it
|
||||
|
||||
## [1.67.0] - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
- Permanent tag blocks are now synced to Lobsters if you are logged in.
|
||||
|
||||
### Changed
|
||||
|
||||
- Search UX has been redone to match Material guidelines
|
||||
- Comments now indicated the OP like the site does, and the animations are faster.
|
||||
- The HTML parser has been reimplemented so it can be updated out-of-band from
|
||||
app updates, ensuring fixes for site markup changes can be rolled out faster.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tags are now parsed properly again.
|
||||
|
||||
## [1.66.0] - 2026-06-02
|
||||
|
||||
### Added
|
||||
|
||||
- You can now upvote and reply to comments from the app, if you're logged in
|
||||
|
||||
### Changed
|
||||
|
||||
- The comments UI now tries to mimic Boost for Reddit
|
||||
|
||||
### Fixed
|
||||
|
||||
- Certificate pinning was removed to avoid errors when certificates were rotated
|
||||
|
||||
## [1.65.0] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- Address fallout from Lobste.rs changing its HTML markup. This will keep happening :(
|
||||
|
||||
### Changed
|
||||
|
||||
- The comment expand/collapse indicators now match the Lobsters UI
|
||||
|
||||
## [1.64.0] - 2026-05-26
|
||||
|
||||
### Added
|
||||
|
||||
- Allow logging in to avoid rate limiting problems.
|
||||
|
||||
### Changed
|
||||
|
||||
- The migration path for tag data format changes in 1.61.0 has been removed.
|
||||
- The app no longer uses the unsupported JSON endpoints on lobste.rs, instead relying
|
||||
on parsing the HTML pages instead.
|
||||
|
||||
## [1.63.0] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Comments are no longer indicated as unread on the first visit of a post
|
||||
|
||||
### Changed
|
||||
|
||||
- Post titles in the list view can now take up 2 lines to make ellipsizing less likely
|
||||
- Action buttons now have a prominent background to make their touch target more obvious
|
||||
- Action buttons now have additional padding to avoid accidental presses when interacting
|
||||
with system gestures
|
||||
|
||||
Thanks to Charles Lombardo for contributing the action button UI changes.
|
||||
|
||||
## [1.62.0] - 2026-03-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve comment scrolling performance
|
||||
- Ensure saved/read state for posts updates properly in UI
|
||||
- All strings in the app are now translatable, open to volunteer contributions.
|
||||
|
||||
## [1.61.0] - 2026-02-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Collapsed comments state are now persisted across navigation events
|
||||
|
||||
### Changed
|
||||
|
||||
- Tags use a more muted background color to avoid being distracting.
|
||||
- Tag blocks can now be time-bound
|
||||
|
||||
## [1.60.0] - 2026-02-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rare crash in widget handling
|
||||
- Line-breaking behavior in comments now packs text densely
|
||||
- Tag filtering now applies to widgets as well
|
||||
|
||||
### Changed
|
||||
|
||||
- Links to other posts now open in the app itself
|
||||
- Widget handles bad network scenarios better
|
||||
|
||||
## [1.59.0] - 2026-01-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Crashes from process death
|
||||
- Crashes in widget handling
|
||||
- Android Auto Backup works again
|
||||
|
||||
### Changed
|
||||
|
||||
- Added a tag filtering option to settings
|
||||
- Comment count badge has been restored
|
||||
- Navigation behavior has been improved to avoid too-deep hierarchies
|
||||
- Widget clicks are handled reliably
|
||||
- Widget UI has been touched up and a preview added for Android 16+
|
||||
- User profile page is now scrollable
|
||||
|
||||
## [1.58.0] - 2025-12-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Avoid crash from trying to import invalid backups
|
||||
- Avoid crash when posts move between pages
|
||||
|
||||
### Changed
|
||||
|
||||
- Widget update is more reliable
|
||||
- Increased network timeouts
|
||||
- Better network retries and rate limit avoidance
|
||||
- Temporarily remove comment count to fix crashes
|
||||
|
||||
## [1.57.0] - 2025-12-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Navigation rewritten for larger screens
|
||||
- Comment expand/collapse now uses button UI
|
||||
- Comment rendering rewritten for better performance
|
||||
- Settings page redesigned
|
||||
|
||||
### Fixed
|
||||
|
||||
- Database concurrency improvements
|
||||
- Upgrade to Compose 2025-12 alpha
|
||||
- Swipe action trigger distance increased for Android 16 QPR1 Beta 1
|
||||
- Deeply nested comment tree collapsing
|
||||
- Post author indication in details
|
||||
- Dark mode toggle crash
|
||||
|
||||
## [1.55.0] - 2025-02-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Adapt to lobsters API change
|
||||
|
||||
## [1.54.0] - 2025-01-31
|
||||
|
||||
### Fixed
|
||||
|
||||
- Upgrade to Compose 2025-01 releases
|
||||
- Upgrade all dependencies
|
||||
|
||||
## [1.53.0] - 2024-11-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tablet users can now navigate between hottest/newest/saved posts again
|
||||
|
||||
## [1.52.0] - 2024-11-26
|
||||
|
||||
### Added
|
||||
|
||||
- A brand new tablet-specific UI — Thanks [@ThanaReka](https://github.com/@ThanaReka)
|
||||
- Usernames of story authors are highlighted in comments
|
||||
|
||||
### Fixed
|
||||
|
||||
- Add a workaround for rare crashes while loading SQLite with older devices
|
||||
- Fix comments page resetting collapsed state while scrolling
|
||||
- Images are cached for longer on disk
|
||||
- Navigation bar now renders a solid background on API levels 31 and below
|
||||
|
||||
## [1.51.0] - 2024-09-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pull to refresh indicator is no longer hiding behind the top app bar
|
||||
- User profile page correctly renders contents below the top app bar
|
||||
- Revert unread comment handling changes that caused false positives
|
||||
|
||||
## [1.50.0] - 2024-08-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Search bar draws under the status bar when hiding keyboard
|
||||
- Bottom of the comments page is cut off by the navigation bar
|
||||
- Rare crash in comments page
|
||||
|
||||
## [1.49.0] - 2024-08-29
|
||||
|
||||
### Added
|
||||
|
||||
- Opening posts you have previously seen will show the number of new comments since last visit
|
||||
|
||||
### Fixed
|
||||
|
||||
- Saving posts no longer triggers a page refresh that invalidates scroll position
|
||||
|
||||
### Changed
|
||||
|
||||
- Change submitter text to 'authored' when applicable
|
||||
- Unread comments now have a brighter background rather than a text badge
|
||||
- Bottom navigation bar has been redesigned
|
||||
|
||||
## [1.48.0] - 2024-06-05
|
||||
|
||||
### Added
|
||||
|
||||
- Swiping a post from left to right now offers a share action
|
||||
|
||||
### Fixed
|
||||
|
||||
- Try to work around an infrequent crash when scrolling hottest/newest posts
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade to Kotlin 2.0.0
|
||||
- Upgrade to Compose May beta releases
|
||||
|
||||
## [1.47.0] - 2024-05-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Bring back dividers between posts (I regret my earlier choices)
|
||||
- Upgrade to Compose May stable releases
|
||||
|
||||
### Fixed
|
||||
|
||||
- Navigating to user profiles now works when invoked from the search
|
||||
results page
|
||||
- Fix occasional crashes due to the app incorrectly trying to open
|
||||
multiple database connections
|
||||
|
||||
## [1.46.0] - 2024-04-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove dividers between posts
|
||||
- Post titles in lists are now truncated to be a single line
|
||||
|
||||
### Fixed
|
||||
|
||||
- Disable logging of network errors to Sentry
|
||||
- Add potential workaround for navigation-related crashes
|
||||
|
||||
## [1.45.0] - 2024-04-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade to Compose April releases
|
||||
- Story items are now more compact so you can see more items
|
||||
on your screen.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a crash when clicking an item on the bottom navigation bar
|
||||
too quickly
|
||||
- Removed buggy deep links
|
||||
- Clicking a username now correctly navigates to the right page in-app
|
||||
|
||||
## [1.44.0] - 2024-03-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a bug in the database code that was triggering crashes for
|
||||
a subset of upgrading users
|
||||
|
||||
## [1.43.0] - 2024-03-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix crash when trying to open a comments-only post from
|
||||
the home screen widget
|
||||
|
||||
## [1.42.0] - 2024-03-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Adapt to changes in lobste.rs API
|
||||
|
||||
## [1.41.0] - 2024-03-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Downgrade dependency that was pulling in extraneous storage
|
||||
permissions which the app does not require.
|
||||
|
||||
## [1.40.0] - 2024-03-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Update to Compose March releases
|
||||
- Improve performance of frequently invoked database queries
|
||||
- Refactor UI data model to improve state handling
|
||||
- Make saved posts update in the background more often
|
||||
- Consolidate widget display logic
|
||||
- Rework some theming logic to align with new Material guidelines
|
||||
|
||||
## [1.39.0] - 2024-01-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Add HTML bookmarks as an export format
|
||||
- Rework widget item layout for consistent touch targets
|
||||
- Add attribution for libraries used in the app
|
||||
- Redesign settings screen
|
||||
- Update to Compose December releases
|
||||
|
||||
## [1.38.0] - 2023-11-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Add brand new icon by dzuk
|
||||
- Fix a bug that caused the app to fetch the same 20 posts over and over
|
||||
- Significantly improve scrolling performance when there are a lot of saved posts
|
||||
- Move search feature to its own screen
|
||||
- Make top app and bottom system bars use the same color
|
||||
- Reduce unnecessary API calls in search screen
|
||||
- Upgrade to Compose November releases
|
||||
- Enable logging of SQLite queries
|
||||
|
||||
## [1.37.0] - 2023-10-06
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a crash that infrequently happened in the comments page
|
||||
- Fixed a crash when trying to list saved posts
|
||||
- Fixed a crash in home screen widget when user had less than 50 saved posts
|
||||
- Upgraded to Compose 1.6.0-alpha07
|
||||
|
||||
## [1.36.0] - 2023-10-04
|
||||
|
||||
### Added
|
||||
|
||||
- Introduce an initial attempt at a home screen widget
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a crash triggered when swiping items
|
||||
- Adjusted pull to refresh component to match Material You theme
|
||||
|
||||
## [1.35.0] - 2023-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed missing vertical spacing between post tags
|
||||
- Fixed user profile links not displaying correctly in-app
|
||||
- Update to Compose October release
|
||||
|
||||
## [1.34.0] - 2023-08-30
|
||||
|
||||
### Added
|
||||
|
||||
- Add a swipe action on each story to open the comments page on `lobste.rs`
|
||||
- Automatically mark posts as "read" and visually distinguish between them
|
||||
|
||||
### Changed
|
||||
|
||||
- Tweak list items to reduce vertical size and adjust colors
|
||||
- Drop bogus workarounds for native library crashes
|
||||
- Updated Jetpack Compose and SQLite
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rework how comments are displayed to adapt to lobste.rs API change
|
||||
|
||||
## [1.33.0] - 2023-07-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Yet another attempt at fixing native library crashes
|
||||
- Rework how baseline profiles are generated
|
||||
|
||||
## [1.32.0] - 2023-07-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade to AndroidX July 26 release
|
||||
- Revert selectable text feature due to sporadic crashes
|
||||
|
||||
## [1.31.0] - 2023-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added the ability to search for posts
|
||||
- Text in the comments page is now selectable
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade to Compose July release
|
||||
- Upgrade to Kotlin 1.9.0
|
||||
|
||||
## [1.30.0] - 2023-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Added another workaround for native library loading crash
|
||||
|
||||
## [1.29.0] - 2023-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- Backup and restore options for saved posts
|
||||
|
||||
### Fixed
|
||||
|
||||
- Spamming the comments button no longer causes it to be opened multiple times
|
||||
- Saved posts screen now has a visual indication when you have nothing saved
|
||||
- Iconography has been updated across the board to be more consistent
|
||||
|
||||
## [1.28.0] - 2023-06-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Navigation transitions have been slightly sped up
|
||||
|
||||
### Fixed
|
||||
|
||||
- Add workaround for a native library loading crash observed on some devices
|
||||
|
||||
## [1.27.0] - 2023-05-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Small accessibility improvements
|
||||
- Slightly tweak the layout for story items to take less vertical space
|
||||
- Upgrade to Compose `1.5.0-beta01` release
|
||||
- Set accessibility web URI for profile screen
|
||||
- Directly use Material Icons from upstream artifacts
|
||||
- Upgrade dependencies
|
||||
- Add adaptive navigation support (thanks @Yash-Garg)
|
||||
- Use latest SQLite for backing databases
|
||||
|
||||
## [1.26.0] - 2023-05-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade to Compose May release
|
||||
- Disable Sentry performance reporting
|
||||
- Migrate to Compose Foundation `FlowRow` in `LobstersCard`
|
||||
|
||||
## [1.25.0] - 2023-04-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Make the app draw edge to edge
|
||||
- Simplify Top App Bar color scheme
|
||||
- Rework how baseline profiles are generated
|
||||
- Upgrade to Compose April release
|
||||
|
||||
## [1.24.0] - 2023-03-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Rebuild app icon assets to align better with Material guidelines
|
||||
- Adopt Slack's Compose lints and address issues found by it
|
||||
- Upgrade to OkHttp 4.x
|
||||
- Upgrade to Compose March release
|
||||
- Start work on support for logging in with lobste.rs
|
||||
|
||||
## [1.23.0] - 2023-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- Introduce [Sentry](https://sentry.io) for error reporting and performance monitoring
|
||||
|
||||
## [1.22.0] - 2023-03-02
|
||||
|
||||
### Changed
|
||||
|
||||
- In-app browser now respects the user's choice for dark mode
|
||||
- Upgrade dependencies
|
||||
|
||||
## [1.21.0] - 2023-02-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Fix bug that caused the app to crash on launch
|
||||
|
||||
## [1.20.0] - 2023-02-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade dependencies
|
||||
|
||||
## [1.19.0] - 2023-02-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade dependencies
|
||||
- Optimize packaged baseline profiles
|
||||
|
||||
## [1.18.0] - 2023-01-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Special-case deleted stories in comments API
|
||||
- Fix regression where save button had the incorrect visual state
|
||||
|
||||
## [1.17.0] - 2023-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- The app will now mark new comments as unread when returning to posts
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade to Kotlin 1.8.0
|
||||
- Revert questionable app bar scrolling behaviour change
|
||||
|
||||
## [1.16.0] - 2022-12-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Fix bug where the newest posts tab actually showed the hottest posts instead
|
||||
|
||||
## [1.15.0] - 2022-12-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Fix a case where collapsing the parent of an already collapsed comment caused it to become expanded
|
||||
- Tweak progress bar UI to use a linear indicator
|
||||
- Allow app bar to hide when scrolling down
|
||||
- Fix bug where refreshing a list of posts caused it to jump around multiple times
|
||||
|
||||
## [1.14.0] - 2022-12-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Rework how often saved posts are updated
|
||||
- Collapsing a comment now collapses all comments under it similar to how it works on the website
|
||||
|
||||
## [1.13.0] - 2022-12-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Fix crash when viewing comments by ~jcs
|
||||
|
||||
## [1.12.0] - 2022-12-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Do not schedule post update job every time the app starts
|
||||
- Improve favicon loading to reduce unnecessary redraws
|
||||
- Fix post lists being reloaded unnecessarily
|
||||
|
||||
## [1.11.0] - 2022-12-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade to Compose 2022.12.00 release
|
||||
- Refresh packaged baseline profile
|
||||
- Improve how saved state of posts is queried
|
||||
|
||||
## [1.10.0] - 2022-11-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Configure Coil image loader with better caching settings
|
||||
- Refresh packaged baseline profile
|
||||
|
||||
## [1.9.0] - 2022-11-28
|
||||
|
||||
### Changed
|
||||
|
||||
- A loading indicator is added to the bottom of the post list to identify when new posts are being fetched
|
||||
|
||||
## [1.8.0] - 2022-11-21
|
||||
|
||||
### Changed
|
||||
|
||||
- The release pipeline now automatically generates Play Store release notes from the changelog file
|
||||
|
||||
### Fixed
|
||||
|
||||
- Downgrade AGP to 8.0.0-alpha07 to fix Baseline Profiles not being packaged
|
||||
|
||||
## [1.7.0] - 2022-11-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Switch to Compose Material's swipe refresh feature
|
||||
- Change metadata extractor logic to no longer prefer canonical URLs. The submitted URL will be retained as-is now.
|
||||
- Remove navigation transition animations
|
||||
|
||||
## [1.6.0] - 2022-11-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrate dependency injection from Hilt to Anvil + Whetstone
|
||||
|
||||
## [1.5.0] - 2022-11-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade to Compose 2022.11.00 release
|
||||
|
||||
## [1.4.0] - 2022-11-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Misc fixes to link metadata extractor
|
||||
|
||||
## [1.3.0] - 2022-10-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Prevent errors during link metadata retrieval from crashing the app
|
||||
- Update Jetpack libraries to 2022-10-24 release
|
||||
|
||||
## [1.2.0] - 2022-10-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Lazily load link metadata to improve comment page loading speed
|
||||
- Upgrade to latest Compose release
|
||||
- Refactor code to align with Twitter's public Compose guidelines
|
||||
|
||||
## [1.1.0] - 2022-09-30
|
||||
|
||||
### Added
|
||||
|
||||
- Add score and relative time to comments
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Jetpack Compose
|
||||
- Declare data backup and transfer rules
|
||||
- Improve legibility of links in comments and user profiles
|
||||
- Commonize and improve how network errors are displayed
|
||||
|
||||
## [1.0.0] - 2022-09-20
|
||||
|
||||
- Initial Play Store release
|
||||
|
||||
[Unreleased]: https://github.com/msfjarvis/compose-lobsters/compare/v1.68.0...HEAD
|
||||
[1.68.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.67.0...v1.68.0
|
||||
[1.67.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.66.0...v1.67.0
|
||||
[1.66.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.65.0...v1.66.0
|
||||
[1.65.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.64.0...v1.65.0
|
||||
[1.64.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.63.0...v1.64.0
|
||||
[1.63.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.62.0...v1.63.0
|
||||
[1.62.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.61.0...v1.62.0
|
||||
[1.61.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.60.0...v1.61.0
|
||||
[1.60.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.59.0...v1.60.0
|
||||
[1.59.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.58.0...v1.59.0
|
||||
[1.58.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.57.0...v1.58.0
|
||||
[1.57.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.55.0...v1.57.0
|
||||
[1.55.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.54.0...v1.55.0
|
||||
[1.54.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.53.0...v1.54.0
|
||||
[1.53.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.52.0...v1.53.0
|
||||
[1.52.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.51.0...v1.52.0
|
||||
[1.51.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.50.0...v1.51.0
|
||||
[1.50.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.49.0...v1.50.0
|
||||
[1.49.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.48.0...v1.49.0
|
||||
[1.48.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.47.0...v1.48.0
|
||||
[1.47.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.46.0...v1.47.0
|
||||
[1.46.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.45.0...v1.46.0
|
||||
[1.45.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.44.0...v1.45.0
|
||||
[1.44.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.43.0...v1.44.0
|
||||
[1.43.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.42.0...v1.43.0
|
||||
[1.42.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.41.0...v1.42.0
|
||||
[1.41.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.40.0...v1.41.0
|
||||
[1.40.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.39.0...v1.40.0
|
||||
[1.39.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.38.0...v1.39.0
|
||||
[1.38.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.37.0...v1.38.0
|
||||
[1.37.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.36.0...v1.37.0
|
||||
[1.36.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.35.0...v1.36.0
|
||||
[1.35.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.34.0...v1.35.0
|
||||
[1.34.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.33.0...v1.34.0
|
||||
[1.33.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.32.0...v1.33.0
|
||||
[1.32.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.31.0...v1.32.0
|
||||
[1.31.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.30.0...v1.31.0
|
||||
[1.30.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.29.0...v1.30.0
|
||||
[1.29.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.28.0...v1.29.0
|
||||
[1.28.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.27.0...v1.28.0
|
||||
[1.27.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.26.0...v1.27.0
|
||||
[1.26.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.25.0...v1.26.0
|
||||
[1.25.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.24.0...v1.25.0
|
||||
[1.24.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.23.0...v1.24.0
|
||||
[1.23.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.22.0...v1.23.0
|
||||
[1.22.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.21.0...v1.22.0
|
||||
[1.21.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.20.0...v1.21.0
|
||||
[1.20.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.19.0...v1.20.0
|
||||
[1.19.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.18.0...v1.19.0
|
||||
[1.18.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.17.0...v1.18.0
|
||||
[1.17.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.16.0...v1.17.0
|
||||
[1.16.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.15.0...v1.16.0
|
||||
[1.15.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.14.0...v1.15.0
|
||||
[1.14.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.13.0...v1.14.0
|
||||
[1.13.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.12.0...v1.13.0
|
||||
[1.12.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.11.0...v1.12.0
|
||||
[1.11.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.10.0...v1.11.0
|
||||
[1.10.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.9.0...v1.10.0
|
||||
[1.9.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.8.0...v1.9.0
|
||||
[1.8.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.7.0...1.8.0
|
||||
[1.7.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.6.0...v1.7.0
|
||||
[1.6.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.5.0...v1.6.0
|
||||
[1.5.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.4.0...v1.5.0
|
||||
[1.4.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.3.0...v1.4.0
|
||||
[1.3.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.2.0...v1.3.0
|
||||
[1.2.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.1.0...v1.2.0
|
||||
[1.1.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.0.0...v1.1.0
|
||||
[1.0.0]: https://github.com/msfjarvis/compose-lobsters/compare/29c374859b17c5fcef03585b8a01c00070de9097...v1.0.0
|
||||
@@ -0,0 +1,17 @@
|
||||
# Hacking on compose-lobsters
|
||||
|
||||
This app is built as a very lightweight frontend for [lobste.rs](https://lobste.rs), mainly as an experiment in using [Jetpack Compose](https://d.android.com/jetpack/compose).
|
||||
|
||||
The codebase is fairly simple, and the package structure follows [Buffer's](https://buffer.com/resources/android-rethinking-package-structure/).
|
||||
|
||||
## Subproject breakdown
|
||||
|
||||
- `:api`: Retrofit-backed interface to the [lobste.rs] API
|
||||
|
||||
- `:app`: The guts of the Android client
|
||||
|
||||
- `:common`: Contains the abstractions we use for sharing strings and browser handling across desktop and mobile
|
||||
|
||||
- `:database`: Database layer implemented using SQLDelight to persist saved posts
|
||||
|
||||
- `:desktop`: Proof-of-concept desktop client that needs significantly more love than it has received so far
|
||||
@@ -1,23 +0,0 @@
|
||||
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.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Privacy policy for Claw
|
||||
|
||||
The app does not collect any information about you and thus does not share anything with me or any other third-party. The only difference between using this app versus the [lobste.rs website](https://lobste.rs/) is the `User-Agent` header which is set to `Claw/$VERSION_NAME/msfjarvis` in the app.
|
||||
@@ -1,37 +1,39 @@
|
||||
# Claw for [lobste.rs](https://lobste.rs) [](https://github.com/msfjarvis/compose-lobsters/actions/workflows/ci.yml)
|
||||
# Claw for [lobste.rs](https://lobste.rs)
|
||||
|
||||
Unofficial Android app for the [lobste.rs](https://lobste.rs) linkblogging community, built with [Jetpack Compose](https://developer.android.com/jetpack/compose).
|
||||
Unofficial Android app for read-only access to [lobste.rs](https://lobste.rs) community, built with [Jetpack Compose](https://developer.android.com/jetpack/compose).
|
||||
|
||||
## Features
|
||||
Requires Android Studio 2020.3.1 Arctic Fox to work.
|
||||
|
||||
- Browse posts in the frontpage (Hottest) and Newest feeds
|
||||
- Save posts for future reading in-app
|
||||
- Upvote and reply to comments
|
||||
- Share post URLs
|
||||
## Distribution
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=dev.msfjarvis.claw.android">
|
||||
<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"
|
||||
alt="Get it on Google Play"
|
||||
height="80" />
|
||||
</a>
|
||||
The app will be published to Google Play soon, in the mean time snapshots from the development branch are available [here](https://dl.msfjarvis.dev/Claw)
|
||||
|
||||
<a href="https://android.izzysoft.de/repo/apk/dev.msfjarvis.claw.android">
|
||||
<img src="./.github/izzy-badge.webp"
|
||||
alt="Get it on IzzyOnDroid"
|
||||
height="80" />
|
||||
</a>
|
||||
## Licence
|
||||
|
||||
> [!NOTE]
|
||||
> Daily builds are published to the Google Play 'Open Testing' channel (if there are any changes), which can be opted into via this link: [[Android]](https://play.google.com/store/apps/details?id=dev.msfjarvis.claw.android) [[Web]](https://play.google.com/apps/testing/dev.msfjarvis.claw.android)
|
||||
```
|
||||
Copyright (c) 2020 Harsh Shandilya
|
||||
|
||||
<img src="https://github.com/msfjarvis/compose-lobsters/blob/main/.github/readme_feature_light.webp#gh-light-mode-only"
|
||||
alt="A grid of screenshots from the app, in clockwise order: the main screen, the comments page, the search page and the saved posts page"
|
||||
width="550" />
|
||||
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:
|
||||
|
||||
<img src="https://github.com/msfjarvis/compose-lobsters/blob/main/.github/readme_feature_dark.webp#gh-dark-mode-only"
|
||||
alt="A grid of screenshots from the app, in clockwise order: the main screen, the comments page, the search page and the saved posts page"
|
||||
width="550" />
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE)
|
||||
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.
|
||||
```
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/apk/
|
||||
/bundle/
|
||||
@@ -1,264 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
@file:OptIn(ExperimentalTime::class)
|
||||
|
||||
import dev.msfjarvis.claw.gradle.addTestDependencies
|
||||
import dev.zacsweers.metro.gradle.DiagnosticSeverity
|
||||
import groovy.json.JsonOutput
|
||||
import groovy.json.JsonSlurper
|
||||
import java.io.File
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
plugins {
|
||||
id("dev.msfjarvis.claw.android-application")
|
||||
id("dev.msfjarvis.claw.rename-artifacts")
|
||||
id("dev.msfjarvis.claw.kotlin-android")
|
||||
id("dev.msfjarvis.claw.sentry")
|
||||
id("dev.msfjarvis.claw.versioning-plugin")
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.android.junit5)
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
alias(libs.plugins.modulegraphassert)
|
||||
alias(libs.plugins.baselineprofile)
|
||||
alias(libs.plugins.licensee)
|
||||
alias(libs.plugins.screenshot)
|
||||
alias(libs.plugins.kotlin.composeCompiler)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.dependencyAnalysis)
|
||||
alias(libs.plugins.metro)
|
||||
alias(libs.plugins.zipline)
|
||||
}
|
||||
|
||||
val ziplineDevManifestUrl =
|
||||
providers
|
||||
.gradleProperty("claw.ziplineParserManifestUrl")
|
||||
.orElse("http://10.0.2.2:8080/manifest.zipline.json")
|
||||
.get()
|
||||
val ziplineProdManifestUrl = "https://claw.msfjarvis.dev/current/v2/manifest.zipline.json"
|
||||
val embeddedZiplineAssetsRoot = layout.buildDirectory.dir("generated/ziplineEmbeddedAssets")
|
||||
val embeddedZiplineAssetsRootFile =
|
||||
layout.buildDirectory.get().dir("generated/ziplineEmbeddedAssets").asFile
|
||||
val prepareEmbeddedZiplineParser =
|
||||
tasks.register("prepareEmbeddedZiplineParser") {
|
||||
val sourceDir =
|
||||
rootProject.layout.projectDirectory.dir("zipline-parser/build/zipline/Production")
|
||||
val outputRoot = embeddedZiplineAssetsRoot
|
||||
inputs.dir(sourceDir)
|
||||
outputs.dir(outputRoot)
|
||||
dependsOn(":zipline-parser:compileProductionExecutableKotlinJsZipline")
|
||||
description = "Embed compiled Zipline artifacts into the app"
|
||||
|
||||
doLast {
|
||||
val outputDir = outputRoot.get().asFile
|
||||
val ziplineDir = File(outputDir, "zipline")
|
||||
ziplineDir.deleteRecursively()
|
||||
ziplineDir.mkdirs()
|
||||
|
||||
val sourceManifestFile = File(sourceDir.asFile, "manifest.zipline.json")
|
||||
check(sourceManifestFile.exists()) { "Missing Zipline manifest: $sourceManifestFile" }
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val parsed = JsonSlurper().parse(sourceManifestFile) as MutableMap<String, Any?>
|
||||
val modules = parsed["modules"] as? Map<*, *>
|
||||
check(!modules.isNullOrEmpty()) {
|
||||
"Zipline manifest contains no modules: $sourceManifestFile"
|
||||
}
|
||||
|
||||
for ((_, moduleValue) in modules) {
|
||||
val module = moduleValue as Map<*, *>
|
||||
val sourceName = module["url"] as String
|
||||
val targetName = module["sha256"] as String
|
||||
val sourceFile = File(sourceDir.asFile, sourceName)
|
||||
check(sourceFile.exists()) { "Missing Zipline module: $sourceFile" }
|
||||
sourceFile.copyTo(File(ziplineDir, targetName), overwrite = true)
|
||||
}
|
||||
|
||||
val unsigned =
|
||||
((parsed["unsigned"] as? Map<*, *>)?.toMutableMap() ?: mutableMapOf()).apply {
|
||||
put("freshAtEpochMs", Clock.System.now().toEpochMilliseconds())
|
||||
}
|
||||
parsed["unsigned"] = unsigned
|
||||
|
||||
val embeddedManifestFile = File(ziplineDir, "zipline-parser.manifest.zipline.json")
|
||||
embeddedManifestFile.writeText(JsonOutput.toJson(parsed))
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.msfjarvis.claw.android"
|
||||
defaultConfig.applicationId = "dev.msfjarvis.claw.android"
|
||||
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
buildFeatures.compose = true
|
||||
experimentalProperties["android.experimental.enableScreenshotTest"] = true
|
||||
|
||||
sourceSets.getByName("main").assets.directories.add(embeddedZiplineAssetsRootFile.path)
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
buildConfigField(
|
||||
"String",
|
||||
"ZIPLINE_PARSER_MANIFEST_URL",
|
||||
"\"$ziplineDevManifestUrl\"",
|
||||
)
|
||||
buildConfigField("boolean", "ZIPLINE_PARSER_VERIFY_SIGNATURES", "false")
|
||||
}
|
||||
getByName("release") {
|
||||
buildConfigField(
|
||||
"String",
|
||||
"ZIPLINE_PARSER_MANIFEST_URL",
|
||||
"\"$ziplineProdManifestUrl\"",
|
||||
)
|
||||
buildConfigField("boolean", "ZIPLINE_PARSER_VERIFY_SIGNATURES", "true")
|
||||
}
|
||||
create("internal") {
|
||||
matchingFallbacks += "release"
|
||||
signingConfig = signingConfigs["debug"]
|
||||
applicationIdSuffix = ".internal"
|
||||
isDebuggable = true
|
||||
buildConfigField(
|
||||
"String",
|
||||
"ZIPLINE_PARSER_MANIFEST_URL",
|
||||
"\"$ziplineProdManifestUrl\"",
|
||||
)
|
||||
buildConfigField("boolean", "ZIPLINE_PARSER_VERIFY_SIGNATURES", "true")
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
managedDevices {
|
||||
localDevices {
|
||||
create("pixel9api36") {
|
||||
device = "Pixel 9"
|
||||
apiLevel = 36
|
||||
systemImageSource = "aosp"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("preBuild").configure { dependsOn(prepareEmbeddedZiplineParser) }
|
||||
|
||||
aboutLibraries.collect.gitHubApiToken = providers.environmentVariable("GITHUB_TOKEN").orNull
|
||||
|
||||
baselineProfile {
|
||||
mergeIntoMain = true
|
||||
saveInSrc = true
|
||||
}
|
||||
|
||||
licensee {
|
||||
allow("Apache-2.0")
|
||||
allow("MIT")
|
||||
allow("BSD-3-Clause")
|
||||
ignoreDependencies("com.michael-bull.kotlin-result") { because("kotlin-result is ISC licensed") }
|
||||
ignoreDependencies("org.commonmark") { because("Commonmark is BSD licensed") }
|
||||
allowUrl("https://jsoup.org/license") { because("Jsoup is MIT licensed") }
|
||||
allowUrl("https://opensource.org/license/MIT") { because("Ksoup is MIT licensed") }
|
||||
allowUrl("https://github.com/Osmerion/sqlite-android/blob/master/LICENSE") {
|
||||
because("sqlite-android is MIT licensed")
|
||||
}
|
||||
}
|
||||
|
||||
metro { unusedGraphInputsSeverity = DiagnosticSeverity.ERROR }
|
||||
|
||||
moduleGraphAssert {
|
||||
assertOnAnyBuild = true
|
||||
maxHeight = 4
|
||||
restricted = arrayOf(":core -X> :.*")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
baselineProfile(projects.benchmark)
|
||||
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.aboutLibraries.compose.core)
|
||||
implementation(libs.aboutLibraries.core)
|
||||
implementation(libs.aboutLibraries.m3)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.animation)
|
||||
implementation(libs.androidx.compose.animation.core)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.compose.foundation.layout)
|
||||
implementation(libs.androidx.compose.glance)
|
||||
implementation(libs.androidx.compose.glance.core)
|
||||
implementation(libs.androidx.compose.glance.m3)
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.adaptive)
|
||||
implementation(libs.androidx.compose.material3.adaptive.layout)
|
||||
implementation(libs.androidx.compose.material3.window.size)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
implementation(libs.androidx.compose.runtime.saveable)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.text)
|
||||
implementation(libs.androidx.compose.ui.unit)
|
||||
implementation(libs.androidx.annotation)
|
||||
implementation(libs.androidx.collection)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.lifecycle.common)
|
||||
implementation(libs.androidx.lifecycle.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel)
|
||||
implementation(libs.androidx.material3.navigation3)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.paging.common)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.coil3.compose)
|
||||
implementation(libs.coil3.core)
|
||||
implementation(libs.eithernet)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.blur)
|
||||
implementation(libs.kotlin.parcelize.runtime)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.serialization.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.metrox.android)
|
||||
implementation(libs.metrox.viewmodel)
|
||||
implementation(libs.metrox.viewmodel.compose)
|
||||
implementation(libs.okhttp.core)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.sentry)
|
||||
implementation(libs.sentry.android.core)
|
||||
implementation(libs.sqldelight.extensions.coroutines)
|
||||
implementation(libs.sqldelight.runtime)
|
||||
implementation(libs.swipe)
|
||||
implementation(libs.unfurl)
|
||||
implementation(libs.zipline)
|
||||
implementation(libs.zipline.loader)
|
||||
implementation(projects.api)
|
||||
implementation(projects.common)
|
||||
implementation(projects.core)
|
||||
implementation(projects.database.core)
|
||||
implementation(projects.database.impl)
|
||||
implementation(projects.model)
|
||||
|
||||
compileOnly(libs.androidx.compose.glance.preview)
|
||||
|
||||
runtimeOnly(libs.androidx.profileinstaller)
|
||||
|
||||
screenshotTestImplementation(libs.screenshot.validation.api)
|
||||
screenshotTestImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
|
||||
addTestDependencies(project)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 9.4.0-alpha03" type="baseline" client="gradle" dependencies="true" name="AGP (9.4.0-alpha03)" variant="all" version="9.4.0-alpha03">
|
||||
|
||||
<issue
|
||||
id="Instantiatable"
|
||||
message="This class should provide a default constructor (a public constructor with no arguments) (`dev.msfjarvis.claw.android.MainActivity`)"
|
||||
errorLine1=" android:name=".MainActivity""
|
||||
errorLine2=" ~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="28"
|
||||
column="21"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DeprecatedCall"
|
||||
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
|
||||
errorLine1=" webViewRef?.loadUrl(LOGIN_URL)"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
|
||||
line="69"
|
||||
column="15"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DeprecatedCall"
|
||||
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
|
||||
errorLine1=" webViewRef?.loadUrl(LOGIN_URL)"
|
||||
errorLine2=" ~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
|
||||
line="69"
|
||||
column="35"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DeprecatedCall"
|
||||
message="android.webkit.WebView.WebView is deprecated; consider using an alternative."
|
||||
errorLine1=" WebView(context)"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
|
||||
line="78"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DeprecatedCall"
|
||||
message="android.webkit.WebView.WebView is deprecated; consider using an alternative."
|
||||
errorLine1=" WebView(context)"
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
|
||||
line="78"
|
||||
column="21"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DeprecatedCall"
|
||||
message="android.webkit.WebView.getSettings is deprecated; consider using an alternative."
|
||||
errorLine1=" settings.javaScriptEnabled = true"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
|
||||
line="81"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DeprecatedCall"
|
||||
message="android.webkit.WebView.setWebViewClient is deprecated; consider using an alternative."
|
||||
errorLine1=" webViewClient ="
|
||||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
|
||||
line="82"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DeprecatedCall"
|
||||
message="android.webkit.WebView.setWebViewClient is deprecated; consider using an alternative."
|
||||
errorLine1=" object : WebViewClient() {"
|
||||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
|
||||
line="83"
|
||||
column="19"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DeprecatedCall"
|
||||
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
|
||||
errorLine1=" loadUrl(LOGIN_URL)"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
|
||||
line="118"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DeprecatedCall"
|
||||
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
|
||||
errorLine1=" loadUrl(LOGIN_URL)"
|
||||
errorLine2=" ~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
|
||||
line="118"
|
||||
column="25"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="MemberExtensionConflict"
|
||||
message="`KeepUntilTransitionsFinished` is defined both as a member in class `androidx.compose.animation.ExitTransition.Companion` and an extension in package `androidx.compose.animation`. The defined behavior for this is to use the member, but since the extension is explicitly imported into this file, there's a chance that this was not expected. (One common way this happens is for members to be added to a class after code was already written to use an extension)."
|
||||
errorLine1=" ) togetherWith ExitTransition.KeepUntilTransitionsFinished"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt"
|
||||
line="421"
|
||||
column="49"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
||||
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 42 KiB |
@@ -1,19 +0,0 @@
|
||||
-dontwarn androidx.window.extensions.WindowExtensions
|
||||
-dontwarn androidx.window.extensions.WindowExtensionsProvider
|
||||
-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation
|
||||
-dontwarn androidx.window.extensions.core.util.function.Consumer
|
||||
-dontwarn androidx.window.extensions.core.util.function.Function
|
||||
-dontwarn androidx.window.extensions.core.util.function.Predicate
|
||||
-dontwarn androidx.window.extensions.layout.DisplayFeature
|
||||
-dontwarn androidx.window.extensions.layout.FoldingFeature
|
||||
-dontwarn androidx.window.extensions.layout.WindowLayoutComponent
|
||||
-dontwarn androidx.window.extensions.layout.WindowLayoutInfo
|
||||
-dontwarn androidx.window.sidecar.SidecarDeviceState
|
||||
-dontwarn androidx.window.sidecar.SidecarDisplayFeature
|
||||
-dontwarn androidx.window.sidecar.SidecarInterface$SidecarCallback
|
||||
-dontwarn androidx.window.sidecar.SidecarInterface
|
||||
-dontwarn androidx.window.sidecar.SidecarProvider
|
||||
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo
|
||||
-dontwarn io.mcarle.konvert.api.GeneratedKonverter
|
||||
-dontwarn com.google.re2j.Matcher
|
||||
-dontwarn com.google.re2j.Pattern
|
||||
@@ -1,2 +0,0 @@
|
||||
-dontobfuscate
|
||||
-keepattributes SourceFile, LineNumberTable
|
||||
@@ -1,13 +0,0 @@
|
||||
<!--
|
||||
~ Copyright © Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Performing the heap growth analysis in process requires more heap. -->
|
||||
<application
|
||||
android:largeHeap="true"/>
|
||||
</manifest>
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.zipline
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import dev.msfjarvis.claw.android.ClawApplication
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.fail
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class AndroidZiplineParserClientTest {
|
||||
@Test
|
||||
fun ziplineLoadingSucceeds() {
|
||||
val context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
as ClawApplication
|
||||
|
||||
val parserClient = context.appGraph.ziplineClient
|
||||
|
||||
GlobalScope.launch {
|
||||
try {
|
||||
parserClient
|
||||
.service()
|
||||
.parseReplyForm(
|
||||
"""
|
||||
<div class="comment_form_container" data-shortid="">
|
||||
<form action="/comments" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="AI0414bnzi152-mE0JTWEtwq5B0ZhALBW1W7rGiG5zR-sFaJjWzdARXFM7w_DbPQqWNjzh9bufWZbXG39v5T6g" autocomplete="off">
|
||||
|
||||
<input value="znlkib" autocomplete="off" type="hidden" name="story_id" id="story_id" />
|
||||
<input value="post" autocomplete="off" type="hidden" name="_method" id="_method" />
|
||||
|
||||
|
||||
<input value="edtrox" autocomplete="off" type="hidden" name="parent_comment_short_id" id="parent_comment_short_id" />
|
||||
|
||||
|
||||
<div style="width: 100%;">
|
||||
<textarea rows="5" placeholder="" required="required" name="comment" id="comment">
|
||||
</textarea>
|
||||
|
||||
<div class="controls">
|
||||
<div class="buttons">
|
||||
<input type="submit" name="commit" value="Post" class="comment-post" data-disable-with="Post">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
.trimIndent()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
fail(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<!--
|
||||
~ Copyright © Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<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="claw-debug"
|
||||
android:host="comments" />
|
||||
</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="claw-debug"
|
||||
android:host="hottest" />
|
||||
</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="claw-debug"
|
||||
android:host="newest" />
|
||||
</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="claw-debug"
|
||||
android:host="saved" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,9 +0,0 @@
|
||||
<!--
|
||||
~ Copyright © Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Claw β</string>
|
||||
</resources>
|
||||
@@ -1,12 +0,0 @@
|
||||
<!--
|
||||
~ Copyright © Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Only allowed in debug builds -->
|
||||
<base-config cleartextTrafficPermitted="true"
|
||||
tools:ignore="InsecureBaseConfiguration">
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -1,9 +0,0 @@
|
||||
<!--
|
||||
~ Copyright © Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Claw 🤫</string>
|
||||
</resources>
|
||||
@@ -1,123 +0,0 @@
|
||||
<!--
|
||||
~ Copyright © Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:name=".ClawApplication"
|
||||
android:appComponentFactory="dev.msfjarvis.claw.android.BackupSafeAppComponentFactory"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/full_backup_content"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Claw.SplashScreen"
|
||||
tools:replace="android:appComponentFactory"
|
||||
tools:targetApi="tiramisu">
|
||||
<profileable
|
||||
android:shell="true"
|
||||
tools:targetApi="29" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="assetsPaths|colorMode|density|fontScale|fontWeightAdjustment|grammaticalGender|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|resourcesUnused|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:taskAffinity=""
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="claw" android:host="comments" android:pathPrefix="/" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="claw" android:host="hottest" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="claw" android:host="newest" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="claw" android:host="saved" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".glance.WidgetClickActivity"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.NoDisplay"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity="" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".glance.SavedPostsWidgetReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/saved_posts_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".glance.HottestPostsWidgetReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/hottest_posts_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Required: set your sentry.io project identifier (DSN) -->
|
||||
<meta-data
|
||||
android:name="io.sentry.dsn"
|
||||
android:value="${sentryDsn}" />
|
||||
<!-- enable automatic breadcrumbs for user interactions (clicks, swipes, scrolls) -->
|
||||
<meta-data
|
||||
android:name="io.sentry.traces.user-interaction.enable"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="io.sentry.breadcrumbs.user-interaction"
|
||||
android:value="true" />
|
||||
<!-- enable screenshot for crashes -->
|
||||
<meta-data
|
||||
android:name="io.sentry.attach-screenshot"
|
||||
android:value="true" />
|
||||
<!-- enable view hierarchy for crashes -->
|
||||
<meta-data
|
||||
android:name="io.sentry.attach-view-hierarchy"
|
||||
android:value="true" />
|
||||
<!-- set the environment for this build -->
|
||||
<meta-data android:name="io.sentry.environment" android:value="${sentryEnvironment}" />
|
||||
<meta-data android:name="io.sentry.auto-init" android:value="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android
|
||||
|
||||
import android.app.Application
|
||||
import dev.zacsweers.metrox.android.MetroAppComponentFactory
|
||||
|
||||
/**
|
||||
* Custom variant of [android.app.AppComponentFactory] that skips [ClawApplication] init when the
|
||||
* app is running in a backup process for Android Auto Backup. In all normal cases, it will defer to
|
||||
* [MetroAppComponentFactory].
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class BackupSafeAppComponentFactory : MetroAppComponentFactory() {
|
||||
|
||||
override fun instantiateApplicationCompat(cl: ClassLoader, className: String): Application {
|
||||
if (isBackupAgentProcess()) {
|
||||
return Application()
|
||||
}
|
||||
return super.instantiateApplicationCompat(cl, className)
|
||||
}
|
||||
|
||||
private fun isBackupAgentProcess(): Boolean {
|
||||
return try {
|
||||
val processName = Application.getProcessName()
|
||||
processName?.contains("backup") == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import dev.msfjarvis.claw.android.injection.AppGraph
|
||||
import dev.msfjarvis.claw.android.work.GeneratedWidgetPreviewUpdateWorker
|
||||
import dev.msfjarvis.claw.android.work.SavedPostUpdaterWorker
|
||||
import dev.msfjarvis.claw.android.work.TagExpirationCleanupWorker
|
||||
import dev.zacsweers.metro.createGraphFactory
|
||||
import dev.zacsweers.metrox.android.MetroAppComponentProviders
|
||||
import dev.zacsweers.metrox.android.MetroApplication
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ClawApplication : Application(), MetroApplication {
|
||||
|
||||
val appGraph by lazy { createGraphFactory<AppGraph.Factory>().create(this) }
|
||||
|
||||
override val appComponentProviders: MetroAppComponentProviders
|
||||
get() = appGraph
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
appGraph.plugins.forEach { plugin -> plugin.apply(this) }
|
||||
val postUpdateWorkRequest =
|
||||
PeriodicWorkRequestBuilder<SavedPostUpdaterWorker>(24, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
appGraph.workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = "updateSavedPosts",
|
||||
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
|
||||
request = postUpdateWorkRequest,
|
||||
)
|
||||
val tagCleanupWorkRequest =
|
||||
PeriodicWorkRequestBuilder<TagExpirationCleanupWorker>(24, TimeUnit.HOURS).build()
|
||||
appGraph.workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = "cleanupExpiredTags",
|
||||
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
|
||||
request = tagCleanupWorkRequest,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
GeneratedWidgetPreviewUpdateWorker.enqueue(appGraph.workManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dev.msfjarvis.claw.android.injection.InjectedViewModelFactory
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Comments
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Hottest
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Newest
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Saved
|
||||
import dev.msfjarvis.claw.android.ui.screens.LobstersPostsScreen
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.binding
|
||||
import dev.zacsweers.metrox.android.ActivityKey
|
||||
import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@ContributesIntoMap(AppScope::class, binding<Activity>())
|
||||
@ActivityKey
|
||||
@Inject
|
||||
class MainActivity(
|
||||
private val uriHandler: UriHandler,
|
||||
private val viewModelFactory: InjectedViewModelFactory,
|
||||
) : ComponentActivity() {
|
||||
|
||||
override val defaultViewModelProviderFactory: ViewModelProvider.Factory
|
||||
get() = viewModelFactory
|
||||
|
||||
var webUri: String? = null
|
||||
var deepLinkDestination by mutableStateOf<NavKey?>(null)
|
||||
private set
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
|
||||
// Don't set navigation bar style, the default matches the platform behavior.
|
||||
)
|
||||
handleIntent(intent)
|
||||
setContent {
|
||||
LobstersTheme(
|
||||
dynamicColor = true,
|
||||
providedValues =
|
||||
arrayOf(
|
||||
LocalUriHandler provides uriHandler,
|
||||
LocalMetroViewModelFactory provides viewModelFactory,
|
||||
),
|
||||
) {
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
LobstersPostsScreen(
|
||||
uriHandler = uriHandler,
|
||||
windowSizeClass = windowSizeClass,
|
||||
setWebUri = { url -> webUri = url },
|
||||
deepLinkDestination = deepLinkDestination,
|
||||
clearDeepLink = { deepLinkDestination = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
val data = intent.data
|
||||
if (data != null && data.scheme == BuildConfig.DEEPLINK_SCHEME) {
|
||||
when (data.host) {
|
||||
"comments" -> {
|
||||
val postId = data.pathSegments.firstOrNull()
|
||||
if (postId != null) {
|
||||
deepLinkDestination = Comments(postId)
|
||||
}
|
||||
}
|
||||
"newest" -> deepLinkDestination = Newest
|
||||
"hottest" -> deepLinkDestination = Hottest
|
||||
"saved" -> deepLinkDestination = Saved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProvideAssistContent(outContent: AssistContent?) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
val uri = webUri
|
||||
if (outContent != null) {
|
||||
if (uri != null) {
|
||||
outContent.webUri = uri.toUri()
|
||||
} else {
|
||||
outContent.webUri = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import com.slack.eithernet.ApiResult
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.android.ClawApplication
|
||||
import dev.msfjarvis.claw.android.MainActivity
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.database.local.CachedRemotePost
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.fromCachedRemotePost
|
||||
import dev.msfjarvis.claw.model.toUIPost
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class HottestPostsWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val appGraph = (context.applicationContext as ClawApplication).appGraph
|
||||
val cachedRemotePostsRepository = appGraph.cachedRemotePostsRepository
|
||||
val tagBlockRepository = appGraph.tagBlockRepository
|
||||
val filteredTags =
|
||||
try {
|
||||
tagBlockRepository.getSavedTags().first()
|
||||
} catch (_: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
val posts =
|
||||
when (val postsResult = appGraph.lobstersApi.getHottestPosts(1)) {
|
||||
is ApiResult.Success -> {
|
||||
val uiPosts = postsResult.value.map(LobstersPost::toUIPost)
|
||||
// Cache the posts for future use when network is unavailable
|
||||
// Using try-catch to prevent widget rendering failures if caching fails
|
||||
try {
|
||||
val cachedPosts = uiPosts.mapIndexed { index, post ->
|
||||
CachedRemotePost(
|
||||
shortId = post.shortId,
|
||||
title = post.title,
|
||||
url = post.url,
|
||||
createdAt = post.createdAt,
|
||||
commentCount = post.commentCount,
|
||||
commentsUrl = post.commentsUrl,
|
||||
submitterName = post.submitter,
|
||||
tags = post.tags,
|
||||
description = post.description,
|
||||
userIsAuthor = post.userIsAuthor,
|
||||
insertionOrder = index,
|
||||
)
|
||||
}
|
||||
cachedRemotePostsRepository.savePosts(cachedPosts)
|
||||
} catch (_: Exception) {
|
||||
// Silently ignore caching failures - widget should still render
|
||||
}
|
||||
uiPosts.toImmutableList()
|
||||
}
|
||||
else -> {
|
||||
// Fall back to cached posts when network fails
|
||||
cachedRemotePostsRepository
|
||||
.getCachedPosts()
|
||||
.map(UIPost::fromCachedRemotePost)
|
||||
.toImmutableList()
|
||||
}
|
||||
}
|
||||
// Filter out posts with tags that are in the filtered tags set
|
||||
val filteredPosts =
|
||||
if (filteredTags.isEmpty()) {
|
||||
posts
|
||||
} else {
|
||||
posts
|
||||
.filter { post -> post.tags.none { tag -> filteredTags.contains(tag) } }
|
||||
.toImmutableList()
|
||||
}
|
||||
provideContent { LobstersGlanceTheme { Content(context, filteredPosts) } }
|
||||
}
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) {
|
||||
provideContent {
|
||||
LobstersGlanceTheme { PreviewContent(context, generatedWidgetPreviewPosts()) }
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ComposeUnstableReceiver")
|
||||
@Composable
|
||||
private fun Content(
|
||||
context: Context,
|
||||
posts: ImmutableList<UIPost>,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
actionsEnabled: Boolean = true,
|
||||
showFooterAction: Boolean = true,
|
||||
) {
|
||||
WidgetContainer(
|
||||
"Hottest posts",
|
||||
listContent = {
|
||||
items(posts) { post ->
|
||||
Box(GlanceModifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||
WidgetPostEntry(post, actionsEnabled = actionsEnabled)
|
||||
}
|
||||
}
|
||||
if (showFooterAction) {
|
||||
item {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(16.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
GlanceModifier.background(GlanceTheme.colors.primary)
|
||||
.cornerRadius(20.dp)
|
||||
.padding(horizontal = 24.dp, vertical = 10.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
"${BuildConfig.DEEPLINK_SCHEME}://hottest".toUri(),
|
||||
)
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setClass(LocalContext.current, MainActivity::class.java)
|
||||
}
|
||||
)
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.see_more_posts),
|
||||
style =
|
||||
TextStyle(color = GlanceTheme.colors.onPrimary, textAlign = TextAlign.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("ComposeUnstableReceiver")
|
||||
@Composable
|
||||
private fun PreviewContent(
|
||||
context: Context,
|
||||
posts: ImmutableList<UIPost>,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Content(
|
||||
context = context,
|
||||
posts = posts,
|
||||
modifier = modifier,
|
||||
actionsEnabled = false,
|
||||
showFooterAction = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.binding
|
||||
import dev.zacsweers.metrox.android.BroadcastReceiverKey
|
||||
|
||||
@ContributesIntoMap(AppScope::class, binding<BroadcastReceiver>())
|
||||
@BroadcastReceiverKey
|
||||
@Inject
|
||||
class HottestPostsWidgetReceiver() : GlanceAppWidgetReceiver() {
|
||||
|
||||
override val glanceAppWidget: GlanceAppWidget
|
||||
get() = HottestPostsWidget()
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.glance.GlanceComposable
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.preview.ExperimentalGlancePreviewApi
|
||||
import androidx.glance.preview.Preview
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.android.ClawApplication
|
||||
import dev.msfjarvis.claw.android.MainActivity
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.fromSavedPost
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class SavedPostsWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val appGraph = (context.applicationContext as ClawApplication).appGraph
|
||||
val posts = appGraph.savedPostsRepository.getRecentPosts(50)
|
||||
val postWindow = posts.first().map(UIPost::fromSavedPost).toImmutableList()
|
||||
provideContent { LobstersGlanceTheme { Content(postWindow) } }
|
||||
}
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) {
|
||||
provideContent { LobstersGlanceTheme { PreviewContent(generatedWidgetPreviewPosts()) } }
|
||||
}
|
||||
|
||||
@SuppressLint("ComposeUnstableReceiver")
|
||||
@Composable
|
||||
private fun Content(
|
||||
posts: ImmutableList<UIPost>,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
actionsEnabled: Boolean = true,
|
||||
showFooterAction: Boolean = true,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
WidgetContainer(
|
||||
title = context.getString(R.string.saved_posts),
|
||||
listContent = {
|
||||
items(posts) { post ->
|
||||
Box(GlanceModifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||
WidgetPostEntry(post, actionsEnabled = actionsEnabled)
|
||||
}
|
||||
}
|
||||
if (showFooterAction) {
|
||||
item {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(16.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
GlanceModifier.background(GlanceTheme.colors.primary)
|
||||
.cornerRadius(20.dp)
|
||||
.padding(horizontal = 24.dp, vertical = 10.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://saved".toUri())
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setClass(LocalContext.current, MainActivity::class.java)
|
||||
}
|
||||
)
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.see_more_posts),
|
||||
style =
|
||||
TextStyle(color = GlanceTheme.colors.onPrimary, textAlign = TextAlign.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("ComposeUnstableReceiver")
|
||||
@Composable
|
||||
private fun PreviewContent(
|
||||
posts: ImmutableList<UIPost>,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Content(
|
||||
posts = posts,
|
||||
modifier = modifier,
|
||||
actionsEnabled = false,
|
||||
showFooterAction = false,
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("ComposeUnstableReceiver")
|
||||
@OptIn(ExperimentalGlancePreviewApi::class)
|
||||
@Preview
|
||||
@GlanceComposable
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
LobstersGlanceTheme { PreviewContent(generatedWidgetPreviewPosts()) }
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.binding
|
||||
import dev.zacsweers.metrox.android.BroadcastReceiverKey
|
||||
|
||||
@ContributesIntoMap(AppScope::class, binding<BroadcastReceiver>())
|
||||
@BroadcastReceiverKey
|
||||
@Inject
|
||||
class SavedPostsWidgetReceiver() : GlanceAppWidgetReceiver() {
|
||||
|
||||
override val glanceAppWidget: GlanceAppWidget
|
||||
get() = SavedPostsWidget()
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.net.toUri
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.android.MainActivity
|
||||
import io.sentry.Breadcrumb
|
||||
import io.sentry.Sentry
|
||||
import io.sentry.SentryLevel
|
||||
|
||||
/**
|
||||
* Trampoline activity for handling widget clicks with explicit components.
|
||||
*
|
||||
* This activity is required to fix COMPOSE-LOBSTERS-9X/9P/8M. Glance's LazyColumn requires all
|
||||
* actionStartActivity intents to have an explicit component set. For external URLs, we route
|
||||
* through this activity which then launches the appropriate intent (either internal deep link or
|
||||
* external browser).
|
||||
*
|
||||
* @see:
|
||||
* https://developer.android.com/reference/kotlin/androidx/glance/appwidget/action/package-summary#actionStartActivity(android.content.Intent,androidx.glance.action.ActionParameters)
|
||||
*/
|
||||
class WidgetClickActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val url = intent.getStringExtra(EXTRA_URL)
|
||||
val shortId = intent.getStringExtra(EXTRA_SHORT_ID)
|
||||
val action = intent.action
|
||||
|
||||
when {
|
||||
// Handle comments deep link
|
||||
action == ACTION_OPEN_COMMENTS && shortId != null -> {
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb().apply {
|
||||
category = "widget_click_debug"
|
||||
message = "Comment deeplink"
|
||||
level = SentryLevel.INFO
|
||||
}
|
||||
)
|
||||
val commentsIntent =
|
||||
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://comments/$shortId".toUri())
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setClass(this@WidgetClickActivity, MainActivity::class.java)
|
||||
}
|
||||
startActivity(commentsIntent)
|
||||
}
|
||||
// Handle external URL
|
||||
url != null && (url.startsWith("http://") || url.startsWith("https://")) -> {
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb().apply {
|
||||
category = "widget_click_debug"
|
||||
message = "External URL"
|
||||
level = SentryLevel.INFO
|
||||
}
|
||||
)
|
||||
val urlIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(urlIntent)
|
||||
}
|
||||
// Handle internal/relative URL as comments link
|
||||
url != null && (url.startsWith('/') || url.isEmpty()) -> {
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb().apply {
|
||||
category = "widget_click_debug"
|
||||
message = "Relative URL, parsed as a comment link"
|
||||
level = SentryLevel.INFO
|
||||
}
|
||||
)
|
||||
// For internal URLs without shortId, extract it from the URL or use comments action
|
||||
val commentsIntent =
|
||||
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://comments/$shortId".toUri())
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setClass(this@WidgetClickActivity, MainActivity::class.java)
|
||||
}
|
||||
startActivity(commentsIntent)
|
||||
}
|
||||
}
|
||||
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_OPEN_COMMENTS = "dev.msfjarvis.claw.OPEN_COMMENTS"
|
||||
const val EXTRA_URL = "dev.msfjarvis.claw.EXTRA_URL"
|
||||
const val EXTRA_SHORT_ID = "dev.msfjarvis.claw.EXTRA_SHORT_ID"
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceComposable
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.preview.ExperimentalGlancePreviewApi
|
||||
import androidx.glance.preview.Preview
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
|
||||
@SuppressLint("ArgInFormattedQuantityStringRes")
|
||||
@Composable
|
||||
@GlanceComposable
|
||||
fun WidgetPostEntry(
|
||||
post: UIPost,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
actionsEnabled: Boolean = true,
|
||||
) {
|
||||
val titleStyle = MaterialTheme.typography.bodyMedium
|
||||
val context = LocalContext.current
|
||||
val commentsAction =
|
||||
if (actionsEnabled) {
|
||||
actionStartActivity(
|
||||
Intent(context, WidgetClickActivity::class.java).apply {
|
||||
action = WidgetClickActivity.ACTION_OPEN_COMMENTS
|
||||
putExtra(WidgetClickActivity.EXTRA_SHORT_ID, post.shortId)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val postAction =
|
||||
if (!actionsEnabled) {
|
||||
null
|
||||
} else if (post.url.startsWith('/') || post.url.isEmpty()) {
|
||||
commentsAction
|
||||
} else {
|
||||
actionStartActivity(
|
||||
Intent(context, WidgetClickActivity::class.java).apply {
|
||||
putExtra(WidgetClickActivity.EXTRA_URL, post.url)
|
||||
putExtra(WidgetClickActivity.EXTRA_SHORT_ID, post.shortId)
|
||||
}
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
modifier.fillMaxWidth().cornerRadius(8.dp).background(GlanceTheme.colors.secondaryContainer),
|
||||
) {
|
||||
val titleModifier =
|
||||
GlanceModifier.defaultWeight()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.padding(start = 4.dp)
|
||||
val commentsModifier =
|
||||
GlanceModifier.padding(horizontal = 8.dp, vertical = 4.dp).padding(horizontal = 4.dp)
|
||||
Text(
|
||||
text = post.title,
|
||||
modifier = if (postAction != null) titleModifier.clickable(postAction) else titleModifier,
|
||||
maxLines = 1,
|
||||
style =
|
||||
TextStyle(color = GlanceTheme.colors.onSecondaryContainer, fontSize = titleStyle.fontSize),
|
||||
)
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_comment),
|
||||
contentDescription =
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.comments,
|
||||
post.commentCount,
|
||||
post.commentCount,
|
||||
),
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSecondaryContainer),
|
||||
modifier =
|
||||
if (commentsAction != null) commentsModifier.clickable(commentsAction)
|
||||
else commentsModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalGlancePreviewApi::class)
|
||||
@Preview
|
||||
@GlanceComposable
|
||||
@Composable
|
||||
private fun WidgetPostEntryPreview() {
|
||||
WidgetPostEntry(generatedWidgetPreviewPosts().first(), actionsEnabled = false)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceComposable
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.LazyListScope
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import dev.msfjarvis.claw.common.posts.TEST_POST
|
||||
import dev.msfjarvis.claw.common.theme.DarkThemeColors
|
||||
import dev.msfjarvis.claw.common.theme.LightThemeColors
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
@Composable
|
||||
fun LobstersGlanceTheme(content: @GlanceComposable @Composable () -> Unit) {
|
||||
GlanceTheme(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme.colors
|
||||
else ColorProviders(light = LightThemeColors, dark = DarkThemeColors),
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WidgetContainer(
|
||||
title: String,
|
||||
listContent: LazyListScope.() -> Unit,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Column(
|
||||
modifier.fillMaxSize().background(GlanceTheme.colors.widgetBackground).cornerRadius(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = GlanceModifier.padding(16.dp).fillMaxWidth(),
|
||||
) {
|
||||
Text(text = title, style = TextStyle(fontSize = 18.sp, color = GlanceTheme.colors.onSurface))
|
||||
}
|
||||
LazyColumn(content = listContent)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("VisibleForTests")
|
||||
fun samplePosts(
|
||||
count: Int = 5,
|
||||
mutator: UIPost.(idx: Int) -> UIPost = { this },
|
||||
): PersistentList<UIPost> {
|
||||
return buildList(count) { repeat(count) { add(TEST_POST.mutator(it)) } }.toPersistentList()
|
||||
}
|
||||
|
||||
internal fun generatedWidgetPreviewPosts(): PersistentList<UIPost> {
|
||||
return listOf(
|
||||
TEST_POST.copy(
|
||||
shortId = "widget-preview-1",
|
||||
title = "Rust 2026 roadmap",
|
||||
url = "/s/widget_preview_1",
|
||||
commentCount = 18,
|
||||
commentsUrl = "https://lobste.rs/s/widget_preview_1/rust_2026_roadmap",
|
||||
submitter = "alice",
|
||||
tags = listOf("rust", "roadmap"),
|
||||
description = "",
|
||||
),
|
||||
TEST_POST.copy(
|
||||
shortId = "widget-preview-2",
|
||||
title = "SQLite tips for mobile apps",
|
||||
url = "/s/widget_preview_2",
|
||||
commentCount = 7,
|
||||
commentsUrl = "https://lobste.rs/s/widget_preview_2/sqlite_tips_for_mobile_apps",
|
||||
submitter = "bob",
|
||||
tags = listOf("sqlite", "android"),
|
||||
description = "",
|
||||
),
|
||||
)
|
||||
.toPersistentList()
|
||||
}
|
||||
|
||||
internal fun generatedWidgetPreviewDelayMinutes(): Long = 15L
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkManager
|
||||
import dev.msfjarvis.claw.android.viewmodel.CachedRemotePostsRepository
|
||||
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
|
||||
import dev.msfjarvis.claw.android.zipline.AndroidZiplineParserClient
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.common.tags.TagBlockRepository
|
||||
import dev.msfjarvis.claw.core.coroutines.MainDispatcher
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.DependencyGraph
|
||||
import dev.zacsweers.metro.Multibinds
|
||||
import dev.zacsweers.metro.Provides
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import dev.zacsweers.metrox.android.MetroAppComponentProviders
|
||||
import dev.zacsweers.metrox.viewmodel.ViewModelGraph
|
||||
import kotlin.reflect.KClass
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
@DependencyGraph(AppScope::class)
|
||||
interface AppGraph : MetroAppComponentProviders, ViewModelGraph {
|
||||
val plugins: Set<AppPlugin>
|
||||
|
||||
val workManager: WorkManager
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesWorkManager(application: Context): WorkManager {
|
||||
return WorkManager.getInstance(application)
|
||||
}
|
||||
|
||||
@Multibinds
|
||||
val workerProviders:
|
||||
Map<KClass<out ListenableWorker>, () -> InjectedWorkerFactory.WorkerInstanceFactory<*>>
|
||||
|
||||
val workerFactory: InjectedWorkerFactory
|
||||
|
||||
val savedPostsRepository: SavedPostsRepository
|
||||
|
||||
val cachedRemotePostsRepository: CachedRemotePostsRepository
|
||||
|
||||
val lobstersApi: LobstersApi
|
||||
|
||||
val tagBlockRepository: TagBlockRepository
|
||||
|
||||
val ziplineClient: AndroidZiplineParserClient
|
||||
|
||||
@MainDispatcher val mainDispatcher: CoroutineDispatcher
|
||||
|
||||
@DependencyGraph.Factory
|
||||
fun interface Factory {
|
||||
fun create(@Provides context: Context): AppGraph
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import android.app.Application
|
||||
|
||||
/** Basic plugin-based architecture for doing things at [Application] initialization. */
|
||||
interface AppPlugin {
|
||||
fun apply(application: Application)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import android.app.Application
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.disk.directory
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.crossfade
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
@Inject
|
||||
class CoilAppPlugin : AppPlugin {
|
||||
override fun apply(application: Application) {
|
||||
SingletonImageLoader.setSafe {
|
||||
ImageLoader.Builder(application)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder().maxSizePercent(application, MEMORY_CACHE_RATIO).build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(application.cacheDir.resolve("image_cache"))
|
||||
.maxSizeBytes(DISK_CACHE_MAX_SIZE)
|
||||
.build()
|
||||
}
|
||||
.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val MEMORY_CACHE_RATIO = 0.10
|
||||
private const val DISK_CACHE_MAX_SIZE = 100L * 1024 * 1024
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory
|
||||
import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory
|
||||
import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class InjectedViewModelFactory(
|
||||
override val viewModelProviders: Map<KClass<out ViewModel>, () -> ViewModel>,
|
||||
override val assistedFactoryProviders: Map<KClass<out ViewModel>, () -> ViewModelAssistedFactory>,
|
||||
override val manualAssistedFactoryProviders:
|
||||
Map<KClass<out ManualViewModelAssistedFactory>, () -> ManualViewModelAssistedFactory>,
|
||||
) : MetroViewModelFactory()
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import kotlin.collections.get
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class InjectedWorkerFactory(
|
||||
val workerProviders: Map<KClass<out ListenableWorker>, WorkerInstanceFactory<*>>
|
||||
) : WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker? {
|
||||
return workerProviders[Class.forName(workerClassName).kotlin]?.create(workerParameters)
|
||||
}
|
||||
|
||||
interface WorkerInstanceFactory<T : ListenableWorker> {
|
||||
fun create(params: WorkerParameters): T
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import android.util.Log
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import me.saket.unfurl.UnfurlLogger
|
||||
import me.saket.unfurl.Unfurler
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@BindingContainer
|
||||
@ContributesTo(AppScope::class)
|
||||
object MetadataExtractorModule {
|
||||
@Provides
|
||||
fun provideUnfurlLogger(): UnfurlLogger {
|
||||
return object : UnfurlLogger {
|
||||
override fun log(message: String) {
|
||||
Log.d("Unfurler", message)
|
||||
}
|
||||
|
||||
override fun log(e: Throwable, message: String) {
|
||||
Log.e("Unfurler", message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideUnfurler(okHttpClient: OkHttpClient, logger: UnfurlLogger): Unfurler {
|
||||
return Unfurler(httpClient = okHttpClient, logger = logger)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import android.app.Application
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.sentry.SentryOptions
|
||||
import io.sentry.TypeCheckHint.OKHTTP_REQUEST
|
||||
import io.sentry.android.core.SentryAndroid
|
||||
import okhttp3.Request
|
||||
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
@Inject
|
||||
class SentryConfigurationPlugin : AppPlugin {
|
||||
override fun apply(application: Application) {
|
||||
SentryAndroid.init(application) { options ->
|
||||
options.beforeSend = SentryOptions.BeforeSendCallback { event, hint ->
|
||||
val request = hint.getAs(OKHTTP_REQUEST, Request::class.java)
|
||||
|
||||
// Drop all OkHttp errors that are not about Lobsters specifically.
|
||||
if (request != null && !LobstersApi.BASE_URL.contains(request.url.host)) {
|
||||
null
|
||||
} else {
|
||||
event
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerFactory
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
@Inject
|
||||
class WorkManagerPlugin(private val workerFactory: WorkerFactory) : AppPlugin {
|
||||
override fun apply(application: Application) {
|
||||
WorkManager.initialize(
|
||||
application,
|
||||
Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import androidx.work.ListenableWorker
|
||||
import dev.zacsweers.metro.MapKey
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/** A [MapKey] annotation for binding Worker in a multibinding map. */
|
||||
@MapKey
|
||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class WorkerKey(val value: KClass<out ListenableWorker>)
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.network
|
||||
|
||||
import android.util.Log
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
|
||||
/** Implementation of [HttpLoggingInterceptor.Logger] backed by [Log]. */
|
||||
@ContributesBinding(AppScope::class)
|
||||
class OkHttpAndroidLogger : HttpLoggingInterceptor.Logger {
|
||||
override fun log(message: String) {
|
||||
Log.d("LobstersApi", message)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.TrafficStats
|
||||
import dev.msfjarvis.claw.core.network.DelegatingSocketFactory
|
||||
import dev.msfjarvis.claw.core.network.OkHttpClientConfigurator
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.IntoSet
|
||||
import dev.zacsweers.metro.Provides
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import java.net.Socket
|
||||
import javax.net.SocketFactory
|
||||
import okhttp3.Cache
|
||||
|
||||
@BindingContainer
|
||||
@ContributesTo(AppScope::class)
|
||||
object OkHttpAndroidModule {
|
||||
private const val CACHE_SIZE_MB = 10L * 1024 * 1024
|
||||
private const val THREAD_STATS_TAG = 0x000090000
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun provideCache(context: Context): Cache {
|
||||
return Cache(context.cacheDir, CACHE_SIZE_MB)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideSocketFactory(): SocketFactory {
|
||||
return object : DelegatingSocketFactory(SocketFactory.getDefault()) {
|
||||
override fun configureSocket(socket: Socket): Socket {
|
||||
TrafficStats.setThreadStatsTag(THREAD_STATS_TAG)
|
||||
return super.configureSocket(socket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@IntoSet
|
||||
fun provideClientConfigurator(
|
||||
cache: Cache,
|
||||
socketFactory: SocketFactory,
|
||||
): OkHttpClientConfigurator {
|
||||
return OkHttpClientConfigurator { builder ->
|
||||
builder.cache(cache)
|
||||
builder.socketFactory(socketFactory)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.slack.eithernet.ApiResult.Failure
|
||||
import com.slack.eithernet.ApiResult.Success
|
||||
import dev.msfjarvis.claw.api.toError
|
||||
import dev.msfjarvis.claw.core.coroutines.IODispatcher
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.toUIPost
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@AssistedInject
|
||||
class LobstersPagingSource(
|
||||
@Assisted private val remoteFetcher: RemoteFetcher<LobstersPost>,
|
||||
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : PagingSource<Int, UIPost>() {
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UIPost> {
|
||||
val page = params.key ?: STARTING_PAGE_INDEX
|
||||
return when (val result = withContext(ioDispatcher) { remoteFetcher.getItemsAtPage(page) }) {
|
||||
is Success ->
|
||||
LoadResult.Page(
|
||||
itemsBefore = (page - 1) * PAGE_SIZE,
|
||||
data = result.value.map(LobstersPost::toUIPost),
|
||||
prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1,
|
||||
nextKey = page + 1,
|
||||
)
|
||||
is Failure.NetworkFailure -> LoadResult.Error(result.error)
|
||||
is Failure.UnknownFailure -> LoadResult.Error(result.error)
|
||||
is Failure.HttpFailure -> LoadResult.Error(result.toError())
|
||||
is Failure.ApiFailure -> LoadResult.Error(IOException("API returned an invalid response"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, UIPost>): Int? {
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
(anchorPosition / PAGE_SIZE).coerceAtLeast(STARTING_PAGE_INDEX)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(remoteFetcher: RemoteFetcher<LobstersPost>): LobstersPagingSource
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PAGE_SIZE = 25
|
||||
const val STARTING_PAGE_INDEX = 1
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.paging
|
||||
|
||||
import com.slack.eithernet.ApiResult
|
||||
|
||||
/** SAM interface to abstract over a remote API that fetches paginated content. */
|
||||
fun interface RemoteFetcher<T> {
|
||||
suspend fun getItemsAtPage(page: Int): ApiResult<List<T>, Unit>
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.slack.eithernet.ApiResult
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
|
||||
import dev.msfjarvis.claw.api.LobstersSearchApi
|
||||
import dev.msfjarvis.claw.api.toError
|
||||
import dev.msfjarvis.claw.core.coroutines.IODispatcher
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.toUIPost
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Specialized variant of [LobstersPagingSource] to prevent hammering the Lobsters search endpoint
|
||||
* with unnecessary requests. Rather than abstract out the API call this paging source takes in the
|
||||
* relevant parameters to short-circuit when there is no query specified in order to avoid calling
|
||||
* the API at all.
|
||||
*/
|
||||
@AssistedInject
|
||||
class SearchPagingSource(
|
||||
private val searchApi: LobstersSearchApi,
|
||||
@Assisted private val queryProvider: () -> String,
|
||||
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : PagingSource<Int, UIPost>() {
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UIPost> {
|
||||
val searchQuery = queryProvider()
|
||||
// If there is no query, we don't need to call the API at all.
|
||||
if (searchQuery.isEmpty()) {
|
||||
return LoadResult.Page(itemsBefore = 0, data = emptyList(), prevKey = null, nextKey = null)
|
||||
}
|
||||
val page = params.key ?: STARTING_PAGE_INDEX
|
||||
val result = withContext(ioDispatcher) { searchApi.searchPosts(searchQuery, page) }
|
||||
return when (result) {
|
||||
is ApiResult.Success -> {
|
||||
// Optimization: prevent fetching more pages if we found no items, this means
|
||||
// there is no active search query.
|
||||
val nextKey = if (result.value.isEmpty()) null else page + 1
|
||||
LoadResult.Page(
|
||||
itemsBefore = (page - 1) * PAGE_SIZE,
|
||||
data = result.value.map(LobstersPost::toUIPost),
|
||||
prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1,
|
||||
nextKey = nextKey,
|
||||
)
|
||||
}
|
||||
is ApiResult.Failure.NetworkFailure -> LoadResult.Error(result.error)
|
||||
is ApiResult.Failure.UnknownFailure -> LoadResult.Error(result.error)
|
||||
is ApiResult.Failure.HttpFailure -> LoadResult.Error(result.toError())
|
||||
is ApiResult.Failure.ApiFailure ->
|
||||
LoadResult.Error(IOException("API returned an invalid response"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, UIPost>): Int? {
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
(anchorPosition / PAGE_SIZE).coerceAtLeast(STARTING_PAGE_INDEX)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(queryProvider: () -> String): SearchPagingSource
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.decorations
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.AppBarWithSearch
|
||||
import androidx.compose.material3.ExpandedFullScreenContainedSearchBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberSearchBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Settings
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.ui.preview.DevicePreviews
|
||||
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
|
||||
|
||||
sealed interface ClawTopBarMode {
|
||||
data object Browsing : ClawTopBarMode
|
||||
|
||||
class Searching(
|
||||
val query: String,
|
||||
val requestFocus: Boolean = true,
|
||||
) : ClawTopBarMode
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ClawAppBar(
|
||||
activity: Activity?,
|
||||
isTopLevel: Boolean,
|
||||
mode: ClawTopBarMode,
|
||||
navigateTo: (NavKey) -> Unit,
|
||||
popBackStack: () -> NavKey?,
|
||||
onStartSearch: () -> Unit,
|
||||
onDismissSearch: () -> Unit,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
when {
|
||||
!isTopLevel -> {
|
||||
TopAppBar(
|
||||
modifier = modifier.shadow(8.dp),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { if (popBackStack() == null) activity?.finish() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.go_back_to_previous_screen),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {},
|
||||
)
|
||||
}
|
||||
mode is ClawTopBarMode.Searching -> {
|
||||
val textFieldState = rememberTextFieldState()
|
||||
val searchBarState = rememberSearchBarState()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val appBarWithSearchColors =
|
||||
SearchBarDefaults.appBarWithSearchColors(
|
||||
searchBarColors = SearchBarDefaults.containedColors(state = searchBarState)
|
||||
)
|
||||
|
||||
val inputField =
|
||||
@Composable {
|
||||
SearchBarDefaults.InputField(
|
||||
textFieldState = textFieldState,
|
||||
searchBarState = searchBarState,
|
||||
onSearch = onSearch,
|
||||
colors = appBarWithSearchColors.searchBarColors.inputFieldColors,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (mode.query.isNotEmpty()) {
|
||||
onQueryChange("")
|
||||
} else {
|
||||
onDismissSearch()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.close_search),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AppBarWithSearch(
|
||||
state = searchBarState,
|
||||
colors = appBarWithSearchColors,
|
||||
inputField = inputField,
|
||||
)
|
||||
ExpandedFullScreenContainedSearchBar(
|
||||
state = searchBarState,
|
||||
inputField = inputField,
|
||||
colors = appBarWithSearchColors.searchBarColors,
|
||||
content = content,
|
||||
)
|
||||
LaunchedEffect(mode.requestFocus) {
|
||||
if (mode.requestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
TopAppBar(
|
||||
modifier = modifier.shadow(8.dp),
|
||||
navigationIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
contentDescription = stringResource(R.string.the_app_icon_for_claw),
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(text = stringResource(R.string.app_name), fontWeight = FontWeight.Bold)
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onStartSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.search_posts),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { navigateTo(Settings) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Tune,
|
||||
contentDescription = stringResource(R.string.settings),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FakeSearchResults() {
|
||||
Text(text = "Fake result content")
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@DevicePreviews
|
||||
@Composable
|
||||
private fun ClawAppBarBrowsingPreview() {
|
||||
LobstersTheme {
|
||||
ClawAppBar(
|
||||
activity = null,
|
||||
isTopLevel = true,
|
||||
mode = ClawTopBarMode.Browsing,
|
||||
navigateTo = {},
|
||||
popBackStack = { null },
|
||||
onStartSearch = {},
|
||||
onDismissSearch = {},
|
||||
onQueryChange = {},
|
||||
onSearch = {},
|
||||
content = { FakeSearchResults() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@DevicePreviews
|
||||
@Composable
|
||||
private fun ClawAppBarSearchingPreview() {
|
||||
LobstersTheme {
|
||||
ClawAppBar(
|
||||
activity = null,
|
||||
isTopLevel = true,
|
||||
mode = ClawTopBarMode.Searching(query = "compose", requestFocus = false),
|
||||
navigateTo = {},
|
||||
popBackStack = { null },
|
||||
onStartSearch = {},
|
||||
onDismissSearch = {},
|
||||
onQueryChange = {},
|
||||
onSearch = {},
|
||||
content = { FakeSearchResults() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.decorations
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.blur.HazeBlurDefaults
|
||||
import dev.chrisbanes.haze.blur.blurEffect
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
|
||||
import dev.msfjarvis.claw.common.ui.FloatingNavigationBar
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
const val AnimationDuration = 100
|
||||
|
||||
@Composable
|
||||
fun ClawNavigationBar(
|
||||
items: ImmutableList<NavigationItem>,
|
||||
currentNavKey: NavKey?,
|
||||
navigateTo: (NavKey) -> Unit,
|
||||
isVisible: Boolean,
|
||||
hazeState: HazeState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter =
|
||||
slideInVertically(
|
||||
// Enters by sliding up from offset 0 to fullHeight.
|
||||
initialOffsetY = { fullHeight -> fullHeight },
|
||||
animationSpec = tween(durationMillis = AnimationDuration, easing = LinearOutSlowInEasing),
|
||||
),
|
||||
exit =
|
||||
slideOutVertically(
|
||||
// Exits by sliding up from offset 0 to -fullHeight.
|
||||
targetOffsetY = { fullHeight -> fullHeight },
|
||||
animationSpec = tween(durationMillis = AnimationDuration, easing = FastOutLinearInEasing),
|
||||
),
|
||||
label = "",
|
||||
content = {
|
||||
val surfaceColor = MaterialTheme.colorScheme.surface
|
||||
FloatingNavigationBar(
|
||||
tonalElevation = 16.dp,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier =
|
||||
modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.navigationBarsPadding()
|
||||
.clip(MaterialTheme.shapes.extraLarge)
|
||||
.hazeEffect(hazeState) {
|
||||
blurEffect {
|
||||
backgroundColor = surfaceColor
|
||||
colorEffects = emptyList()
|
||||
blurRadius = 24.dp
|
||||
noiseFactor = 0f
|
||||
}
|
||||
},
|
||||
containerColor = if (HazeBlurDefaults.blurEnabled()) Color.Transparent else surfaceColor,
|
||||
) {
|
||||
items.forEach { navItem ->
|
||||
val isSelected = currentNavKey == navItem.navKey
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
Crossfade(isSelected, label = "nav-label") {
|
||||
Icon(
|
||||
imageVector = if (it) navItem.selectedIcon else navItem.icon,
|
||||
contentDescription = navItem.label.replaceFirstChar(Char::uppercase),
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(text = navItem.label) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (isSelected) {
|
||||
navItem.listStateResetCallback()
|
||||
} else {
|
||||
navigateTo(navItem.navKey)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag(navItem.label.uppercase()),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
class NavigationItem
|
||||
private constructor(
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
val navKey: NavKey,
|
||||
val selectedIcon: ImageVector,
|
||||
val listStateResetCallback: () -> Unit,
|
||||
) {
|
||||
constructor(
|
||||
destination: AppDestinations,
|
||||
listStateResetCallback: () -> Unit,
|
||||
) : this(
|
||||
destination.icon,
|
||||
destination.label,
|
||||
destination.navKey,
|
||||
destination.selectedIcon,
|
||||
listStateResetCallback,
|
||||
) {}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.decorations
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
fun ClawNavigationRail(
|
||||
items: ImmutableList<NavigationItem>,
|
||||
currentNavKey: NavKey?,
|
||||
navigateTo: (NavKey) -> Unit,
|
||||
isVisible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter =
|
||||
slideInHorizontally(
|
||||
// Enters by sliding in from offset -fullWidth to 0.
|
||||
initialOffsetX = { fullWidth -> -fullWidth },
|
||||
animationSpec = tween(durationMillis = AnimationDuration, easing = LinearOutSlowInEasing),
|
||||
),
|
||||
exit =
|
||||
slideOutHorizontally(
|
||||
// Exits by sliding out from offset 0 to -fullWidth.
|
||||
targetOffsetX = { fullWidth -> -fullWidth },
|
||||
animationSpec = tween(durationMillis = AnimationDuration, easing = FastOutLinearInEasing),
|
||||
),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
NavigationRail(modifier = modifier) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
items.forEach { navItem ->
|
||||
val isSelected = currentNavKey == navItem.navKey
|
||||
NavigationRailItem(
|
||||
icon = {
|
||||
Crossfade(isSelected, label = "nav-label") {
|
||||
Icon(
|
||||
imageVector = if (it) navItem.selectedIcon else navItem.icon,
|
||||
contentDescription = navItem.label.replaceFirstChar(Char::uppercase),
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(text = navItem.label) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (isSelected) {
|
||||
navItem.listStateResetCallback()
|
||||
} else {
|
||||
navigateTo(navItem.navKey)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag(navItem.label.uppercase()),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.model.LinkMetadata
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
|
||||
fun PostActions(
|
||||
context: Context,
|
||||
uriHandler: UriHandler,
|
||||
viewModel: ClawViewModel,
|
||||
navigateToComments: (String) -> Unit,
|
||||
): PostActions {
|
||||
return object : PostActions {
|
||||
override fun viewPost(postId: String, postUrl: String, commentsUrl: String) {
|
||||
viewModel.markPostAsRead(postId)
|
||||
uriHandler.openUri(postUrl.ifEmpty { commentsUrl })
|
||||
}
|
||||
|
||||
override fun viewComments(postId: String) {
|
||||
viewModel.markPostAsRead(postId)
|
||||
navigateToComments(postId)
|
||||
}
|
||||
|
||||
override fun viewCommentsPage(post: UIPost) {
|
||||
uriHandler.openUri(post.commentsUrl)
|
||||
}
|
||||
|
||||
override fun toggleSave(post: UIPost) {
|
||||
viewModel.toggleSave(post)
|
||||
}
|
||||
|
||||
override fun share(post: UIPost) {
|
||||
shareUrl(post.url.ifEmpty { post.commentsUrl }, post.title)
|
||||
}
|
||||
|
||||
override fun shareComment(commentId: String) {
|
||||
shareUrl("https://lobste.rs/c/$commentId")
|
||||
}
|
||||
|
||||
override fun isPostRead(post: UIPost): Boolean = viewModel.isPostRead(post)
|
||||
|
||||
override fun isPostSaved(post: UIPost): Boolean = viewModel.isPostSaved(post)
|
||||
|
||||
override suspend fun getLinkMetadata(url: String): LinkMetadata {
|
||||
return viewModel.getLinkMetadata(url)
|
||||
}
|
||||
|
||||
private fun shareUrl(url: String, title: String? = null) {
|
||||
val sendIntent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_TEXT, url)
|
||||
title?.let { putExtra(Intent.EXTRA_TITLE, it) }
|
||||
type = "text/plain"
|
||||
}
|
||||
context.startActivity(Intent.createChooser(sendIntent, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.lists
|
||||
|
||||
import androidx.activity.compose.ReportDrawn
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Inbox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.ui.decorations.MonthHeader
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DatabasePosts(
|
||||
items: ImmutableMap<String, List<UIPost>>,
|
||||
listState: LazyListState,
|
||||
postActions: PostActions,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ReportDrawn()
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (items.isEmpty()) {
|
||||
Column(modifier = Modifier.align(Alignment.Center)) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Inbox,
|
||||
contentDescription = stringResource(R.string.empty_inbox_icon),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(36.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.no_saved_posts),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(state = listState, contentPadding = contentPadding) {
|
||||
items.forEach { (month, posts) ->
|
||||
stickyHeader(contentType = "month-header") { MonthHeader(label = month) }
|
||||
items(items = posts, key = { it.shortId }, contentType = { "LobstersItem" }) { item ->
|
||||
val isSaved = postActions.isPostSaved(item)
|
||||
val isRead = postActions.isPostRead(item)
|
||||
LobstersListItem(
|
||||
item = item,
|
||||
isSaved = isSaved,
|
||||
isRead = isRead,
|
||||
postActions = postActions,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.lists
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Web
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.msfjarvis.claw.android.glance.samplePosts
|
||||
import dev.msfjarvis.claw.common.posts.LobstersCard
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.posts.TEST_POST_ACTIONS
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import me.saket.swipe.SwipeAction
|
||||
import me.saket.swipe.SwipeableActionsBox
|
||||
|
||||
@Composable
|
||||
fun LobstersListItem(
|
||||
item: UIPost,
|
||||
isSaved: Boolean,
|
||||
isRead: Boolean,
|
||||
postActions: PostActions,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val webAction =
|
||||
SwipeAction(
|
||||
icon = rememberVectorPainter(Icons.Filled.Web),
|
||||
background = MaterialTheme.colorScheme.tertiary,
|
||||
onSwipe = { postActions.viewCommentsPage(item) },
|
||||
)
|
||||
val shareAction =
|
||||
SwipeAction(
|
||||
icon = rememberVectorPainter(Icons.Filled.Share),
|
||||
background = MaterialTheme.colorScheme.tertiary,
|
||||
onSwipe = { postActions.share(item) },
|
||||
)
|
||||
SwipeableActionsBox(
|
||||
startActions = listOf(shareAction),
|
||||
endActions = listOf(webAction),
|
||||
swipeThreshold = 80.dp,
|
||||
backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
LobstersCard(
|
||||
post = item,
|
||||
isSaved = isSaved,
|
||||
isRead = isRead,
|
||||
postActions = postActions,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@Composable
|
||||
private fun ItemPreview() {
|
||||
LobstersTheme {
|
||||
LobstersListItem(
|
||||
item = samplePosts(1).first(),
|
||||
isSaved = true,
|
||||
isRead = true,
|
||||
postActions = TEST_POST_ACTIONS,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.lists
|
||||
|
||||
import androidx.activity.compose.ReportDrawnWhen
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemContentType
|
||||
import dev.msfjarvis.claw.android.glance.samplePosts
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.posts.TEST_POST_ACTIONS
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.ui.NetworkError
|
||||
import dev.msfjarvis.claw.common.ui.ProgressBar
|
||||
import dev.msfjarvis.claw.common.ui.preview.DevicePreviews
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NetworkPosts(
|
||||
lazyPagingItems: LazyPagingItems<UIPost>,
|
||||
listState: LazyListState,
|
||||
postActions: PostActions,
|
||||
contentPadding: PaddingValues,
|
||||
filteredTags: ImmutableSet<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ReportDrawnWhen { lazyPagingItems.itemCount > 0 }
|
||||
val refreshLoadState by rememberUpdatedState(lazyPagingItems.loadState.refresh)
|
||||
val state = rememberPullToRefreshState()
|
||||
PullToRefreshBox(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
isRefreshing = refreshLoadState == LoadState.Loading,
|
||||
state = state,
|
||||
onRefresh = { lazyPagingItems.refresh() },
|
||||
indicator = {
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = state,
|
||||
isRefreshing = refreshLoadState == LoadState.Loading,
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(contentPadding),
|
||||
)
|
||||
},
|
||||
) {
|
||||
if (lazyPagingItems.itemCount == 0 && refreshLoadState is LoadState.Error) {
|
||||
NetworkError(
|
||||
label = "Failed to load posts",
|
||||
error = (refreshLoadState as LoadState.Error).error,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(contentPadding = contentPadding, state = listState) {
|
||||
items(
|
||||
count = lazyPagingItems.itemCount,
|
||||
// Intentionally keyed on just the index to avoid crashes
|
||||
key = { index -> index },
|
||||
contentType = lazyPagingItems.itemContentType { "LobstersItem" },
|
||||
) { index ->
|
||||
val item = lazyPagingItems[index]
|
||||
if (item != null) {
|
||||
val shouldShowPost = item.tags.none { tag -> filteredTags.contains(tag) }
|
||||
if (shouldShowPost) {
|
||||
val isSaved = postActions.isPostSaved(item)
|
||||
val isRead = postActions.isPostRead(item)
|
||||
LobstersListItem(
|
||||
item = item,
|
||||
isSaved = isSaved,
|
||||
isRead = isRead,
|
||||
postActions = postActions,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lazyPagingItems.loadState.append == LoadState.Loading) {
|
||||
item(key = "progressbar") {
|
||||
ProgressBar(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.CenterHorizontally)
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DevicePreviews
|
||||
@Composable
|
||||
private fun ListPreview() {
|
||||
val items = samplePosts(20) { this.copy(shortId = "${this.shortId}${it}") }
|
||||
val flow = MutableStateFlow(PagingData.from(items))
|
||||
LobstersTheme {
|
||||
NetworkPosts(
|
||||
lazyPagingItems = flow.collectAsLazyPagingItems(),
|
||||
listState = rememberLazyListState(),
|
||||
postActions = TEST_POST_ACTIONS,
|
||||
contentPadding = PaddingValues(),
|
||||
filteredTags = persistentSetOf(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.lists
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
@Composable
|
||||
fun SearchResultsList(
|
||||
lazyPagingItems: LazyPagingItems<UIPost>,
|
||||
listState: LazyListState,
|
||||
postActions: PostActions,
|
||||
filteredTags: ImmutableSet<String>,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val refreshLoadState by rememberUpdatedState(lazyPagingItems.loadState.refresh)
|
||||
val hasNoResults = lazyPagingItems.itemCount == 0 && refreshLoadState is LoadState.NotLoading
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (hasNoResults) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_search_results),
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
} else {
|
||||
NetworkPosts(
|
||||
lazyPagingItems = lazyPagingItems,
|
||||
listState = listState,
|
||||
postActions = postActions,
|
||||
contentPadding = contentPadding,
|
||||
filteredTags = filteredTags,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.navigation
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.NewReleases
|
||||
import androidx.compose.material.icons.filled.Whatshot
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material.icons.outlined.NewReleases
|
||||
import androidx.compose.material.icons.outlined.Whatshot
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
interface TopLevelDestination
|
||||
|
||||
interface NonStackable
|
||||
|
||||
@Parcelize @Serializable data object Hottest : NavKey, TopLevelDestination, Parcelable
|
||||
|
||||
@Parcelize @Serializable data object Newest : NavKey, TopLevelDestination, Parcelable
|
||||
|
||||
@Parcelize @Serializable data object Saved : NavKey, TopLevelDestination, Parcelable
|
||||
|
||||
@Parcelize @Serializable data class Comments(val postId: String) : NavKey, Parcelable, NonStackable
|
||||
|
||||
@Parcelize @Serializable data class User(val username: String) : NavKey, Parcelable, NonStackable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
class Reply(
|
||||
val postId: String,
|
||||
val commentId: String,
|
||||
) : NavKey, Parcelable
|
||||
|
||||
@Parcelize @Serializable data object Settings : NavKey, Parcelable
|
||||
|
||||
@Parcelize @Serializable data object AboutLibraries : NavKey, Parcelable
|
||||
|
||||
@Parcelize @Serializable data object TagFiltering : NavKey, Parcelable
|
||||
|
||||
@Parcelize @Serializable data object Login : NavKey, Parcelable
|
||||
|
||||
enum class AppDestinations(
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
val navKey: NavKey,
|
||||
val selectedIcon: ImageVector,
|
||||
) {
|
||||
HOTTEST(
|
||||
icon = Icons.Outlined.Whatshot,
|
||||
label = "Hottest",
|
||||
navKey = Hottest,
|
||||
selectedIcon = Icons.Filled.Whatshot,
|
||||
),
|
||||
NEWEST(
|
||||
icon = Icons.Outlined.NewReleases,
|
||||
label = "Newest",
|
||||
navKey = Newest,
|
||||
selectedIcon = Icons.Filled.NewReleases,
|
||||
),
|
||||
SAVED(
|
||||
icon = Icons.Outlined.FavoriteBorder,
|
||||
label = "Saved",
|
||||
navKey = Saved,
|
||||
selectedIcon = Icons.Filled.Favorite,
|
||||
),
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.navigation
|
||||
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
|
||||
enum class ClawNavigationType {
|
||||
BOTTOM_NAVIGATION,
|
||||
NAVIGATION_RAIL;
|
||||
|
||||
companion object {
|
||||
fun fromSize(windowSize: WindowWidthSizeClass): ClawNavigationType {
|
||||
return when (windowSize) {
|
||||
WindowWidthSizeClass.Medium,
|
||||
WindowWidthSizeClass.Expanded -> NAVIGATION_RAIL
|
||||
else -> BOTTOM_NAVIGATION
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
/** Taken from https://github.com/getsentry/sentry-java/pull/5023 */
|
||||
@file:Suppress("UnstableApiUsage") // Sentry internal APIs are used by this module
|
||||
|
||||
package dev.msfjarvis.claw.android.ui.navigation
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import io.sentry.Breadcrumb
|
||||
import io.sentry.BuildConfig
|
||||
import io.sentry.Hint
|
||||
import io.sentry.IScopes
|
||||
import io.sentry.ITransaction
|
||||
import io.sentry.ScopesAdapter
|
||||
import io.sentry.SentryIntegrationPackageStorage
|
||||
import io.sentry.SentryLevel
|
||||
import io.sentry.SentryOptions
|
||||
import io.sentry.SpanStatus
|
||||
import io.sentry.TransactionContext
|
||||
import io.sentry.TransactionOptions
|
||||
import io.sentry.TypeCheckHint
|
||||
import io.sentry.protocol.TransactionNameSource
|
||||
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
private const val TRACE_ORIGIN = "auto.navigation.navigation3"
|
||||
private const val NAVIGATION_OP = "navigation"
|
||||
|
||||
/** Holder for the active navigation transaction in a composition scope. */
|
||||
internal class NavigationTransactionHolder {
|
||||
internal var activeTransaction: ITransaction? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* A Composable that observes a back stack [List] and captures a [Breadcrumb] and starts an
|
||||
* [ITransaction] for each navigation event, sending them to Sentry.
|
||||
*
|
||||
* This integration is designed for Android Navigation 3 which uses a back stack-based approach with
|
||||
* Compose state observation instead of traditional listeners.
|
||||
*
|
||||
* Each invocation of this composable maintains independent transaction state, allowing multiple
|
||||
* navigation instances (e.g., in split-screen or multi-pane layouts) to coexist without
|
||||
* interference.
|
||||
*
|
||||
* @param T The type of keys in the back stack
|
||||
* @param backStack The current back stack to observe for navigation changes
|
||||
* @param enableNavigationBreadcrumbs Whether the integration should capture breadcrumbs for
|
||||
* navigation events.
|
||||
* @param enableNavigationTracing Whether the integration should start a new idle [ITransaction]
|
||||
* with [SentryOptions.idleTimeout] for navigation events.
|
||||
* @param keyToRoute A function to extract a route name from a back stack key. Defaults to
|
||||
* [Any.toString].
|
||||
* @param scopes The [IScopes] instance to use for capturing events. Defaults to the singleton
|
||||
* instance.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> SentryNavigation3Traced(
|
||||
@SuppressLint("ComposeUnstableCollections") backStack: List<T>,
|
||||
enableNavigationBreadcrumbs: Boolean = true,
|
||||
enableNavigationTracing: Boolean = true,
|
||||
keyToRoute: (T) -> String? = { it.toString() },
|
||||
scopes: IScopes = ScopesAdapter.getInstance(),
|
||||
) {
|
||||
val backStackSnapshot by rememberUpdatedState(backStack)
|
||||
val enableBreadcrumbsSnapshot by rememberUpdatedState(enableNavigationBreadcrumbs)
|
||||
val enableTracingSnapshot by rememberUpdatedState(enableNavigationTracing)
|
||||
val keyToRouteSnapshot by rememberUpdatedState(keyToRoute)
|
||||
val scopesSnapshot by rememberUpdatedState(scopes)
|
||||
|
||||
val transactionHolder = remember { NavigationTransactionHolder() }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
addIntegrationToSdkVersion("Navigation3")
|
||||
|
||||
val observer =
|
||||
SentryBackStackObserver(
|
||||
enableNavigationBreadcrumbs = enableBreadcrumbsSnapshot,
|
||||
enableNavigationTracing = enableTracingSnapshot,
|
||||
keyToRoute = keyToRouteSnapshot,
|
||||
scopes = scopesSnapshot,
|
||||
transactionHolder = transactionHolder,
|
||||
)
|
||||
|
||||
@SuppressLint("RawDispatchersUse")
|
||||
val scope =
|
||||
kotlinx.coroutines.CoroutineScope(
|
||||
kotlinx.coroutines.Dispatchers.Main + kotlinx.coroutines.SupervisorJob()
|
||||
)
|
||||
@SuppressLint("DenyListedApi")
|
||||
val job =
|
||||
snapshotFlow { backStackSnapshot }
|
||||
.drop(1) // Skip initial state
|
||||
.onEach { currentStack ->
|
||||
val currentKey = currentStack.lastOrNull()
|
||||
val previousKey = observer.previousKey
|
||||
if (currentKey != null && currentKey != previousKey) {
|
||||
observer.handleNavigation(currentKey, currentStack)
|
||||
observer.previousKey = currentKey
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
onDispose { job.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal observer that monitors back stack changes and creates Sentry events. */
|
||||
internal class SentryBackStackObserver<T>(
|
||||
private val enableNavigationBreadcrumbs: Boolean,
|
||||
private val enableNavigationTracing: Boolean,
|
||||
private val keyToRoute: (T) -> String?,
|
||||
private val scopes: IScopes,
|
||||
private val transactionHolder: NavigationTransactionHolder,
|
||||
) {
|
||||
internal var previousKey: T? = null
|
||||
|
||||
private val isPerformanceEnabled: Boolean
|
||||
get() = scopes.options.isTracingEnabled && enableNavigationTracing
|
||||
|
||||
init {
|
||||
SentryIntegrationPackageStorage.getInstance()
|
||||
.addPackage("maven:io.sentry:sentry-android-navigation3", BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
internal fun handleNavigation(currentKey: T, currentStack: List<T>) {
|
||||
val currentRoute = keyToRoute(currentKey)
|
||||
if (currentRoute != null) {
|
||||
addBreadcrumb(currentRoute, currentStack)
|
||||
|
||||
if (scopes.options.isEnableScreenTracking) {
|
||||
scopes.configureScope { it.screen = currentRoute }
|
||||
}
|
||||
|
||||
startTracing(currentRoute, currentStack)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addBreadcrumb(toRoute: String, currentStack: List<T>) {
|
||||
if (!enableNavigationBreadcrumbs) {
|
||||
return
|
||||
}
|
||||
|
||||
val breadcrumb =
|
||||
Breadcrumb().apply {
|
||||
type = NAVIGATION_OP
|
||||
category = NAVIGATION_OP
|
||||
|
||||
val fromKey = previousKey
|
||||
if (fromKey != null) {
|
||||
val fromRoute = keyToRoute(fromKey)
|
||||
if (fromRoute != null) {
|
||||
data["from"] = fromRoute
|
||||
}
|
||||
}
|
||||
|
||||
data["to"] = toRoute
|
||||
|
||||
// Capture back stack keys as a list
|
||||
val backStackKeys = currentStack.mapNotNull { keyToRoute(it) }
|
||||
if (backStackKeys.isNotEmpty()) {
|
||||
data["back_stack"] = backStackKeys
|
||||
}
|
||||
|
||||
level = SentryLevel.INFO
|
||||
}
|
||||
|
||||
val hint = Hint()
|
||||
hint.set(TypeCheckHint.ANDROID_NAV_DESTINATION, toRoute)
|
||||
scopes.addBreadcrumb(breadcrumb, hint)
|
||||
}
|
||||
|
||||
private fun startTracing(routeName: String, currentStack: List<T>) {
|
||||
if (!isPerformanceEnabled) {
|
||||
io.sentry.util.TracingUtils.startNewTrace(scopes)
|
||||
return
|
||||
}
|
||||
|
||||
// Finish previous transaction in this navigation scope
|
||||
if (transactionHolder.activeTransaction != null) {
|
||||
stopTracing()
|
||||
}
|
||||
|
||||
val transactionOptions =
|
||||
TransactionOptions().also {
|
||||
it.isWaitForChildren = true
|
||||
it.idleTimeout = scopes.options.idleTimeout
|
||||
|
||||
// Set deadline timeout based on configured option
|
||||
val deadlineTimeoutMillis = scopes.options.deadlineTimeout
|
||||
// No deadline when zero or negative value is set
|
||||
it.deadlineTimeout = if (deadlineTimeoutMillis <= 0) null else deadlineTimeoutMillis
|
||||
|
||||
it.isTrimEnd = true
|
||||
}
|
||||
|
||||
val transaction =
|
||||
scopes.startTransaction(
|
||||
TransactionContext(routeName, TransactionNameSource.ROUTE, NAVIGATION_OP),
|
||||
transactionOptions,
|
||||
)
|
||||
|
||||
transaction.spanContext.origin = TRACE_ORIGIN
|
||||
|
||||
// Capture back stack keys as data
|
||||
val backStackKeys = currentStack.mapNotNull { keyToRoute(it) }
|
||||
if (backStackKeys.isNotEmpty()) {
|
||||
transaction.setData("back_stack", backStackKeys)
|
||||
}
|
||||
|
||||
scopes.configureScope { scope ->
|
||||
scope.withTransaction { tx ->
|
||||
if (tx == null) {
|
||||
scope.transaction = transaction
|
||||
}
|
||||
}
|
||||
}
|
||||
transactionHolder.activeTransaction = transaction
|
||||
}
|
||||
|
||||
private fun stopTracing() {
|
||||
val status = transactionHolder.activeTransaction?.status ?: SpanStatus.OK
|
||||
transactionHolder.activeTransaction?.finish(status)
|
||||
|
||||
// clear transaction from scope so others can bind to it
|
||||
scopes.configureScope { scope ->
|
||||
scope.withTransaction { tx ->
|
||||
if (tx == transactionHolder.activeTransaction) {
|
||||
scope.clearTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transactionHolder.activeTransaction = null
|
||||
}
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
|
||||
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.android.ui.PostActions
|
||||
import dev.msfjarvis.claw.android.ui.decorations.ClawAppBar
|
||||
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationBar
|
||||
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationRail
|
||||
import dev.msfjarvis.claw.android.ui.decorations.ClawTopBarMode
|
||||
import dev.msfjarvis.claw.android.ui.decorations.NavigationItem
|
||||
import dev.msfjarvis.claw.android.ui.lists.DatabasePosts
|
||||
import dev.msfjarvis.claw.android.ui.lists.NetworkPosts
|
||||
import dev.msfjarvis.claw.android.ui.lists.SearchResultsList
|
||||
import dev.msfjarvis.claw.android.ui.navigation.AboutLibraries
|
||||
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
|
||||
import dev.msfjarvis.claw.android.ui.navigation.ClawNavigationType
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Comments
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Hottest
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Login
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Newest
|
||||
import dev.msfjarvis.claw.android.ui.navigation.NonStackable
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Reply
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Saved
|
||||
import dev.msfjarvis.claw.android.ui.navigation.SentryNavigation3Traced
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Settings
|
||||
import dev.msfjarvis.claw.android.ui.navigation.TagFiltering
|
||||
import dev.msfjarvis.claw.android.ui.navigation.TopLevelDestination
|
||||
import dev.msfjarvis.claw.android.ui.navigation.User
|
||||
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
|
||||
import dev.msfjarvis.claw.android.viewmodel.SettingsViewModel
|
||||
import dev.msfjarvis.claw.common.comments.CommentsPage
|
||||
import dev.msfjarvis.claw.common.comments.reply.ReplyScreen
|
||||
import dev.msfjarvis.claw.common.tags.TagFilterViewModel
|
||||
import dev.msfjarvis.claw.common.tags.TagList
|
||||
import dev.msfjarvis.claw.common.user.UserProfile
|
||||
import dev.zacsweers.metrox.viewmodel.metroViewModel
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val SEARCH_DEBOUNCE_MILLIS = 300L
|
||||
private const val SEARCH_WEB_URI = "https://lobste.rs/search"
|
||||
private const val HOTTEST_WEB_URI = "https://lobste.rs/"
|
||||
private const val NEWEST_WEB_URI = "https://lobste.rs/newest"
|
||||
|
||||
enum class TopLevelBackAction {
|
||||
DismissSearch,
|
||||
PopNavigation,
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
fun handleTopLevelBack(
|
||||
isSearchActive: Boolean,
|
||||
isCurrentDestinationTopLevel: Boolean,
|
||||
): TopLevelBackAction {
|
||||
return if (isSearchActive && isCurrentDestinationTopLevel) {
|
||||
TopLevelBackAction.DismissSearch
|
||||
} else {
|
||||
TopLevelBackAction.PopNavigation
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun LobstersPostsScreen(
|
||||
uriHandler: UriHandler,
|
||||
windowSizeClass: WindowSizeClass,
|
||||
setWebUri: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
deepLinkDestination: NavKey? = null,
|
||||
clearDeepLink: () -> Unit = {},
|
||||
viewModel: ClawViewModel = metroViewModel(),
|
||||
settingsViewModel: SettingsViewModel = metroViewModel(),
|
||||
tagFilterViewModel: TagFilterViewModel = metroViewModel(key = "tag_filter"),
|
||||
) {
|
||||
val navigationType = ClawNavigationType.fromSize(windowSizeClass.widthSizeClass)
|
||||
|
||||
val libraries by produceLibraries()
|
||||
val backStack = rememberNavBackStack(Hottest)
|
||||
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
|
||||
|
||||
// region Pain
|
||||
val context = LocalContext.current
|
||||
val activity = LocalActivity.current
|
||||
val hottestListState = rememberLazyListState()
|
||||
val newestListState = rememberLazyListState()
|
||||
val savedListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
val savedPosts by viewModel.savedPostsByMonth.collectAsStateWithLifecycle(persistentMapOf())
|
||||
val savedPostsCount by viewModel.savedPostsCount.collectAsStateWithLifecycle(0L)
|
||||
|
||||
val filteredTags by tagFilterViewModel.filteredTags.collectAsStateWithLifecycle(persistentSetOf())
|
||||
val isLoggedIn by settingsViewModel.isLoggedIn.collectAsStateWithLifecycle(false)
|
||||
val username by settingsViewModel.username.collectAsStateWithLifecycle(null)
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var lastExecutedSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val searchResultsListState = rememberLazyListState()
|
||||
val searchResults = viewModel.searchResults.collectAsLazyPagingItems()
|
||||
val currentDestination = backStack.lastOrNull()
|
||||
val currentDestinationIsTopLevel = currentDestination is TopLevelDestination
|
||||
val normalizedSearchQuery = searchQuery.trim()
|
||||
val searchResultsBottomPadding =
|
||||
if (navigationType == ClawNavigationType.BOTTOM_NAVIGATION) {
|
||||
80.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
|
||||
val navItems =
|
||||
persistentListOf(
|
||||
NavigationItem(AppDestinations.HOTTEST) {
|
||||
coroutineScope.launch { hottestListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
NavigationItem(AppDestinations.NEWEST) {
|
||||
coroutineScope.launch { newestListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
NavigationItem(AppDestinations.SAVED) {
|
||||
coroutineScope.launch { if (savedPosts.isNotEmpty()) savedListState.scrollToItem(0) }
|
||||
},
|
||||
)
|
||||
// endregion
|
||||
|
||||
val executeSearch = { query: String ->
|
||||
val normalizedQuery = query.trim()
|
||||
lastExecutedSearchQuery = normalizedQuery
|
||||
coroutineScope.launch { searchResultsListState.scrollToItem(0) }
|
||||
viewModel.searchQuery = normalizedQuery
|
||||
searchResults.refresh()
|
||||
}
|
||||
val dismissSearch = {
|
||||
isSearchActive = false
|
||||
searchQuery = ""
|
||||
lastExecutedSearchQuery = ""
|
||||
viewModel.searchQuery = ""
|
||||
}
|
||||
val navigateToDestination = { destination: NavKey, allowStacking: Boolean ->
|
||||
if (isSearchActive) {
|
||||
dismissSearch()
|
||||
}
|
||||
navigateTo(backStack, destination, allowStacking = allowStacking)
|
||||
}
|
||||
val startSearch = {
|
||||
isSearchActive = true
|
||||
}
|
||||
|
||||
val postActions =
|
||||
remember(isSearchActive) {
|
||||
PostActions(context, uriHandler, viewModel) {
|
||||
navigateToDestination(Comments(it), false)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(deepLinkDestination) {
|
||||
if (deepLinkDestination != null) {
|
||||
navigateToDestination(deepLinkDestination, true)
|
||||
clearDeepLink()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentDestination, isSearchActive) {
|
||||
if (currentDestinationIsTopLevel) {
|
||||
when {
|
||||
isSearchActive -> setWebUri(SEARCH_WEB_URI)
|
||||
currentDestination == Hottest -> setWebUri(HOTTEST_WEB_URI)
|
||||
currentDestination == Newest -> setWebUri(NEWEST_WEB_URI)
|
||||
currentDestination == Saved -> setWebUri(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentDestinationIsTopLevel, isSearchActive) {
|
||||
if (!currentDestinationIsTopLevel && isSearchActive) {
|
||||
dismissSearch()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isSearchActive, normalizedSearchQuery) {
|
||||
if (!isSearchActive) return@LaunchedEffect
|
||||
delay(SEARCH_DEBOUNCE_MILLIS.milliseconds)
|
||||
if (normalizedSearchQuery != lastExecutedSearchQuery) {
|
||||
executeSearch(normalizedSearchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
isSearchActive,
|
||||
normalizedSearchQuery,
|
||||
lastExecutedSearchQuery,
|
||||
viewModel.searchQuery,
|
||||
) {
|
||||
if (!isSearchActive) return@LaunchedEffect
|
||||
if (normalizedSearchQuery.isBlank()) return@LaunchedEffect
|
||||
if (
|
||||
normalizedSearchQuery == lastExecutedSearchQuery &&
|
||||
viewModel.searchQuery != normalizedSearchQuery
|
||||
) {
|
||||
executeSearch(normalizedSearchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = isSearchActive || backStack.size > 1) {
|
||||
when (
|
||||
handleTopLevelBack(
|
||||
isSearchActive = isSearchActive,
|
||||
isCurrentDestinationTopLevel = currentDestinationIsTopLevel,
|
||||
)
|
||||
) {
|
||||
TopLevelBackAction.DismissSearch -> dismissSearch()
|
||||
TopLevelBackAction.PopNavigation -> popBackStack(backStack)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ClawAppBar(
|
||||
activity = activity,
|
||||
isTopLevel = currentDestinationIsTopLevel,
|
||||
mode =
|
||||
if (currentDestinationIsTopLevel && isSearchActive) {
|
||||
ClawTopBarMode.Searching(query = searchQuery, requestFocus = true)
|
||||
} else {
|
||||
ClawTopBarMode.Browsing
|
||||
},
|
||||
navigateTo = { destination -> navigateToDestination(destination, false) },
|
||||
popBackStack = { popBackStack(backStack) },
|
||||
onStartSearch = startSearch,
|
||||
onDismissSearch = dismissSearch,
|
||||
onQueryChange = { searchQuery = it },
|
||||
onSearch = { query ->
|
||||
searchQuery = query
|
||||
executeSearch(query)
|
||||
},
|
||||
) {
|
||||
if (
|
||||
normalizedSearchQuery.isNotBlank() && normalizedSearchQuery == lastExecutedSearchQuery
|
||||
) {
|
||||
SearchResultsList(
|
||||
lazyPagingItems = searchResults,
|
||||
listState = searchResultsListState,
|
||||
postActions = postActions,
|
||||
filteredTags = filteredTags,
|
||||
contentPadding = PaddingValues(bottom = searchResultsBottomPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
AnimatedVisibility(visible = navigationType == ClawNavigationType.BOTTOM_NAVIGATION) {
|
||||
ClawNavigationBar(
|
||||
items = navItems,
|
||||
currentNavKey = currentDestination,
|
||||
navigateTo = { destination -> navigateToDestination(destination, false) },
|
||||
isVisible = currentDestinationIsTopLevel && !isSearchActive,
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = Modifier.semantics { testTagsAsResourceId = true },
|
||||
) { contentPadding ->
|
||||
SentryNavigation3Traced(backStack = backStack)
|
||||
Row {
|
||||
AnimatedVisibility(visible = navigationType == ClawNavigationType.NAVIGATION_RAIL) {
|
||||
ClawNavigationRail(
|
||||
items = navItems,
|
||||
currentNavKey = currentDestination,
|
||||
navigateTo = { destination -> navigateToDestination(destination, false) },
|
||||
isVisible = currentDestinationIsTopLevel && !isSearchActive,
|
||||
)
|
||||
}
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = modifier.hazeSource(hazeState),
|
||||
sceneStrategies = listOf(listDetailStrategy),
|
||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator()),
|
||||
onBack = {
|
||||
if (popBackStack(backStack) == null) {
|
||||
activity?.finish()
|
||||
}
|
||||
},
|
||||
predictivePopTransitionSpec = {
|
||||
slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(200)) togetherWith
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200))
|
||||
},
|
||||
entryProvider =
|
||||
entryProvider {
|
||||
entry<Hottest>(
|
||||
metadata = ListDetailSceneStrategy.listPane(detailPlaceholder = { Placeholder() })
|
||||
) {
|
||||
setWebUri(HOTTEST_WEB_URI)
|
||||
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
|
||||
NetworkPosts(
|
||||
lazyPagingItems = hottestPosts,
|
||||
listState = hottestListState,
|
||||
postActions = postActions,
|
||||
contentPadding = contentPadding,
|
||||
filteredTags = filteredTags,
|
||||
)
|
||||
}
|
||||
entry<Newest>(
|
||||
metadata = ListDetailSceneStrategy.listPane(detailPlaceholder = { Placeholder() })
|
||||
) {
|
||||
setWebUri(NEWEST_WEB_URI)
|
||||
val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems()
|
||||
NetworkPosts(
|
||||
lazyPagingItems = newestPosts,
|
||||
listState = newestListState,
|
||||
postActions = postActions,
|
||||
contentPadding = contentPadding,
|
||||
filteredTags = filteredTags,
|
||||
)
|
||||
}
|
||||
entry<Saved>(
|
||||
metadata = ListDetailSceneStrategy.listPane(detailPlaceholder = { Placeholder() })
|
||||
) {
|
||||
setWebUri(null)
|
||||
DatabasePosts(
|
||||
items = savedPosts,
|
||||
listState = savedListState,
|
||||
postActions = postActions,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
}
|
||||
entry<Comments>(metadata = ListDetailSceneStrategy.detailPane()) { dest ->
|
||||
val commentsPostActions =
|
||||
remember(isSearchActive) {
|
||||
PostActions(context, uriHandler, viewModel) {
|
||||
navigateToDestination(Comments(it), true)
|
||||
}
|
||||
}
|
||||
CommentsPage(
|
||||
postId = dest.postId,
|
||||
postActions = commentsPostActions,
|
||||
contentPadding = contentPadding,
|
||||
openUserProfile = { navigateToDestination(User(it), false) },
|
||||
openReplyScreen = { postId, commentId ->
|
||||
navigateToDestination(Reply(postId, commentId), true)
|
||||
},
|
||||
)
|
||||
}
|
||||
entry<Reply>(metadata = ListDetailSceneStrategy.extraPane()) { dest ->
|
||||
ReplyScreen(
|
||||
commentId = dest.commentId,
|
||||
contentPadding = contentPadding,
|
||||
postId = dest.postId,
|
||||
onReplySubmitted = { popBackStack(backStack) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
entry<User>(
|
||||
metadata =
|
||||
NavDisplay.transitionSpec {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(200),
|
||||
) togetherWith ExitTransition.KeepUntilTransitionsFinished
|
||||
} +
|
||||
NavDisplay.popTransitionSpec {
|
||||
// Slide old content down, revealing the new content in place underneath
|
||||
EnterTransition.None togetherWith
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200))
|
||||
} +
|
||||
ListDetailSceneStrategy.extraPane()
|
||||
) { dest ->
|
||||
UserProfile(
|
||||
username = dest.username,
|
||||
contentPadding = contentPadding,
|
||||
openUserProfile = { navigateToDestination(User(it), false) },
|
||||
)
|
||||
}
|
||||
entry<Settings>(metadata = ListDetailSceneStrategy.extraPane()) {
|
||||
SettingsScreen(
|
||||
openInputStream = context.contentResolver::openInputStream,
|
||||
openOutputStream = context.contentResolver::openOutputStream,
|
||||
openLibrariesScreen = { navigateToDestination(AboutLibraries, false) },
|
||||
openRepository = {
|
||||
uriHandler.openUri("https://github.com/msfjarvis/compose-lobsters")
|
||||
},
|
||||
openTagFiltering = { navigateToDestination(TagFiltering, false) },
|
||||
openLoginScreen = { navigateToDestination(Login, false) },
|
||||
onLogout = { settingsViewModel.logout() },
|
||||
isLoggedIn = isLoggedIn,
|
||||
username = username,
|
||||
importPosts = viewModel::importPosts,
|
||||
exportPostsAsJson = viewModel::exportPostsAsJson,
|
||||
exportPostsAsHtml = viewModel::exportPostsAsHtml,
|
||||
savedPostsCount = savedPostsCount,
|
||||
snackbarHostState = snackbarHostState,
|
||||
contentPadding = contentPadding,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
entry<Login>(metadata = ListDetailSceneStrategy.extraPane()) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = settingsViewModel::saveCookie,
|
||||
popBackStack = { popBackStack(backStack) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
entry<AboutLibraries>(metadata = ListDetailSceneStrategy.extraPane()) {
|
||||
LibrariesContainer(
|
||||
libraries = libraries,
|
||||
contentPadding = contentPadding,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
entry<TagFiltering> {
|
||||
TagList(
|
||||
popBackStack = { popBackStack(backStack) },
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
fun navigateTo(
|
||||
backStack: MutableList<NavKey>,
|
||||
destination: NavKey,
|
||||
allowStacking: Boolean = false,
|
||||
) {
|
||||
if (destination is TopLevelDestination) {
|
||||
backStack.clear()
|
||||
if (destination != Hottest) {
|
||||
backStack.add(Hottest)
|
||||
}
|
||||
backStack.add(destination)
|
||||
return
|
||||
}
|
||||
|
||||
if (destination is NonStackable) {
|
||||
if (allowStacking && destination is Comments) {
|
||||
val lastItem = backStack.lastOrNull()
|
||||
if (lastItem is Comments && lastItem.postId == destination.postId) {
|
||||
return
|
||||
}
|
||||
backStack.add(destination)
|
||||
return
|
||||
}
|
||||
|
||||
val existingEntry = backStack.firstOrNull {
|
||||
it is NonStackable && it::class.java.isAssignableFrom(destination::class.java)
|
||||
}
|
||||
|
||||
if (existingEntry != null) {
|
||||
backStack.remove(existingEntry)
|
||||
}
|
||||
}
|
||||
|
||||
backStack.add(destination)
|
||||
}
|
||||
|
||||
private fun popBackStack(backStack: MutableList<NavKey>): NavKey? {
|
||||
if (backStack.size <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return backStack.removeAt(backStack.lastIndex)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Placeholder(modifier: Modifier = Modifier) {
|
||||
Text(text = stringResource(R.string.no_post_selected), modifier = modifier)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
|
||||
private const val LOGIN_URL = "https://lobste.rs/login"
|
||||
private const val LOBSTERS_URL = "https://lobste.rs"
|
||||
|
||||
internal fun parseAuthenticatedUsername(usernameResult: String): String? {
|
||||
return usernameResult.removeSurrounding("\"").takeUnless { it.isBlank() || it == "null" }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: (String, String) -> Unit,
|
||||
popBackStack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
Scaffold(modifier = modifier) { contentPadding ->
|
||||
Box(modifier = Modifier.padding(contentPadding).fillMaxSize()) {
|
||||
if (hasError) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("Failed to load login page.")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
hasError = false
|
||||
isLoading = true
|
||||
webViewRef?.loadUrl(LOGIN_URL)
|
||||
}
|
||||
) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context)
|
||||
.apply {
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
settings.javaScriptEnabled = true
|
||||
webViewClient =
|
||||
object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
isLoading = false
|
||||
if (!url.contains("/login")) {
|
||||
@SuppressLint("DeprecatedCall")
|
||||
view.evaluateJavascript(
|
||||
"document.body && document.body.getAttribute('data-username')"
|
||||
) { usernameResult ->
|
||||
val username = parseAuthenticatedUsername(usernameResult)
|
||||
if (username != null) {
|
||||
val cookie = CookieManager.getInstance().getCookie(LOBSTERS_URL)
|
||||
if (cookie != null) {
|
||||
onLoginSuccess(cookie, username)
|
||||
popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
if (request.isForMainFrame) {
|
||||
isLoading = false
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
loadUrl(LOGIN_URL)
|
||||
}
|
||||
.also { webViewRef = it }
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading && !hasError) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.screens
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Bookmarks
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
|
||||
import io.sentry.Sentry
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val JSON_MIME_TYPE = "application/json"
|
||||
private const val HTML_MIME_TYPE = "application/html"
|
||||
|
||||
internal fun loggedInAccountText(username: String?): String {
|
||||
return if (username.isNullOrBlank()) {
|
||||
"Sign out of your lobste.rs account"
|
||||
} else {
|
||||
"Logged in as $username"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
openLibrariesScreen: () -> Unit,
|
||||
openRepository: () -> Unit,
|
||||
openTagFiltering: () -> Unit,
|
||||
openLoginScreen: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
isLoggedIn: Boolean,
|
||||
username: String?,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
openInputStream: (Uri) -> InputStream?,
|
||||
openOutputStream: (Uri) -> OutputStream?,
|
||||
importPosts: suspend (InputStream) -> Result<Unit>,
|
||||
exportPostsAsJson: suspend (OutputStream) -> Unit,
|
||||
exportPostsAsHtml: suspend (OutputStream) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
savedPostsCount: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Column(modifier.padding(contentPadding)) {
|
||||
// Account Section
|
||||
SectionHeader(title = "Account")
|
||||
if (isLoggedIn) {
|
||||
ListItem(
|
||||
supportingContent = { Text(loggedInAccountText(username)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AccountCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onLogout),
|
||||
) {
|
||||
Text("Log out")
|
||||
}
|
||||
} else {
|
||||
ListItem(
|
||||
supportingContent = { Text("Sign in to your lobste.rs account") },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AccountCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = openLoginScreen),
|
||||
) {
|
||||
Text("Log in with lobste.rs")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Data Management Section
|
||||
SectionHeader(title = stringResource(R.string.data_management))
|
||||
ListItem(
|
||||
supportingContent = { Text(stringResource(R.string.import_and_export_your_saved_posts)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ImportExport,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ImportPosts(openInputStream, coroutineScope, snackbarHostState, importPosts)
|
||||
ExportPosts(
|
||||
coroutineScope,
|
||||
snackbarHostState,
|
||||
openOutputStream,
|
||||
exportPostsAsJson,
|
||||
exportPostsAsHtml,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.data_transfer))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// App Information Section
|
||||
SectionHeader(title = stringResource(R.string.app_information))
|
||||
ListItem(
|
||||
supportingContent = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.version_placeholder,
|
||||
BuildConfig.VERSION_NAME,
|
||||
BuildConfig.VERSION_CODE,
|
||||
)
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.version))
|
||||
}
|
||||
ListItem(
|
||||
supportingContent = { Text(stringResource(R.string.posts_saved_locally, savedPostsCount)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bookmarks,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.saved_posts))
|
||||
}
|
||||
ListItem(
|
||||
supportingContent = { Text(stringResource(R.string.filter_posts_by_tags)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = openTagFiltering),
|
||||
) {
|
||||
Text(stringResource(R.string.tag_filtering))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// About Section
|
||||
SectionHeader(title = stringResource(R.string.about))
|
||||
ListItem(
|
||||
supportingContent = {
|
||||
Text(stringResource(R.string.view_open_source_libraries_used_in_this_))
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.LibraryBooks,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = openLibrariesScreen),
|
||||
) {
|
||||
Text(stringResource(R.string.libraries))
|
||||
}
|
||||
ListItem(
|
||||
supportingContent = { Text(stringResource(R.string.view_the_source_code_on_github)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = openRepository),
|
||||
) {
|
||||
Text(stringResource(R.string.source_code))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportPosts(
|
||||
openInputStream: (Uri) -> InputStream?,
|
||||
coroutineScope: CoroutineScope,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
importPosts: suspend (InputStream) -> Result<Unit>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val importAction =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") }
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
openInputStream(uri)?.use { stream ->
|
||||
val result = importPosts(stream)
|
||||
if (result.isSuccess) {
|
||||
snackbarHostState.showSnackbarDismissing("Successfully imported posts")
|
||||
} else {
|
||||
snackbarHostState.showSnackbarDismissing("Failed to import posts")
|
||||
}
|
||||
} ?: snackbarHostState.showSnackbarDismissing("Unable to open selected file")
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbarDismissing("Failed to open file: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
importAction.launch(JSON_MIME_TYPE)
|
||||
Sentry.metrics().count("import", 1.0)
|
||||
},
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(text = stringResource(R.string.import_action))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private inline fun ExportPosts(
|
||||
coroutineScope: CoroutineScope,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
crossinline openOutputStream: (Uri) -> OutputStream?,
|
||||
crossinline exportPostsAsJson: suspend (OutputStream) -> Unit,
|
||||
crossinline exportPostsAsHtml: suspend (OutputStream) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val jsonExportAction =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(JSON_MIME_TYPE)) { uri
|
||||
->
|
||||
if (uri == null) {
|
||||
coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") }
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
openOutputStream(uri)?.use { stream ->
|
||||
exportPostsAsJson(stream)
|
||||
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
|
||||
Sentry.metrics().count("json_export_success", 1.0)
|
||||
}
|
||||
?: run {
|
||||
snackbarHostState.showSnackbarDismissing("Unable to open output file")
|
||||
Sentry.metrics().count("json_export_failure", 1.0)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbarDismissing("Failed to export posts: ${e.message}")
|
||||
Sentry.metrics().count("json_export_failure", 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
val htmlExportAction =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(HTML_MIME_TYPE)) { uri
|
||||
->
|
||||
if (uri == null) {
|
||||
coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") }
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
openOutputStream(uri)?.use { stream ->
|
||||
exportPostsAsHtml(stream)
|
||||
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
|
||||
Sentry.metrics().count("html_export_success", 1.0)
|
||||
}
|
||||
?: run {
|
||||
snackbarHostState.showSnackbarDismissing("Unable to open output file")
|
||||
Sentry.metrics().count("html_export_failure", 1.0)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbarDismissing("Failed to export posts: ${e.message}")
|
||||
Sentry.metrics().count("html_export_failure", 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
OutlinedButton(
|
||||
onClick = { expanded = true },
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(text = stringResource(R.string.export))
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.json)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
jsonExportAction.launch("claw-export.json")
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.export_as_json),
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.bookmarks)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
htmlExportAction.launch("claw-export.html")
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bookmarks,
|
||||
contentDescription = stringResource(R.string.export_as_browser_bookmarks),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows a Snackbar but dismisses any existing ones first. */
|
||||
private suspend inline fun SnackbarHostState.showSnackbarDismissing(text: String) {
|
||||
currentSnackbarData?.dismiss()
|
||||
showSnackbar(text)
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@Composable
|
||||
private fun SettingsScreenPreview() {
|
||||
LobstersTheme {
|
||||
SettingsScreen(
|
||||
openLibrariesScreen = {},
|
||||
openRepository = {},
|
||||
openTagFiltering = {},
|
||||
openLoginScreen = {},
|
||||
onLogout = {},
|
||||
isLoggedIn = false,
|
||||
username = null,
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
openInputStream = { null },
|
||||
openOutputStream = { null },
|
||||
importPosts = { Result.success(Unit) },
|
||||
exportPostsAsJson = {},
|
||||
exportPostsAsHtml = {},
|
||||
contentPadding = PaddingValues(),
|
||||
savedPostsCount = 42,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
|
||||
import dev.msfjarvis.claw.database.local.CachedRemotePost
|
||||
import dev.msfjarvis.claw.database.local.CachedRemotePostQueries
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Inject
|
||||
class CachedRemotePostsRepository(
|
||||
private val cachedRemotePostQueries: CachedRemotePostQueries,
|
||||
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
|
||||
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
val cachedPosts = cachedRemotePostQueries.selectAllPosts().asFlow().mapToList(readDispatcher)
|
||||
|
||||
fun getRecentPosts(limit: Long) =
|
||||
cachedRemotePostQueries.selectRecentPosts(limit).asFlow().mapToList(readDispatcher)
|
||||
|
||||
suspend fun getCachedPosts(): List<CachedRemotePost> {
|
||||
return withContext(readDispatcher) { cachedRemotePostQueries.selectAllPosts().executeAsList() }
|
||||
}
|
||||
|
||||
suspend fun savePosts(posts: List<CachedRemotePost>) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Caching remote posts: ${posts.joinToString(",") { it.shortId }}")
|
||||
}
|
||||
withContext(writeDispatcher) {
|
||||
cachedRemotePostQueries.transaction {
|
||||
cachedRemotePostQueries.deleteAllPosts()
|
||||
posts.forEach { post -> cachedRemotePostQueries.insertOrReplacePost(post) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "CachedRemotePostsRepository"
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
|
||||
import dev.msfjarvis.claw.android.paging.SearchPagingSource
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.core.coroutines.IODispatcher
|
||||
import dev.msfjarvis.claw.core.coroutines.MainDispatcher
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.fromSavedPost
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.binding
|
||||
import dev.zacsweers.metrox.viewmodel.ViewModelKey
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
@Inject
|
||||
@ViewModelKey
|
||||
@ContributesIntoMap(scope = AppScope::class, binding = binding<ViewModel>())
|
||||
class ClawViewModel(
|
||||
context: Context,
|
||||
private val api: LobstersApi,
|
||||
private val readPostsRepository: ReadPostsRepository,
|
||||
private val savedPostsRepository: SavedPostsRepository,
|
||||
private val linkMetadataRepository: LinkMetadataRepository,
|
||||
private val dataTransferRepository: DataTransferRepository,
|
||||
private val pagingSourceFactory: LobstersPagingSource.Factory,
|
||||
private val searchPagingSourceFactory: SearchPagingSource.Factory,
|
||||
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@param:MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
||||
) : AndroidViewModel(context as Application) {
|
||||
private var _hottestPosts: Flow<PagingData<UIPost>>? = null
|
||||
val hottestPosts: Flow<PagingData<UIPost>>
|
||||
get() {
|
||||
if (_hottestPosts == null) {
|
||||
_hottestPosts =
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = PAGE_SIZE),
|
||||
initialKey = STARTING_PAGE_INDEX,
|
||||
pagingSourceFactory = {
|
||||
pagingSourceFactory.create { page -> api.getHottestPosts(page) }
|
||||
},
|
||||
)
|
||||
.flow
|
||||
.cachedIn(viewModelScope)
|
||||
}
|
||||
return requireNotNull(_hottestPosts) { "_hottestPosts should be initialized here" }
|
||||
}
|
||||
|
||||
private var _newestPosts: Flow<PagingData<UIPost>>? = null
|
||||
val newestPosts: Flow<PagingData<UIPost>>
|
||||
get() {
|
||||
if (_newestPosts == null) {
|
||||
_newestPosts =
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = PAGE_SIZE),
|
||||
initialKey = STARTING_PAGE_INDEX,
|
||||
pagingSourceFactory = {
|
||||
pagingSourceFactory.create { page -> api.getNewestPosts(page) }
|
||||
},
|
||||
)
|
||||
.flow
|
||||
.cachedIn(viewModelScope)
|
||||
}
|
||||
return requireNotNull(_newestPosts) { "_newestPosts should be initialized here" }
|
||||
}
|
||||
|
||||
val searchResults =
|
||||
Pager(
|
||||
PagingConfig(pageSize = PAGE_SIZE),
|
||||
initialKey = STARTING_PAGE_INDEX,
|
||||
pagingSourceFactory = { searchPagingSourceFactory.create { searchQuery } },
|
||||
)
|
||||
.flow
|
||||
val savedPosts = savedPostsRepository.savedPosts.map { it.map(UIPost.Companion::fromSavedPost) }
|
||||
val savedPostsCount = savedPostsRepository.savedPosts.map { it.size.toLong() }
|
||||
val savedPostsByMonth
|
||||
get() =
|
||||
savedPostsRepository.savedPostsSortedByDate.map { posts ->
|
||||
posts
|
||||
.map(UIPost.Companion::fromSavedPost)
|
||||
.groupBy { post ->
|
||||
val time = post.createdAt.toLocalDateTime()
|
||||
"${time.month.name.lowercase().capitalize(Locale.current)} ${time.year}"
|
||||
}
|
||||
.toImmutableMap()
|
||||
}
|
||||
|
||||
var searchQuery by mutableStateOf("")
|
||||
|
||||
private var _readPosts by mutableStateOf(emptySet<String>())
|
||||
private var _savedPosts by mutableStateOf(emptySet<String>())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
savedPosts.collectLatest { _savedPosts = it.map(UIPost::shortId).toSet() }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
readPostsRepository.readPosts.collectLatest { _readPosts = it.toSet() }
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSave(post: UIPost) {
|
||||
viewModelScope.launch {
|
||||
savedPostsRepository.toggleSave(post)
|
||||
withContext(mainDispatcher) { SavedPostsWidget().updateAll(getApplication()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun isPostRead(post: UIPost): Boolean {
|
||||
return _readPosts.contains(post.shortId)
|
||||
}
|
||||
|
||||
fun isPostSaved(post: UIPost): Boolean {
|
||||
return _savedPosts.contains(post.shortId)
|
||||
}
|
||||
|
||||
suspend fun getLinkMetadata(url: String) =
|
||||
withContext(ioDispatcher) { linkMetadataRepository.getLinkMetadata(url) }
|
||||
|
||||
suspend fun importPosts(input: InputStream): Result<Unit> =
|
||||
dataTransferRepository.importPosts(input)
|
||||
|
||||
suspend fun exportPostsAsJson(output: OutputStream) =
|
||||
dataTransferRepository.exportPostsAsJson(output)
|
||||
|
||||
suspend fun exportPostsAsHtml(output: OutputStream) =
|
||||
dataTransferRepository.exportPostsAsHTML(output)
|
||||
|
||||
fun markPostAsRead(postId: String) {
|
||||
viewModelScope.launch { readPostsRepository.markRead(postId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a given [String] into a [LocalDateTime]. This method is only intended to be used for
|
||||
* dates in the format returned by the Lobsters API, and is not a general purpose parsing
|
||||
* solution.
|
||||
*/
|
||||
private fun String.toLocalDateTime(): LocalDateTime {
|
||||
if (isEmpty()) return Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
return Instant.parse(this).toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import dev.msfjarvis.claw.core.coroutines.IODispatcher
|
||||
import dev.msfjarvis.claw.database.SavedPostSerializer
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.zacsweers.metro.Inject
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
|
||||
@Inject
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
class DataTransferRepository(
|
||||
private val json: Json,
|
||||
private val savedPostsRepository: SavedPostsRepository,
|
||||
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private val serializer = ListSerializer(SavedPostSerializer)
|
||||
|
||||
suspend fun importPosts(input: InputStream): Result<Unit> {
|
||||
return try {
|
||||
val posts: List<SavedPost> =
|
||||
withContext(ioDispatcher) { json.decodeFromStream(serializer, input) }
|
||||
savedPostsRepository.savePosts(posts)
|
||||
Result.success(Unit)
|
||||
} catch (t: Throwable) {
|
||||
Result.failure(t)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportPostsAsJson(output: OutputStream) {
|
||||
val posts = savedPostsRepository.savedPosts.first()
|
||||
withContext(ioDispatcher) { json.encodeToStream(serializer, posts, output) }
|
||||
}
|
||||
|
||||
suspend fun exportPostsAsHTML(output: OutputStream) {
|
||||
fun computeTimestamp(post: SavedPost): Long =
|
||||
Instant.parse(post.createdAt).toEpochMilliseconds()
|
||||
|
||||
val posts = savedPostsRepository.savedPosts.first()
|
||||
val header =
|
||||
"""
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<!-- This is an automatically generated file.
|
||||
It will be read and overwritten.
|
||||
DO NOT EDIT! -->
|
||||
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
"""
|
||||
.trimIndent()
|
||||
val html = buildString {
|
||||
append(header)
|
||||
append("<DD><p>\n")
|
||||
for (post in posts) {
|
||||
append(
|
||||
"""
|
||||
<DT><A HREF="${post.url.ifEmpty { post.commentsUrl }}" ADD_DATE="${computeTimestamp(post)}" PRIVATE="0" TAGS="${post.tags.joinToString(",")}">${post.title}</A>
|
||||
<DD>${post.title}
|
||||
"""
|
||||
.trimIndent()
|
||||
)
|
||||
}
|
||||
append(
|
||||
"""
|
||||
<DT><A HREF="https://example.com/" ADD_DATE="0" PRIVATE="0" TAGS="delete,me,pls">Padding post</A>
|
||||
<DD>Linkding ignores the last entry so this pads the difference for imports
|
||||
"""
|
||||
.trimIndent()
|
||||
)
|
||||
append("</DD></p>\n")
|
||||
}
|
||||
withContext(ioDispatcher) { output.bufferedWriter().use { writer -> writer.write(html) } }
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import dev.msfjarvis.claw.core.coroutines.IODispatcher
|
||||
import dev.msfjarvis.claw.model.LinkMetadata
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.saket.unfurl.Unfurler
|
||||
|
||||
@Inject
|
||||
class LinkMetadataRepository(
|
||||
private val unfurler: Unfurler,
|
||||
@param:IODispatcher private val dispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
suspend fun getLinkMetadata(url: String): LinkMetadata {
|
||||
val result = withContext(dispatcher) { unfurler.unfurl(url) }
|
||||
return LinkMetadata(url, result?.favicon?.toString())
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
|
||||
import dev.msfjarvis.claw.database.local.ReadPostsQueries
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Inject
|
||||
class ReadPostsRepository(
|
||||
private val readPostsQueries: ReadPostsQueries,
|
||||
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
|
||||
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
val readPosts = readPostsQueries.selectAllPosts().asFlow().mapToList(readDispatcher)
|
||||
|
||||
suspend fun markRead(postId: String) {
|
||||
withContext(writeDispatcher) { readPostsQueries.markRead(postId) }
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.msfjarvis.claw.database.local.SavedPostQueries
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.toSavedPost
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Inject
|
||||
class SavedPostsRepository(
|
||||
private val savedPostQueries: SavedPostQueries,
|
||||
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
|
||||
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
val savedPosts = savedPostQueries.selectAllPosts().asFlow().mapToList(readDispatcher)
|
||||
val savedPostsSortedByDate =
|
||||
savedPostQueries.selectAllPostsSortedByDate().asFlow().mapToList(readDispatcher)
|
||||
|
||||
fun getPostsFromLastNDays(days: Long) =
|
||||
savedPostQueries.selectPostsFromLastNDays(days.toString()).asFlow().mapToList(readDispatcher)
|
||||
|
||||
fun getRecentPosts(limit: Long) =
|
||||
savedPostQueries.selectRecentPosts(limit).asFlow().mapToList(readDispatcher)
|
||||
|
||||
suspend fun toggleSave(post: UIPost) {
|
||||
val exists =
|
||||
withContext(readDispatcher) { savedPostQueries.postExists(post.shortId).executeAsOne() }
|
||||
if (exists) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Removing post: ${post.shortId}")
|
||||
}
|
||||
withContext(writeDispatcher) { savedPostQueries.deletePost(post.shortId) }
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Saving post: ${post.shortId}")
|
||||
}
|
||||
withContext(writeDispatcher) { savedPostQueries.insertOrReplacePost(post.toSavedPost()) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun savePosts(posts: List<SavedPost>) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Saving posts: ${posts.joinToString(",") { it.shortId }}")
|
||||
}
|
||||
withContext(writeDispatcher) {
|
||||
savedPostQueries.transaction {
|
||||
posts.forEach { post -> savedPostQueries.insertOrReplacePost(post) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "SavedPostsRepository"
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.msfjarvis.claw.core.network.SessionCookieStore
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.binding
|
||||
import dev.zacsweers.metrox.viewmodel.ViewModelKey
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@Inject
|
||||
@ViewModelKey
|
||||
@ContributesIntoMap(scope = AppScope::class, binding = binding<ViewModel>())
|
||||
class SettingsViewModel(
|
||||
private val sessionCookieStore: SessionCookieStore,
|
||||
private val webViewCookieStore: WebViewCookieStore,
|
||||
) : ViewModel() {
|
||||
|
||||
val isLoggedIn =
|
||||
sessionCookieStore
|
||||
.isLoggedIn()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
val username =
|
||||
sessionCookieStore
|
||||
.username()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
fun logout() {
|
||||
sessionCookieStore.clear()
|
||||
webViewCookieStore.clearLobstersCookies()
|
||||
}
|
||||
|
||||
fun saveCookie(cookie: String, username: String) {
|
||||
sessionCookieStore.set(cookie, username)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
|
||||
interface WebViewCookieStore {
|
||||
fun clearLobstersCookies()
|
||||
}
|
||||
|
||||
@Inject
|
||||
@ContributesBinding(AppScope::class)
|
||||
class AndroidWebViewCookieStore : WebViewCookieStore {
|
||||
override fun clearLobstersCookies() {
|
||||
CookieManager.getInstance().removeAllCookies(null)
|
||||
CookieManager.getInstance().flush()
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.work
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.android.glance.SavedPostsWidgetReceiver
|
||||
import dev.msfjarvis.claw.android.glance.generatedWidgetPreviewDelayMinutes
|
||||
import dev.msfjarvis.claw.android.injection.InjectedWorkerFactory
|
||||
import dev.msfjarvis.claw.android.injection.WorkerKey
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.binding
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@AssistedInject
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
class GeneratedWidgetPreviewUpdateWorker(
|
||||
context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
try {
|
||||
val result =
|
||||
GlanceAppWidgetManager(applicationContext)
|
||||
.setWidgetPreviews(SavedPostsWidgetReceiver::class)
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("WidgetPreviewUpdateWorker", "GlanceAppWidgetManager#setWidgetPreviews -> $result")
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Workaround for Motorola Android 15 bug where AppWidgetServiceImpl incorrectly
|
||||
// reports registered widgets as invalid when setting previews.
|
||||
// See: https://claw.sentry.io/share/issue/f8ee09821d8840b9b86293d3ffb627d3/
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val UNIQUE_WORK_NAME = "generatedWidgetPreviewUpdate"
|
||||
|
||||
fun enqueue(workManager: WorkManager) {
|
||||
workManager.enqueueUniqueWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
androidx.work.ExistingWorkPolicy.KEEP,
|
||||
request(),
|
||||
)
|
||||
}
|
||||
|
||||
fun request(): OneTimeWorkRequest {
|
||||
return OneTimeWorkRequestBuilder<GeneratedWidgetPreviewUpdateWorker>()
|
||||
.setInitialDelay(generatedWidgetPreviewDelayMinutes(), TimeUnit.MINUTES)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerKey(GeneratedWidgetPreviewUpdateWorker::class)
|
||||
@ContributesIntoMap(
|
||||
AppScope::class,
|
||||
binding = binding<InjectedWorkerFactory.WorkerInstanceFactory<*>>(),
|
||||
)
|
||||
@AssistedFactory
|
||||
abstract class Factory :
|
||||
InjectedWorkerFactory.WorkerInstanceFactory<GeneratedWidgetPreviewUpdateWorker>
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.slack.eithernet.ApiResult.Success
|
||||
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
|
||||
import dev.msfjarvis.claw.android.injection.InjectedWorkerFactory
|
||||
import dev.msfjarvis.claw.android.injection.WorkerKey
|
||||
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.msfjarvis.claw.model.toSavedPost
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.binding
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* WorkManager-backed [CoroutineWorker] that gets all the posts from [SavedPostsRepository] that
|
||||
* were created in the last 30 days, fetches their newest state using the [LobstersApi] and then
|
||||
* writes them back to the database. API calls are staggered with a configurable delay to avoid rate
|
||||
* limiting. This allows saved posts to stay up-to-date with current comment counts and other
|
||||
* metadata changes.
|
||||
*/
|
||||
@AssistedInject
|
||||
class SavedPostUpdaterWorker(
|
||||
context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val savedPostsRepository: SavedPostsRepository,
|
||||
private val lobstersApi: LobstersApi,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val postsToUpdate = savedPostsRepository.getPostsFromLastNDays(DAYS_TO_UPDATE).first()
|
||||
|
||||
if (postsToUpdate.isEmpty()) {
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val updatedPosts = mutableListOf<SavedPost>()
|
||||
|
||||
for ((index, post) in postsToUpdate.withIndex()) {
|
||||
when (val result = lobstersApi.getPostDetails(post.shortId)) {
|
||||
is Success -> {
|
||||
updatedPosts.add(result.value.toSavedPost())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
if (index < postsToUpdate.lastIndex) {
|
||||
// Add a random delay between API calls to avoid rate limiting
|
||||
val delayMs = Random.nextLong(MIN_DELAY_MS, MAX_DELAY_MS)
|
||||
delay(delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedPosts.isNotEmpty()) {
|
||||
savedPostsRepository.savePosts(updatedPosts)
|
||||
SavedPostsWidget().updateAll(applicationContext)
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DAYS_TO_UPDATE = 30L
|
||||
const val MIN_DELAY_MS = 500L
|
||||
const val MAX_DELAY_MS = 2000L
|
||||
}
|
||||
|
||||
@WorkerKey(SavedPostUpdaterWorker::class)
|
||||
@ContributesIntoMap(
|
||||
AppScope::class,
|
||||
binding = binding<InjectedWorkerFactory.WorkerInstanceFactory<*>>(),
|
||||
)
|
||||
@AssistedFactory
|
||||
abstract class Factory : InjectedWorkerFactory.WorkerInstanceFactory<SavedPostUpdaterWorker>
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.msfjarvis.claw.android.injection.InjectedWorkerFactory
|
||||
import dev.msfjarvis.claw.android.injection.WorkerKey
|
||||
import dev.msfjarvis.claw.common.tags.TagBlockRepository
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.binding
|
||||
|
||||
@AssistedInject
|
||||
class TagExpirationCleanupWorker(
|
||||
context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val tagBlockRepository: TagBlockRepository,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
tagBlockRepository.removeExpiredTags()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
@WorkerKey(TagExpirationCleanupWorker::class)
|
||||
@ContributesIntoMap(
|
||||
AppScope::class,
|
||||
binding = binding<InjectedWorkerFactory.WorkerInstanceFactory<*>>(),
|
||||
)
|
||||
@AssistedFactory
|
||||
abstract class Factory : InjectedWorkerFactory.WorkerInstanceFactory<TagExpirationCleanupWorker>
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.zipline
|
||||
|
||||
import android.content.Context
|
||||
import app.cash.zipline.Zipline
|
||||
import app.cash.zipline.ZiplineManifest
|
||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||
import app.cash.zipline.loader.FreshnessChecker
|
||||
import app.cash.zipline.loader.LoadResult
|
||||
import app.cash.zipline.loader.ManifestVerifier
|
||||
import app.cash.zipline.loader.ZiplineLoader
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.api.LobstersParserClient
|
||||
import dev.msfjarvis.claw.parser.LobstersParserService
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.binding
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.ExecutorCoroutineDispatcher
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.ByteString.Companion.decodeHex
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
internal object EmbeddedAssetsFreshnessChecker : FreshnessChecker {
|
||||
override fun isFresh(
|
||||
manifest: ZiplineManifest,
|
||||
freshAtEpochMs: Long,
|
||||
): Boolean = true
|
||||
}
|
||||
|
||||
internal suspend fun loadWithEmbeddedFallback(
|
||||
initialFreshnessChecker: FreshnessChecker,
|
||||
load: suspend (FreshnessChecker) -> LoadResult,
|
||||
): LoadResult {
|
||||
val initialResult = load(initialFreshnessChecker)
|
||||
return if (initialResult is LoadResult.Failure && initialResult.exception is IOException) {
|
||||
load(EmbeddedAssetsFreshnessChecker)
|
||||
} else {
|
||||
initialResult
|
||||
}
|
||||
}
|
||||
|
||||
@Inject
|
||||
@ContributesBinding(AppScope::class, binding = binding<LobstersParserClient>())
|
||||
class AndroidZiplineParserClient(
|
||||
private val context: Context,
|
||||
private val httpClient: OkHttpClient,
|
||||
private val manifestUrl: String = BuildConfig.ZIPLINE_PARSER_MANIFEST_URL,
|
||||
private val verifySignatures: Boolean = BuildConfig.ZIPLINE_PARSER_VERIFY_SIGNATURES,
|
||||
) : LobstersParserClient, AutoCloseable {
|
||||
private val ziplineThread = AtomicReference<Thread>()
|
||||
private val dispatcher: ExecutorCoroutineDispatcher =
|
||||
Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(
|
||||
null,
|
||||
{
|
||||
ziplineThread.set(Thread.currentThread())
|
||||
runnable.run()
|
||||
},
|
||||
"Claw-ZiplineParser",
|
||||
ZIPLINE_THREAD_STACK_SIZE_BYTES,
|
||||
)
|
||||
}
|
||||
.asCoroutineDispatcher()
|
||||
private val mutex = Mutex()
|
||||
private var loadedZipline: Zipline? = null
|
||||
private var loadedService: LobstersParserService? = null
|
||||
|
||||
override suspend fun service(): LobstersParserService {
|
||||
loadedService?.let {
|
||||
return it
|
||||
}
|
||||
return mutex.withLock { loadedService ?: loadService().also { loadedService = it } }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
loadedService?.close()
|
||||
loadedService = null
|
||||
loadedZipline?.let { zipline -> onZiplineThread { zipline.close() } }
|
||||
loadedZipline = null
|
||||
dispatcher.close()
|
||||
}
|
||||
|
||||
private suspend fun loadService(): LobstersParserService {
|
||||
extractEmbeddedArtifacts()
|
||||
|
||||
val embeddedDir = File(context.codeCacheDir, "zipline-embedded/zipline")
|
||||
val embeddedManifestFile = File(embeddedDir, "zipline-parser.manifest.zipline.json")
|
||||
|
||||
evictManifestCacheEntry()
|
||||
|
||||
val manifestVerifier =
|
||||
if (verifySignatures) {
|
||||
ManifestVerifier.Builder()
|
||||
.addEd25519(
|
||||
"key0",
|
||||
"c960a123f76e0312cc05d9e358377b82d1e655d9569e9cff5f4834b1c4f228ce".decodeHex(),
|
||||
)
|
||||
.build()
|
||||
} else {
|
||||
ManifestVerifier.NO_SIGNATURE_CHECKS
|
||||
}
|
||||
|
||||
val loader =
|
||||
ZiplineLoader(
|
||||
dispatcher = dispatcher,
|
||||
manifestVerifier = manifestVerifier,
|
||||
httpClient = httpClient,
|
||||
)
|
||||
.withEmbedded(
|
||||
embeddedFileSystem = FileSystem.SYSTEM,
|
||||
embeddedDir = embeddedDir.absolutePath.toPath(),
|
||||
)
|
||||
|
||||
val freshnessChecker =
|
||||
if (verifySignatures) {
|
||||
DefaultFreshnessCheckerNotFresh
|
||||
} else {
|
||||
EmbeddedAssetsFreshnessChecker
|
||||
}
|
||||
val effectiveManifestUrl =
|
||||
if (!verifySignatures && embeddedManifestFile.exists()) {
|
||||
embeddedManifestFile.toURI().toString()
|
||||
} else {
|
||||
manifestUrl
|
||||
}
|
||||
|
||||
return withContext(dispatcher) {
|
||||
when (
|
||||
val result =
|
||||
loadWithEmbeddedFallback(freshnessChecker) { attemptFreshnessChecker ->
|
||||
loader.loadOnce(
|
||||
applicationName = "zipline-parser",
|
||||
freshnessChecker = attemptFreshnessChecker,
|
||||
manifestUrl = effectiveManifestUrl,
|
||||
)
|
||||
}
|
||||
) {
|
||||
is LoadResult.Success -> {
|
||||
loadedZipline = result.zipline
|
||||
DispatcherConfinedLobstersParserService(result.zipline.take("LobstersParserService"))
|
||||
}
|
||||
is LoadResult.Failure -> throw result.exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun <T> onZiplineThread(block: () -> T): T {
|
||||
return if (Thread.currentThread() === ziplineThread.get()) {
|
||||
block()
|
||||
} else {
|
||||
runBlocking(dispatcher) { block() }
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DispatcherConfinedLobstersParserService(
|
||||
private val delegate: LobstersParserService
|
||||
) : LobstersParserService {
|
||||
override fun parsePostsPage(html: String) = onZiplineThread { delegate.parsePostsPage(html) }
|
||||
|
||||
override fun parsePostDetails(html: String) = onZiplineThread {
|
||||
delegate.parsePostDetails(html)
|
||||
}
|
||||
|
||||
override fun parseUser(html: String) = onZiplineThread { delegate.parseUser(html) }
|
||||
|
||||
override fun parseTagsPage(html: String) = onZiplineThread { delegate.parseTagsPage(html) }
|
||||
|
||||
override fun parseSearchResults(html: String) = onZiplineThread {
|
||||
delegate.parseSearchResults(html)
|
||||
}
|
||||
|
||||
override fun parseCsrfToken(html: String) = onZiplineThread { delegate.parseCsrfToken(html) }
|
||||
|
||||
override fun parseReplyForm(html: String) = onZiplineThread { delegate.parseReplyForm(html) }
|
||||
|
||||
override fun parseFiltersPage(html: String) = onZiplineThread {
|
||||
delegate.parseFiltersPage(html)
|
||||
}
|
||||
|
||||
override fun close() = onZiplineThread { delegate.close() }
|
||||
}
|
||||
|
||||
// Workaround for the pinned /current manifest being served without the right
|
||||
// Cache-Control headers.
|
||||
private fun evictManifestCacheEntry() {
|
||||
val cache = httpClient.cache ?: return
|
||||
val urls = cache.urls()
|
||||
while (urls.hasNext()) {
|
||||
if (urls.next() == manifestUrl) {
|
||||
urls.remove()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractEmbeddedArtifacts() {
|
||||
val assets = context.assets
|
||||
val ziplineAssets = assets.list("zipline").orEmpty()
|
||||
if (ziplineAssets.isEmpty()) return
|
||||
|
||||
val embeddedDir = File(context.codeCacheDir, "zipline-embedded/zipline")
|
||||
embeddedDir.mkdirs()
|
||||
|
||||
for (assetName in ziplineAssets) {
|
||||
assets.open("zipline/$assetName").use { input ->
|
||||
File(embeddedDir, assetName).outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val ZIPLINE_THREAD_STACK_SIZE_BYTES = 8L * 1024L * 1024L
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB |