Compare commits
952 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72df72228f | ||
|
|
3e26bd2f52 | ||
|
|
f241d74e27 | ||
|
|
ff0f3b414b | ||
|
|
f9fd62db09 | ||
|
|
4795a5ff6e | ||
|
|
4da52e4797 | ||
|
|
46bc0ccbeb | ||
|
|
75f1caf022 | ||
|
|
6f6d2e1e08 | ||
|
|
482b7d5e7d | ||
|
|
1e729d97a0 | ||
|
|
357e348681 | ||
|
|
65c1b4def0 | ||
|
|
461eac8932 | ||
|
|
12d4ec2129 | ||
|
|
26f9737e5e | ||
|
|
771740ca93 | ||
|
|
ed070118d6 | ||
|
|
e7094beab9 | ||
|
|
501a2715be | ||
|
|
bc2380dfb1 | ||
|
|
4b584af0a9 | ||
|
|
4bc1083939 | ||
|
|
b34b5032ee | ||
|
|
d10b70cc62 | ||
|
|
a8ba45c14b | ||
|
|
9d34f0896f | ||
|
|
e492aca885 | ||
|
|
12208b849a | ||
|
|
7f2af61244 | ||
|
|
f745f95286 | ||
|
|
cf1c8eb4e3 | ||
|
|
6d5ab6b4ba | ||
|
|
ab27e36560 | ||
|
|
f368026124 | ||
|
|
bb4c68c6bd | ||
|
|
b675c14767 | ||
|
|
5e1b63ba21 | ||
|
|
5a462df797 | ||
|
|
6d96ab5b7c | ||
|
|
1f051f3fd8 | ||
|
|
e4e91dd283 | ||
|
|
aca0773874 | ||
|
|
2e5d73162d | ||
|
|
0c04701750 | ||
|
|
591ee62cc9 | ||
|
|
1ff3ec5431 | ||
|
|
c8277624d2 | ||
|
|
a7ab35a6b0 | ||
|
|
89d1498956 | ||
|
|
cc585c9b43 | ||
|
|
43a500c150 | ||
|
|
b107523d65 | ||
|
|
b2e01188ee | ||
|
|
bfd3387fa1 | ||
|
|
433161b08d | ||
|
|
b3ab900e47 | ||
|
|
15d400c758 | ||
|
|
2023e55bfd | ||
|
|
d0352b86e0 | ||
|
|
ecdc6c05b0 | ||
|
|
c4e3566ff0 | ||
|
|
109d103cc8 | ||
|
|
115a6378d3 | ||
|
|
264f9a933c | ||
|
|
cc42110d49 | ||
|
|
5e9900378e | ||
|
|
bacd11f4f3 | ||
|
|
dd7aa01b3f | ||
|
|
3cfed238fb | ||
|
|
8725ed9d3c | ||
|
|
30cfd883f5 | ||
|
|
03e0b2af51 | ||
|
|
da155fa5b1 | ||
|
|
fe2adb28e2 | ||
|
|
95a9d44fcf | ||
|
|
4a712b1f26 | ||
|
|
ee8ed127f6 | ||
|
|
c0ba23af3c | ||
|
|
c5197975c5 | ||
|
|
e66751dc06 | ||
|
|
8a219ab58d | ||
|
|
f80f240d4e | ||
|
|
92de40dd23 | ||
|
|
513b29f57d | ||
|
|
a8f4efa929 | ||
|
|
767e414c94 | ||
|
|
7e1f63348a | ||
|
|
0e5c09626f | ||
|
|
0dabe89bcd | ||
|
|
c3e48d20f3 | ||
|
|
1352f884cb | ||
|
|
f1700c9573 | ||
|
|
256eb472c2 | ||
|
|
5d0ebac464 | ||
|
|
b2956a901a | ||
|
|
bc592a97f2 | ||
|
|
414ad1df9c | ||
|
|
868e60db22 | ||
|
|
8d16c31e75 | ||
|
|
83b4a5b222 | ||
|
|
c6483f2de2 | ||
|
|
2884c6a8dd | ||
|
|
8e01c18484 | ||
|
|
f39b204e45 | ||
|
|
121708ca55 | ||
|
|
49c5ae3730 | ||
|
|
6b10ffd71a | ||
|
|
bcdbe6e6e8 | ||
|
|
1df9fbdf21 | ||
|
|
15091f2a15 | ||
|
|
34b719178e | ||
|
|
790ef36b1b | ||
|
|
1cbe0f10c4 | ||
|
|
0b806bfee1 | ||
|
|
bd737e97f1 | ||
|
|
1b8d21a3e0 | ||
|
|
4e0552e978 | ||
|
|
de2280dd2c | ||
|
|
04b4f05642 | ||
|
|
ef05232e4e | ||
|
|
21dda592b4 | ||
|
|
4e42a97b4b | ||
|
|
3a029bd242 | ||
|
|
055bbab36c | ||
|
|
576d37633c | ||
|
|
cbccc231f2 | ||
|
|
2928301a9f | ||
|
|
47e105228a | ||
|
|
3228deec4c | ||
|
|
62a4393e03 | ||
|
|
9fd47f4825 | ||
|
|
1823919e28 | ||
|
|
1037d39168 | ||
|
|
7da0974ef0 | ||
|
|
aa7a307cc1 | ||
|
|
bf44f7ef13 | ||
|
|
1b4afe7ba9 | ||
|
|
832d371b2e | ||
|
|
15984eabdf | ||
|
|
1f72ff3ed7 | ||
|
|
9456a0045a | ||
|
|
0035a41fe9 | ||
|
|
8c3fe2ff52 | ||
|
|
7678d7b809 | ||
|
|
9be424fa77 | ||
|
|
e65dfe16ac | ||
|
|
84c555e3bc | ||
|
|
c4d7018531 | ||
|
|
b2c797fb46 | ||
|
|
b888f71d2a | ||
|
|
6ffdbb7b71 | ||
|
|
7cb40f1556 | ||
|
|
975999df96 | ||
|
|
a6f1943e05 | ||
|
|
91ff898ac7 | ||
|
|
5c705b0f22 | ||
|
|
77a07ffbb1 | ||
|
|
559ec3ecb3 | ||
|
|
17bdd54f63 | ||
|
|
cfdf6ac27e | ||
|
|
4fbf9aa1f8 | ||
|
|
d66463c7c6 | ||
|
|
e2d5d4c30b | ||
|
|
5db90188b2 | ||
|
|
cfe6332005 | ||
|
|
d1a706860f | ||
|
|
29ab502d2e | ||
|
|
629e65edba | ||
|
|
f4c2c5b7f3 | ||
|
|
2b334e973a | ||
|
|
3212d3ce04 | ||
|
|
094acf86c3 | ||
|
|
51e91efb12 | ||
|
|
fe48a9ece2 | ||
|
|
97152ffcdf | ||
|
|
25f8649ee5 | ||
|
|
359b9d04be | ||
|
|
359392d77d | ||
|
|
755b56a8db | ||
|
|
9a99223d19 | ||
|
|
b77ae93290 | ||
|
|
688b6b3f82 | ||
|
|
ff8d2e7010 | ||
|
|
a29672d758 | ||
|
|
7b2c1c1f32 | ||
|
|
7f4a51d1cd | ||
|
|
7486bf4c06 | ||
|
|
97e16b9f73 | ||
|
|
16b3352e65 | ||
|
|
4936127655 | ||
|
|
aa7453855e | ||
|
|
c789c71768 | ||
|
|
37de5b79a8 | ||
|
|
11b24a1821 | ||
|
|
5963b5ab1e | ||
|
|
b4f738cd98 | ||
|
|
d44107d64d | ||
|
|
ad2006c853 | ||
|
|
296c3bbcc8 | ||
|
|
c349293e24 | ||
|
|
c624bb69b6 | ||
|
|
db58e8f214 | ||
|
|
4379f5cd12 | ||
|
|
bc78c61009 | ||
|
|
51b2fc7dc5 | ||
|
|
2bd2dbe624 | ||
|
|
4a1b1e19e8 | ||
|
|
2456f07128 | ||
|
|
e517db3002 | ||
|
|
c7b8cc72fc | ||
|
|
06e832848b | ||
|
|
7848bef09b | ||
|
|
943a7fcff3 | ||
|
|
c67194606f | ||
|
|
2c823b9eb5 | ||
|
|
238abc20b5 | ||
|
|
657e1b9c36 | ||
|
|
7164bca3c8 | ||
|
|
7b7e0e1f8d | ||
|
|
4058a844b7 | ||
|
|
2f8b2e4069 | ||
|
|
21323fd396 | ||
|
|
35efc6a1bc | ||
|
|
8bbef30a1d | ||
|
|
70c61dcbf5 | ||
|
|
6375850da6 | ||
|
|
8a18e2f167 | ||
|
|
61dd972ee5 | ||
|
|
30efd8c45b | ||
|
|
6b31a3e2d6 | ||
|
|
0e868f0be0 | ||
|
|
67847d90da | ||
|
|
730804409a | ||
|
|
567347e66a | ||
|
|
b3f033e30b | ||
|
|
c44dbc82c8 | ||
|
|
00cffd24fe | ||
|
|
51469e1198 | ||
|
|
78f75d4cc1 | ||
|
|
9fbc4ad851 | ||
|
|
bcc62ed222 | ||
|
|
9cc8769d45 | ||
|
|
9ed3e2f897 | ||
|
|
9bf399070a | ||
|
|
dd72723198 | ||
|
|
3f4567fa5f | ||
|
|
065d6398e6 | ||
|
|
68e3e54ae0 | ||
|
|
cae5622d7c | ||
|
|
5e8230291e | ||
|
|
e2c9115d63 | ||
|
|
cae67ad3a9 | ||
|
|
1f7939c90d | ||
|
|
b6b2d873a0 | ||
|
|
f1393ee02e | ||
|
|
316593e32c | ||
|
|
78af4bfb59 | ||
|
|
b0c77d42c0 | ||
|
|
2ec8ddbebe | ||
|
|
bfc0076429 | ||
|
|
1b1a1e4449 | ||
|
|
5fa6ad31a8 | ||
|
|
21e59d9a68 | ||
|
|
5739d8a5ae | ||
|
|
1f7ad8e535 | ||
|
|
ced0112561 | ||
|
|
e0f11440d9 | ||
|
|
75d92cdbbe | ||
|
|
2c6ecb7d42 | ||
|
|
e8391c2463 | ||
|
|
c0d631b3c3 | ||
|
|
89806139a8 | ||
|
|
210b0b3be2 | ||
|
|
49d4421635 | ||
|
|
bf3cae3da7 | ||
|
|
e93730a5dc | ||
|
|
7e544749d5 | ||
|
|
1e23d2f510 | ||
|
|
5bcda74066 | ||
|
|
b599b4d6cc | ||
|
|
3f3610102f | ||
|
|
e4e6ebf91c | ||
|
|
589d011d4c | ||
|
|
325d40c1bf | ||
|
|
f2dc402869 | ||
|
|
c19a0be8f8 | ||
|
|
445fea5237 | ||
|
|
d1b38b926b | ||
|
|
fcd128d8d9 | ||
|
|
0025f812b8 | ||
|
|
d7c7e05ec1 | ||
|
|
c932bafc12 | ||
|
|
a0376b344a | ||
|
|
1cbc117878 | ||
|
|
ea8c1abec9 | ||
|
|
a410eb424f | ||
|
|
8624de5705 | ||
|
|
358d5820f6 | ||
|
|
7c06aecf31 | ||
|
|
5d7544e58c | ||
|
|
17423efb96 | ||
|
|
a28793edd2 | ||
|
|
50760471d5 | ||
|
|
a6df8cb62d | ||
|
|
873711939d | ||
|
|
2bd13eb3ba | ||
|
|
d423f17342 | ||
|
|
a02c99452d | ||
|
|
fd854912fc | ||
|
|
5999991209 | ||
|
|
50ff46465f | ||
|
|
c1040907d9 | ||
|
|
2cae19a75d | ||
|
|
c302473798 | ||
|
|
4107636f00 | ||
|
|
ee07ea9426 | ||
|
|
c806f20f2c | ||
|
|
98b5121a72 | ||
|
|
fb842f72c8 | ||
|
|
982a043342 | ||
|
|
09e1baf19f | ||
|
|
fd2dbcf120 | ||
|
|
9ff0d76c3a | ||
|
|
48f0186151 | ||
|
|
45a945e983 | ||
|
|
971c07a7c2 | ||
|
|
eeb85ae3db | ||
|
|
fe13376a56 | ||
|
|
0d55c1fc37 | ||
|
|
d5ebe761d5 | ||
|
|
9774b2a215 | ||
|
|
ba442511b7 | ||
|
|
e831a01a28 | ||
|
|
884ab72ac6 | ||
|
|
6edb483a5b | ||
|
|
d1c788c639 | ||
|
|
b17680d897 | ||
|
|
707d9d2332 | ||
|
|
9c67d68d05 | ||
|
|
a37ae88d3b | ||
|
|
b8489b0379 | ||
|
|
87e2bdd39d | ||
|
|
52b95ef5f9 | ||
|
|
81375f501e | ||
|
|
d37d977c79 | ||
|
|
654967b6f0 | ||
|
|
8ad48a04a0 | ||
|
|
a4c04bb279 | ||
|
|
d4e0797586 | ||
|
|
8c6009cd63 | ||
|
|
09be5b3f97 | ||
|
|
dee5e3b365 | ||
|
|
8224801024 | ||
|
|
f5377dc276 | ||
|
|
94f6b49487 | ||
|
|
acaacf063e | ||
|
|
881705fa9c | ||
|
|
5440674498 | ||
|
|
c1770c428d | ||
|
|
db3cb118e1 | ||
|
|
085cce211f | ||
|
|
671318a810 | ||
|
|
8accae1105 | ||
|
|
cbf36eb999 | ||
|
|
26e25268f7 | ||
|
|
2d93e98dc7 | ||
|
|
7764ff22dc | ||
|
|
a5e0b96af5 | ||
|
|
f30d58446f | ||
|
|
a4041d3bcd | ||
|
|
e72b7f3d29 | ||
|
|
2bfe5cdfc9 | ||
|
|
1124bc48c2 | ||
|
|
69b7484a4a | ||
|
|
19939e457b | ||
|
|
92603217e7 | ||
|
|
3e256d41d2 | ||
|
|
b6a9c1b879 | ||
|
|
bf839b8d52 | ||
|
|
a13c98e6c9 | ||
|
|
65806d2491 | ||
|
|
0450701548 | ||
|
|
9f1b329a46 | ||
|
|
22a71dd2d2 | ||
|
|
595690aea9 | ||
|
|
deae7d100c | ||
|
|
0397fcb9be | ||
|
|
6df27566d2 | ||
|
|
0b919e3815 | ||
|
|
1d5f07a658 | ||
|
|
571a09364c | ||
|
|
92f94d7a82 | ||
|
|
87cb7dbc39 | ||
|
|
2c3dd3960d | ||
|
|
d1a16a3202 | ||
|
|
3bfb665a76 | ||
|
|
1ebf6eb8f9 | ||
|
|
1af6ce2ce6 | ||
|
|
f0040620c8 | ||
|
|
41e91371d5 | ||
|
|
c2a00f88d9 | ||
|
|
4a0ac11883 | ||
|
|
a50082f738 | ||
|
|
19dc448a08 | ||
|
|
ad06dc14b0 | ||
|
|
c1aa2775a5 | ||
|
|
ec58a7e369 | ||
|
|
973ffb0fe5 | ||
|
|
5e4c993525 | ||
|
|
3ffc427cd6 | ||
|
|
24b065ba03 | ||
|
|
47bd910727 | ||
|
|
a02f654b14 | ||
|
|
d1c6afcebd | ||
|
|
75d64c3c31 | ||
|
|
97b16e879f | ||
|
|
00db01d163 | ||
|
|
56610538ce | ||
|
|
d555f5d3e4 | ||
|
|
89d5b8fb69 | ||
|
|
69e36b0f71 | ||
|
|
9b5ef3ad33 | ||
|
|
ec5dd81018 | ||
|
|
72f546ed15 | ||
|
|
0b48414715 | ||
|
|
ca67c1eaca | ||
|
|
4dd5a80ef2 | ||
|
|
c9f211807a | ||
|
|
440c15d9fa | ||
|
|
86a443c39f | ||
|
|
a8d86db57f | ||
|
|
8359b48285 | ||
|
|
36f4770cae | ||
|
|
932cf91800 | ||
|
|
5cf02e66b7 | ||
|
|
b7251972a8 | ||
|
|
006a423d5c | ||
|
|
374b1edc81 | ||
|
|
0894549687 | ||
|
|
cc5963cc34 | ||
|
|
fffe77501d | ||
|
|
3443c2ae82 | ||
|
|
01324af544 | ||
|
|
d56b1fe89b | ||
|
|
92457d54df | ||
|
|
4d2e30ff85 | ||
|
|
1d0c279956 | ||
|
|
0202ca5b23 | ||
|
|
5e8ebeadc3 | ||
|
|
8bfb0c45a8 | ||
|
|
2c1ecf32ad | ||
|
|
9cc4bd722d | ||
|
|
d5f1e091b8 | ||
|
|
439f3b44cb | ||
|
|
e07bd39e95 | ||
|
|
11fe2ba2a4 | ||
|
|
2b0e507f45 | ||
|
|
84bad0aa6c | ||
|
|
b41cda84e4 | ||
|
|
0ae6fb2833 | ||
|
|
21b6a1f4ef | ||
|
|
69f9da4be4 | ||
|
|
032db0921d | ||
|
|
eef4601ce2 | ||
|
|
71701048f5 | ||
|
|
2361bb7682 | ||
|
|
0b1b8c5c5a | ||
|
|
fb885b0e00 | ||
|
|
a2dec4f7cf | ||
|
|
521797d070 | ||
|
|
d2922dc226 | ||
|
|
ecc2b675d5 | ||
|
|
5a49b650b0 | ||
|
|
5fd57caabf | ||
|
|
bad6afc543 | ||
|
|
ffaa036115 | ||
|
|
65133e969e | ||
|
|
267ee4e03e | ||
|
|
db972ae421 | ||
|
|
ea93dd5b2d | ||
|
|
c4d738844e | ||
|
|
f124d2cabc | ||
|
|
022038878b | ||
|
|
c6a846c602 | ||
|
|
31986a1ce5 | ||
|
|
04eea3b6e4 | ||
|
|
09a5482df5 | ||
|
|
ba8e5a03ea | ||
|
|
ddb3c34078 | ||
|
|
8d4daa5d00 | ||
|
|
65e1787987 | ||
|
|
202f41b34b | ||
|
|
10497f358e | ||
|
|
ffe0dafbdc | ||
|
|
5828be28e8 | ||
|
|
b4e0605016 | ||
|
|
da7686b9b3 | ||
|
|
651d5ae56a | ||
|
|
570d8ce7eb | ||
|
|
a9491e22e4 | ||
|
|
1ba31afa23 | ||
|
|
8f30d0d468 | ||
|
|
4a8cea262b | ||
|
|
e25574ce9a | ||
|
|
9773c1cb98 | ||
|
|
6d3eafe9e8 | ||
|
|
19acab9d18 | ||
|
|
bfcda1d73c | ||
|
|
600fc7939e | ||
|
|
1189aaae4f | ||
|
|
e9c8e8d764 | ||
|
|
3e90620fcc | ||
|
|
62a364a110 | ||
|
|
fbbbe99bf4 | ||
|
|
d078ccc78c | ||
|
|
cbacb6568e | ||
|
|
65f220b570 | ||
|
|
22d83d831d | ||
|
|
ac564a67ca | ||
|
|
d27a8dc29c | ||
|
|
568dfe911e | ||
|
|
64a647ca84 | ||
|
|
6b3c3ac9b0 | ||
|
|
6438df92c6 | ||
|
|
5f98fdfafc | ||
|
|
4a1c8aadf8 | ||
|
|
441567f9d2 | ||
|
|
381fd434ad | ||
|
|
89f713899b | ||
|
|
a82c61791e | ||
|
|
734c1ddab6 | ||
|
|
1d0b31e9de | ||
|
|
1441036475 | ||
|
|
7a84352723 | ||
|
|
6f153f3879 | ||
|
|
f888091e22 | ||
|
|
e59cf2afca | ||
|
|
7516b8e662 | ||
|
|
bb4a480250 | ||
|
|
c6df18c456 | ||
|
|
cb945998d3 | ||
|
|
5622c93bd9 | ||
|
|
7552227da0 | ||
|
|
1904fce32d | ||
|
|
f1e5e572f4 | ||
|
|
d8f83170be | ||
|
|
95c135b270 | ||
|
|
8408daf070 | ||
|
|
bd8eb6a034 | ||
|
|
9c4d0ef85e | ||
|
|
1ee441314f | ||
|
|
d5d1e51bbc | ||
|
|
84aa99ba88 | ||
|
|
23e02d2c24 | ||
|
|
3ea66c6c4c | ||
|
|
50cf737db6 | ||
|
|
bf55b5a802 | ||
|
|
49bf04c6c6 | ||
|
|
f5d64f3882 | ||
|
|
48d7de53c0 | ||
|
|
d53bace4ce | ||
|
|
02c800496c | ||
|
|
0ce39946cb | ||
|
|
52b573d20f | ||
|
|
5be6faa07c | ||
|
|
ee05e818d9 | ||
|
|
29d5e4fa13 | ||
|
|
9bd830b368 | ||
|
|
7d70f816d1 | ||
|
|
0d0cf04b57 | ||
|
|
4a6e514b81 | ||
|
|
b74f9092e7 | ||
|
|
a061347d76 | ||
|
|
71586b1100 | ||
|
|
809aa6afd2 | ||
|
|
0067a036ae | ||
|
|
794d3329fe | ||
|
|
f96ed6c56f | ||
|
|
92d44eebe6 | ||
|
|
93fe734636 | ||
|
|
71a5c132f4 | ||
|
|
15a514aca5 | ||
|
|
71f74ced7d | ||
|
|
5c86c911c1 | ||
|
|
7aebc44062 | ||
|
|
6f6e1f1009 | ||
|
|
2e7f17b823 | ||
|
|
5194dae9a6 | ||
|
|
2db03669ce | ||
|
|
a6c4f83973 | ||
|
|
f625cea183 | ||
|
|
d6ee9db6ff | ||
|
|
9ea94ce177 | ||
|
|
8102163c5b | ||
|
|
1034c16bfb | ||
|
|
956f9547e7 | ||
|
|
95fb241da1 | ||
|
|
7b06af4c8b | ||
|
|
1ca0ff53c8 | ||
|
|
1e0bdf44c2 | ||
|
|
c611d6386a | ||
|
|
b326dc3bc2 | ||
|
|
7f6e21450a | ||
|
|
ee911a15c6 | ||
|
|
aa0e05f085 | ||
|
|
f1fe078cf2 | ||
|
|
e53dcf27ec | ||
|
|
6e9ce8d5a5 | ||
|
|
686d88557b | ||
|
|
b07f14d01b | ||
|
|
c200a72031 | ||
|
|
2a8ff3e50a | ||
|
|
06cde138c1 | ||
|
|
9fae62f289 | ||
|
|
a1333929e9 | ||
|
|
6a05fafe04 | ||
|
|
13c6fc60f8 | ||
|
|
ff7948ad83 | ||
|
|
3972ab207c | ||
|
|
33a8f1dab4 | ||
|
|
27f261ae4a | ||
|
|
ef59331dd3 | ||
|
|
b019731249 | ||
|
|
47aa7fc191 | ||
|
|
9356b26dfd | ||
|
|
41b626ddbd | ||
|
|
f76b41581b | ||
|
|
14f08b7759 | ||
|
|
8c1cec09d6 | ||
|
|
15c10cb14c | ||
|
|
3b2f68a400 | ||
|
|
2a9e4e0b82 | ||
|
|
7b25628f7a | ||
|
|
e06dd1e465 | ||
|
|
a42ab10aed | ||
|
|
cd2ea90006 | ||
|
|
f0295edd83 | ||
|
|
eee0a40224 | ||
|
|
bd189ae322 | ||
|
|
714aa7a20b | ||
|
|
76c6c65018 | ||
|
|
c511b54de2 | ||
|
|
16b4a1f1a3 | ||
|
|
afb5743d31 | ||
|
|
7e91c311d4 | ||
|
|
eef9f61e20 | ||
|
|
0cc55891c1 | ||
|
|
e892eaa3d5 | ||
|
|
bcdce2a880 | ||
|
|
9f38809730 | ||
|
|
77b416a52d | ||
|
|
0684b0ec79 | ||
|
|
20057a55f0 | ||
|
|
a668dee567 | ||
|
|
11bda2fe07 | ||
|
|
a7b83bc058 | ||
|
|
19950e5115 | ||
|
|
11be65c6fe | ||
|
|
b30ce84468 | ||
|
|
c242c7ec82 | ||
|
|
f128556a49 | ||
|
|
3cebc78443 | ||
|
|
c640501430 | ||
|
|
48100a19e1 | ||
|
|
102ca8034a | ||
|
|
c1d8f5904b | ||
|
|
361086fc48 | ||
|
|
10ebacf9cf | ||
|
|
02bfb34665 | ||
|
|
9f9ba7c367 | ||
|
|
4430822768 | ||
|
|
b4904024c6 | ||
|
|
0d047e5cf9 | ||
|
|
58259d9fb0 | ||
|
|
e9dde114b7 | ||
|
|
03c6f4130b | ||
|
|
bb94c7fff4 | ||
|
|
217884aaec | ||
|
|
17508e1f36 | ||
|
|
95b33b35df | ||
|
|
4c0ae74f2d | ||
|
|
9ef9110297 | ||
|
|
71e0115cd6 | ||
|
|
ec045abf53 | ||
|
|
b8ae79faa1 | ||
|
|
b51cccf1d7 | ||
|
|
87b78acd39 | ||
|
|
a965862c1b | ||
|
|
ce427d2a0b | ||
|
|
8ea4c84a29 | ||
|
|
0bb66708c6 | ||
|
|
bf4b04ef5f | ||
|
|
eea2f9c0cd | ||
|
|
3cdc27eb94 | ||
|
|
75237ab7dc | ||
|
|
7601a7de16 | ||
|
|
3745240eea | ||
|
|
963b80cce9 | ||
|
|
8812500734 | ||
|
|
bd41807f3b | ||
|
|
90ee102eaa | ||
|
|
0bac414151 | ||
|
|
36bd1db67e | ||
|
|
d15f3e5daf | ||
|
|
4c9c444dfc | ||
|
|
0dc23789f5 | ||
|
|
3f0ebd4aed | ||
|
|
1e501c707c | ||
|
|
d57f97b492 | ||
|
|
29f9214869 | ||
|
|
c6c1fe3595 | ||
|
|
2ba4c6f443 | ||
|
|
a9ff948818 | ||
|
|
7dd9cfa7f0 | ||
|
|
15a415558b | ||
|
|
b3e53bc48d | ||
|
|
d0e33c8a12 | ||
|
|
060db42e47 | ||
|
|
6bcf259de9 | ||
|
|
4d0a673209 | ||
|
|
be9be6dc35 | ||
|
|
3f47497c12 | ||
|
|
268e0af7cb | ||
|
|
c611f723b7 | ||
|
|
4c9f500122 | ||
|
|
15147dddf5 | ||
|
|
747c81c269 | ||
|
|
63ad076046 | ||
|
|
f109033ed2 | ||
|
|
9445d3383a | ||
|
|
5db5537685 | ||
|
|
1149d1c37f | ||
|
|
e4b77551f7 | ||
|
|
401fd298f5 | ||
|
|
6a1f7f7238 | ||
|
|
7c1c0894a8 | ||
|
|
cb96ae6cbc | ||
|
|
c15d972359 | ||
|
|
c010e2371c | ||
|
|
f88a244594 | ||
|
|
e2dccda205 | ||
|
|
bc6c6bc9a5 | ||
|
|
3281eabbe1 | ||
|
|
92de091228 | ||
|
|
94ab8a4e8b | ||
|
|
799987205e | ||
|
|
f5dfe70ac6 | ||
|
|
69b03e2e59 | ||
|
|
588ebe11f4 | ||
|
|
4fe0d6e893 | ||
|
|
787c20794f | ||
|
|
c462e72279 | ||
|
|
be7e6afce2 | ||
|
|
feb9920829 | ||
|
|
4d6a1a705d | ||
|
|
59d54469dc | ||
|
|
9cccd28447 | ||
|
|
da13b4602c | ||
|
|
2084a33192 | ||
|
|
542ce40c1a | ||
|
|
ddd4ea329c | ||
|
|
b5c3875ce9 | ||
|
|
ec8387d9db | ||
|
|
cf464e8eea | ||
|
|
7b66f1c398 | ||
|
|
814e11c1f4 | ||
|
|
647277444b | ||
|
|
bd4ff9c7ec | ||
|
|
7ed6195cca | ||
|
|
43397f069a | ||
|
|
8e34e31b35 | ||
|
|
7ef1bdfc2a | ||
|
|
726f91ec75 | ||
|
|
55a5268a3c | ||
|
|
4777e3d0b7 | ||
|
|
bfc970abe5 | ||
|
|
d2d35f4f39 | ||
|
|
49cef5ea0a | ||
|
|
f2fbf55c53 | ||
|
|
eacfd2fa4f | ||
|
|
51f87848f4 | ||
|
|
c3f4637ddc | ||
|
|
2361394391 | ||
|
|
38fdf8d53e | ||
|
|
0cd3cab65d | ||
|
|
142c8b55a1 | ||
|
|
3274acaba8 | ||
|
|
99b7d46ddf | ||
|
|
4f7e45211f | ||
|
|
221773bf71 | ||
|
|
305ce2ca42 | ||
|
|
7dc2480400 | ||
|
|
9634ec6616 | ||
|
|
6f29540170 | ||
|
|
18485c1cf8 | ||
|
|
7c36c32e4b | ||
|
|
1d0af51813 | ||
|
|
d50060d602 | ||
|
|
9d455b2331 | ||
|
|
5f3b537f7d | ||
|
|
1d43ed4c8a | ||
|
|
e839323575 | ||
|
|
7ee657012e | ||
|
|
847fbed956 | ||
|
|
a89d5df313 | ||
|
|
5f58789008 | ||
|
|
7b64cad668 | ||
|
|
70f60f094d | ||
|
|
a5eff14552 | ||
|
|
36fb929387 | ||
|
|
66aabfb386 | ||
|
|
f503973db1 | ||
|
|
7cec835509 | ||
|
|
e300942455 | ||
|
|
79476eff85 | ||
|
|
b4ec470691 | ||
|
|
4f72a0c74e | ||
|
|
bcbb41aa43 | ||
|
|
5cc38d845a | ||
|
|
36acc1588a | ||
|
|
349c5200ed | ||
|
|
ff71f6a092 | ||
|
|
8863284970 | ||
|
|
d5feb4e9f9 | ||
|
|
4c284226e5 | ||
|
|
fef9cf5e64 | ||
|
|
93f3c9a9eb | ||
|
|
5827c77b0c | ||
|
|
1b36866fba | ||
|
|
ebc10d5052 | ||
|
|
8953fa48c7 | ||
|
|
59e7a296ca | ||
|
|
86432228a3 | ||
|
|
4518566c37 | ||
|
|
b392a89350 | ||
|
|
225682f35d | ||
|
|
b41ff2e18f | ||
|
|
6ef76fb5bb | ||
|
|
a36679b032 | ||
|
|
dde91778a2 | ||
|
|
1cdc6f4fcf | ||
|
|
b8a5346631 | ||
|
|
5cf222379a | ||
|
|
8f4ff49b32 | ||
|
|
3a68ca3cc0 | ||
|
|
116dc68a38 | ||
|
|
df84b0ac34 | ||
|
|
5ef737766c | ||
|
|
be17ba870b | ||
|
|
747b2d5801 | ||
|
|
b84c9bf948 | ||
|
|
b1e999cc9c | ||
|
|
89951a8547 | ||
|
|
1bb0ac1110 | ||
|
|
5bb51901f7 | ||
|
|
18562cd3ee | ||
|
|
9e01270b1e | ||
|
|
0b1ff9730c | ||
|
|
845cfde58e | ||
|
|
f81f264b37 | ||
|
|
5e14270c47 | ||
|
|
6d2427b336 | ||
|
|
d89562a4c0 | ||
|
|
08389b023d | ||
|
|
797c4d6baa | ||
|
|
9458ddd490 | ||
|
|
8f3a8af35e | ||
|
|
d0f927c8d2 | ||
|
|
2de44c8d7f | ||
|
|
9a3ab2f4d2 | ||
|
|
7cecd689bb | ||
|
|
b79cf4e087 | ||
|
|
63a9ce6eb6 | ||
|
|
1f960e8631 | ||
|
|
6dc059646e | ||
|
|
5b37db0f8e | ||
|
|
2789dd9fd1 | ||
|
|
a2a72a4aee | ||
|
|
b79fc8132a | ||
|
|
91b8735a4c | ||
|
|
313d81ffe1 | ||
|
|
192b32c1c6 | ||
|
|
2ef8be7c59 | ||
|
|
94e8d5e6d9 | ||
|
|
445600653e | ||
|
|
4349c7a9e7 | ||
|
|
c2b2c39c8a | ||
|
|
9dee6eea24 | ||
|
|
53355b31ea | ||
|
|
c2f55675a8 | ||
|
|
42f3c58d02 | ||
|
|
dac2c413d6 | ||
|
|
a539eb3768 | ||
|
|
481610cd10 | ||
|
|
fa78a0f6ca | ||
|
|
bcc96ff329 | ||
|
|
bb3028fff6 | ||
|
|
3e66ce8949 | ||
|
|
5f89fb1e49 | ||
|
|
b5b3c2671a | ||
|
|
6a8c09c113 | ||
|
|
9660a2a019 | ||
|
|
f667b657f6 | ||
|
|
9db309634e | ||
|
|
8a96762bcc | ||
|
|
d58b24722e | ||
|
|
fe8904b7a5 | ||
|
|
6f3404aac9 | ||
|
|
9a81f720c2 | ||
|
|
3605ad4616 | ||
|
|
490ecfcb43 | ||
|
|
02b8ac55d5 | ||
|
|
fd71f04ca5 | ||
|
|
091953ada8 | ||
|
|
8707db891a | ||
|
|
1c67cb5edb | ||
|
|
a96567c329 | ||
|
|
691372119a | ||
|
|
25734af54e | ||
|
|
b37f9abeae | ||
|
|
f502374533 | ||
|
|
f10da18272 | ||
|
|
940f2ca73f | ||
|
|
db3192e75a | ||
|
|
1841568e7e | ||
|
|
f4ce0e67ac | ||
|
|
92b34f085e | ||
|
|
4e4eb05526 | ||
|
|
4dc707871d | ||
|
|
e9562378b4 | ||
|
|
c1b9aa7826 | ||
|
|
e979a348be | ||
|
|
89c5787ad6 | ||
|
|
f0b2329656 | ||
|
|
6cf9969220 | ||
|
|
1f37e7605e | ||
|
|
cf6af6f912 | ||
|
|
2c91adb03e | ||
|
|
911da90854 | ||
|
|
27f0235055 | ||
|
|
f3764222d8 | ||
|
|
64cb8c4a9a | ||
|
|
de78356f5a | ||
|
|
8a9d39397c | ||
|
|
2d89fd0cf0 | ||
|
|
402620dbe4 | ||
|
|
32776db395 | ||
|
|
4523ab8a67 | ||
|
|
760106bf5b | ||
|
|
f705afcafc |
79
.github/workflows/build_and_deploy.yml
vendored
Normal file
79
.github/workflows/build_and_deploy.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Build and deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'ci_setup'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: 21
|
||||
distribution: temurin
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.3.0
|
||||
bundler-cache: true
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Decode keystore
|
||||
uses: timheuer/base64-to-file@v1
|
||||
id: android_keystore
|
||||
with:
|
||||
fileName: "release.jks"
|
||||
encodedString: ${{ secrets.KEYSTORE_FILE }}
|
||||
|
||||
- name: Prepare Gradle environment
|
||||
run: |
|
||||
echo "apply from: 'ci_signing.gradle'" >> mastodon/build.gradle
|
||||
echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties
|
||||
|
||||
- name: Build and deploy to Google Play
|
||||
run: bundle exec fastlane deploy
|
||||
env:
|
||||
KEYSTORE_FILE: ${{ steps.android_keystore.outputs.filePath }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
SUPPLY_JSON_KEY_DATA: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
|
||||
SUPPLY_SKIP_UPLOAD_METADATA: true
|
||||
SUPPLY_SKIP_UPLOAD_CHANGELOGS: true
|
||||
|
||||
- name: Build release apk
|
||||
run: ./gradlew assembleRelease
|
||||
env:
|
||||
KEYSTORE_FILE: ${{ steps.android_keystore.outputs.filePath }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
|
||||
- name: Upload release apk
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mastodon-release.apk
|
||||
path: mastodon/build/outputs/apk/release/mastodon-release.apk
|
||||
|
||||
- name: Build githubRelease apk
|
||||
run: ./gradlew assembleGithubRelease
|
||||
env:
|
||||
KEYSTORE_FILE: ${{ steps.android_keystore.outputs.filePath }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
|
||||
- name: Upload githubRelease apk
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mastodon-githubRelease.apk
|
||||
path: mastodon/build/outputs/apk/githubRelease/mastodon-githubRelease.apk
|
||||
|
||||
- name: Upload mappings
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mappings
|
||||
path: mastodon/build/outputs/mapping/*/mapping.txt
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@
|
||||
.cxx
|
||||
local.properties
|
||||
*.jks
|
||||
/fastlane/report.xml
|
||||
|
||||
218
Gemfile.lock
Normal file
218
Gemfile.lock
Normal file
@@ -0,0 +1,218 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.913.0)
|
||||
aws-sdk-core (3.191.6)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.78.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.146.1)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.110.0)
|
||||
faraday (1.10.3)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (1.0.1)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.3.1)
|
||||
fastlane (2.220.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.7.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.4.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.2)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.0)
|
||||
nanaimo (0.3.0)
|
||||
naturally (2.2.1)
|
||||
nkf (0.2.0)
|
||||
optparse (0.4.0)
|
||||
os (1.1.4)
|
||||
plist (3.7.1)
|
||||
public_suffix (5.0.5)
|
||||
rake (13.2.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.6)
|
||||
rouge (2.0.7)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.5)
|
||||
signet (0.19.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.5.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.24.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.3.0)
|
||||
rexml (~> 3.2.4)
|
||||
xcpretty (0.3.0)
|
||||
rouge (~> 2.0.7)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-22
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.4
|
||||
@@ -5,7 +5,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath "com.android.tools.build:gradle:7.4.2"
|
||||
classpath "com.android.tools.build:gradle:8.2.2"
|
||||
classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1"
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
2
fastlane/Appfile
Normal file
2
fastlane/Appfile
Normal file
@@ -0,0 +1,2 @@
|
||||
json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
|
||||
package_name("org.joinmastodon.android") # e.g. com.krausefx.app
|
||||
36
fastlane/Fastfile
Normal file
36
fastlane/Fastfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# This file contains the fastlane.tools configuration
|
||||
# You can find the documentation at https://docs.fastlane.tools
|
||||
#
|
||||
# For a list of all available actions, check out
|
||||
#
|
||||
# https://docs.fastlane.tools/actions
|
||||
#
|
||||
# For a list of all available plugins, check out
|
||||
#
|
||||
# https://docs.fastlane.tools/plugins/available-plugins
|
||||
#
|
||||
|
||||
# Uncomment the line if you want fastlane to automatically update itself
|
||||
# update_fastlane
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
desc "Runs all the tests"
|
||||
lane :test do
|
||||
gradle(task: "test")
|
||||
end
|
||||
|
||||
desc "Deploy a new version to the Google Play"
|
||||
lane :deploy do
|
||||
gradle(
|
||||
task: "bundle",
|
||||
build_type: "release",
|
||||
)
|
||||
upload_to_play_store(
|
||||
changes_not_sent_for_review: true,
|
||||
skip_upload_images: true,
|
||||
skip_upload_screenshots: true
|
||||
)
|
||||
end
|
||||
end
|
||||
40
fastlane/README.md
Normal file
40
fastlane/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## Android
|
||||
|
||||
### android test
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android test
|
||||
```
|
||||
|
||||
Runs all the tests
|
||||
|
||||
### android deploy
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android deploy
|
||||
```
|
||||
|
||||
Deploy a new version to the Google Play
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
2
fastlane/metadata/android/en-US/changelogs/102.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/102.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
- Updated look for link previews and the News tab
|
||||
- Bug fixes and improvements
|
||||
3
fastlane/metadata/android/en-US/changelogs/86.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/86.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
- You can now easily share and scan QR codes to quickly find each other
|
||||
- We've updated the look of the tab bar to better match current platform guidelines
|
||||
- Various minor usability improvements
|
||||
3
fastlane/metadata/android/en-US/changelogs/88.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/88.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
- QR code scanner can now be accessed from the explore page
|
||||
- You can now pin posts to your profile
|
||||
- Fixed featured hashtags on profiles not showing the profile's own posts exclusively
|
||||
8
fastlane/metadata/android/en-US/changelogs/93.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/93.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
- Adjust filters in the Notifications tab to silence unwanted alerts*
|
||||
- Opt into push notifications when a user posts by tapping the bell 🔔 in the top corner of a user's profile.
|
||||
- Mute overly active conversation notifications via the More button ⋮ on your posts
|
||||
- When writing a post, choose Quiet public 🌙 visibility to avoid appearing in feeds or searches
|
||||
- Improved post legibility with adjusted line height
|
||||
- Bug fixes
|
||||
|
||||
*Your server must support filtered notifications to see this option.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 46 KiB |
@@ -1,4 +1,4 @@
|
||||
Mastodon est le plus grand réseau social décentralisé sur Internet. Au lieu d’un site Web unique, c’est un réseau de millions d’utilisateurs dans des communautés indépendantes qui peuvent tous interagir les uns avec les autres, de manière transparente. Peu importe ce que vous êtes, vous pouvez rencontrer des gens passionnés qui publient à ce sujet sur Mastodon !
|
||||
Mastodon est le plus grand réseau social décentralisé sur Internet. Au lieu d’un site Web unique, c’est un réseau de millions d’utilisateurs dans des communautés indépendantes qui peuvent tous interagir les uns avec les autres, de manière transparente. Peu importe ce que vous aimez, vous pouvez rencontrer des gens passionnés qui en parlent sur Mastodon !
|
||||
|
||||
Rejoignez une communauté et créez votre profil. Trouvez et suivez des personnes fascinantes et lisez leurs messages chronologiquement et sans publicité. Exprimez-vous avec des émojis personnalisés, des images, des GIFs, des vidéos et de l’audio dans des messages de 500 caractères. Répondez aux sujets de discussions et aux reblogues de n’importe qui pour partager des choses géniales. Trouvez de nouveaux comptes à suivre et des hashtags tendance pour étendre votre réseau.
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
A Mastodon a legnagyobb decentralizált közösségi hálózat az interneten. Egyetlen weboldal helyett, ez több millió felhasználóból álló, független közösségek hálózata, amelyek egymással kapcsolatba tudnak lépni, zökkenőmentesen. Nem számít, mi az érdeklődésed, a Mastodonon találkozhatsz róla posztoló lelkes emberekkel!
|
||||
|
||||
Csatlakozz egy közösséghez és készítsd el a profilodat. Találj meg és kövess be fantasztikus embereket, olvasd el a bejegyzéseiket reklámmentesen, időrendben. Fejezd ki magad egyedi hangulatjelekkel, képekkel, GIFekkel, videókkal és hanggal, 500 karakter hosszúságú bejegyzésekben. Válaszolj szálakban, told meg bárki bejegyzését, hogy megoszthass szuper dolgokat. Fedezz fel új fiókokat amiket követhetsz és felkapott hashtageket, hogy bővíthesd a kapcsolataidat.
|
||||
Csatlakozz egy közösséghez és készítsd el a profilodat. Találj meg és kövess fantasztikus embereket, olvasd el a bejegyzéseiket reklámmentesen, időrendben. Fejezd ki magad egyedi hangulatjelekkel, képekkel, GIFekkel, videókkal és hanggal, 500 karakter hosszúságú bejegyzésekben. Válaszolj szálakban, told meg bárki bejegyzését, hogy megoszthass szuper dolgokat. Fedezz fel új fiókokat amiket követhetsz és felkapott hashtageket, hogy bővíthesd a kapcsolataidat.
|
||||
|
||||
A Mastodon az adatvédelemre és a biztonságra összpontosítva épült. Döntsd el, hogy a bejegyzéseidet csak a követőiddel, csak azokkal akiket megemlítesz, vagy az egész világgal osztod meg. A tartalomfigyelmeztetések elrejthetővé teszik az érzékeny vagy ingerlő tartalmakat addig, amíg nem vagy kész azok megtekintésére. Minden közösségnek saját irényelvei és moderátorai vannak arra, hogy biztonságban tudják a tagjaikat. Erőteljes letiltási és bejelentési eszközök segítik a visszaélések megelőzését.
|
||||
A Mastodon az adatvédelemre és a biztonságra összpontosítva épült. Döntsd el, hogy a bejegyzéseidet csak a követőiddel, csak azokkal akiket megemlítesz, vagy az egész világgal osztod meg. A tartalmi figyelmeztetések elrejthetővé teszik az érzékeny vagy ingerlő tartalmakat addig, amíg nem vagy kész azok megtekintésére. Minden közösségnek saját irányelvei és moderátorai vannak arra, hogy biztonságban tudják a tagjaikat. Robusztus letiltási és jelentési eszközök segítik a visszaélések megelőzését.
|
||||
|
||||
További funkciók:
|
||||
|
||||
• Sötét mód: Olvasd a bejegyzéseket világos, sötét vagy teljesen fekete módban
|
||||
• Sötét mód: olvasd a bejegyzéseket világos, sötét vagy valódi fekete módban
|
||||
• Szavazás: Kérd ki a követőid véleményét és összesítsd a szavazataikat
|
||||
• Felfedezés: A felkapott hashtagek és fiókok egyetlen kattintásra vannak
|
||||
• Értesítések: Értesülj az új követőidről, válaszokról, megtolásokról
|
||||
• Megosztás: Írj bejegyzést Mastodonra bármely app megosztási funkciójával
|
||||
• Cukiság: A kabalánk egy cuki elefánt, mely fel fog bukkanni időről időre
|
||||
|
||||
A Mastodon egy bejegyzett non-profit szervezet, a fejlesztés közvetlenül a te adományaidból történik. Nincs hirdetés, se monetizáció, se kockázati tőke, és ez így is fog maradni.
|
||||
A Mastodon egy bejegyzett nonprofit szervezet, a fejlesztés közvetlenül a felhasználók adományaiból történik. Nincs hirdetés, se monetizáció, se kockázati tőke, és ez így is fog maradni.
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Զգայուն կամ հրահրող թեմաներով գրառումները կարելի է թաքցնել նախազգուշացումներով։ Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
|
||||
|
||||
Ավելին՝
|
||||
Ավելին.
|
||||
|
||||
• Մուգ տարբերակ՝ կարդացեք գրառումներ մուգ, բաց կամ իսկական սև տարբերակներում
|
||||
• Հարցումներ՝ իմացեք ձեր հետևորդների կարծիքը և հաշվեք ձայները
|
||||
• Մուգ տարբերակ. կարդացեք գրառումներ մուգ, բաց կամ իսկական սև տարբերակներում
|
||||
• Հարցումներ. իմացեք ձեր հետևորդների կարծիքը և հաշվեք ձայները
|
||||
• Explore: Trending hashtags and accounts are a tap away
|
||||
• Notifications: Get notified about new follows, replies, and reblogs
|
||||
• Հիշեցումներ` ստացեք հիշեցումներ նոր հետևումների, մեկնաբանությունների և կիսումների մասին
|
||||
• Sharing: Post directly to Mastodon from any share sheet in any app
|
||||
• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
|
||||
|
||||
|
||||
16
fastlane/metadata/android/ia/full_description.txt
Normal file
16
fastlane/metadata/android/ia/full_description.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Mastodon es le rete social decentralisate le plus grande sur internet. In vice que un sol sito web, illo es un rete de milliones de usatores in communitates independente que pote interager le unes con le alteres, transparentemente. Non importa lo que te interessa, tu pote incontrar personas passionate que parla de illo sur Mastodon!
|
||||
|
||||
Inscribe te a un communitate e crea tu profilo. Trova e seque personas fascinante e lege lor messages in un chronologia libere de publicitate. Exprime te con emojis personalisate, imagines, GIFs, videos e audio in messages de 500 characteres. Responde a discussiones e impulsa messages de quicunque pro compartir cosas genial. Trova nove contos a sequer e hashtags de tendentia pro expander tu rete.
|
||||
|
||||
Mastodon es producite con attention a confidentialitate e securitate. Decide si tu messages es compartite con tu sequitores, con solo le gente que tu mentiona, o con tote le mundo. Le advertimentos de contento te permitte celar messages que contine material sensibile o provocatori usque tu es preste a interager con illos. Cata communitate ha su proprie directivas e moderatores pro mantener su membros secur, e robuste utensiles de blocage e de reportage pro adjutar a impedir abusos.
|
||||
|
||||
Altere functiones:
|
||||
|
||||
• Modo obscur: lege messages in modo clar, obscur, o vermente nigre
|
||||
• Sondages: Demanda al sequitores lor opinion e conta le votos
|
||||
• Explorar: le hashtags e contos popular es a portata de mano
|
||||
• Notificationes: sia notificate de nove sequitores, responsas e impulsos
|
||||
• Compartir: publica directemente sur Mastodon desde le function Compartir de qualcunque app
|
||||
• Pachydermo: nostre mascotte es un adorabile elephante, e tu lo videra apparer de tempore in tempore
|
||||
|
||||
Mastodon es un organisation registrate sin scopo lucrative e le disveloppamento es sustenite directemente per tu donationes. Il non ha publicitate, ni monetisation, ni capital de risco, e nos intende mantener lo assi.
|
||||
1
fastlane/metadata/android/ia/short_description.txt
Normal file
1
fastlane/metadata/android/ia/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Rete social decentralisate
|
||||
1
fastlane/metadata/android/ia/title.txt
Normal file
1
fastlane/metadata/android/ia/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Mastodon
|
||||
@@ -1,6 +1,6 @@
|
||||
Mastodon dalah jaringan sosial terdesentralisasi terbesar di internet. Bukan hanya satu situs web, ini adalah jaringan dari jutaan pengguna dalam komunitas tersendiri yang dapat saling interaksi antar sesama, tanpa batasan. Apapun yang kamu minati, kamu dapat bertemu orang-orang baru yang mengirimkan apa yang mereka minati di Mastodon!
|
||||
|
||||
Bergabunglah dalam sebuah komunitas dan buat profil kalian. Temukan dan ikuti orang-orang yang menarik dan baca postingan mereka dalam linimasa yang kronologis serta bebas iklan. Ekspresikan diri Anda dengan emoji kustom, gambar, GIF, video, dan audio dalam kiriman dengan batasan 500 karakter. Balas ke utasan dan bagikan kiriman dari siapa pun ke pengikut Anda untuk membagikan hal-hal yang keren. Temukan akun baru untuk diikuti dan tagar yang sedang tren untuk memperluas jejaring Anda.
|
||||
Bergabunglah dalam sebuah komunitas dan buat profil kalian. Temukan dan ikuti orang-orang yang menarik dan baca postingan mereka dalam lini masa yang kronologis serta bebas iklan. Ekspresikan diri Anda dengan emoji kustom, gambar, GIF, video, dan audio dalam kiriman dengan batasan 500 karakter. Balas ke utasan dan bagikan kiriman dari siapa pun ke pengikut Anda untuk membagikan hal-hal yang keren. Temukan akun baru untuk diikuti dan tagar yang sedang tren untuk memperluas jejaring Anda.
|
||||
|
||||
Mastodon dibuat dengan fokus pada privasi dan keamanan. Tentukan apakah kiriman Anda dibagikan kepada pengikut, hanya orang yang disebut, atau seluruh dunia. Peringatan konten memungkinkan Anda untuk menyembunyikan kiriman yang berisi material sensitif atau memicu sampai Anda siap untuk terlibat dengan mereka. Setiap komunitas memiliki pedoman dan moderator sendiri-sendiri untuk menjaga anggotanya aman, dan alat pemblokiran dan pelaporan yang kokoh membantu mencegah pelecehan.
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
Mastodon – didžiausias decentralizuotas socialinis tinklas internete. Vietoj vienos svetainės tai yra milijonų naudotojų, priklausančių nepriklausomoms bendruomenėms, kurios gali sklandžiai bendrauti tarpusavyje, tinklas. Nesvarbu, kuo domiesi, Mastodon gali sutikti aistringų žmonių, skelbiančių apie tai!
|
||||
„Mastodon“ – tai didžiausias decentralizuotas socialinis tinklas internete. Vietoj vienos svetainės tai yra milijonų naudotojų, priklausančių nepriklausomoms bendruomenėms, kurios gali sklandžiai bendrauti tarpusavyje, tinklas. Nesvarbu, kuo domiesi, „Mastodon“ platformoje gali sutikti aistringų žmonių, skelbiančių apie tai!
|
||||
|
||||
Prisijunk prie bendruomenės ir susikurk savo profilį. Rask ir sek žavius žmones bei skaityk jų įrašus chronologinėje laiko skalėje be reklamų. Išreikšk save su pasirinktais jaustukais, vaizdais, GIF, vaizdo ir garso įrašais 500 simbolių įrašuose. Atsakyk į gijas ir perrašyk bet kurio asmens įrašus, kad galėtum dalytis puikiais dalykais. Ieškok naujų paskyrų sekti ir tendencingų saitažodžių, kad praplėstum savo tinklą.
|
||||
Prisijunk prie bendruomenės ir susikurk savo profilį. Rask ir sek žavius žmones bei skaityk jų įrašus chronologinėje laiko skalėje be reklamų. Išreikšk save su pasirinktais jaustukais, vaizdais, GIF, vaizdo ir garso įrašais 500 simbolių įrašuose. Atsakyk į gijas ir perdalyk bet kurio asmens įrašus, kad galėtum bendrinti puikiais dalykais. Ieškok naujų paskyrų sekti ir tendencingų saitažodžių, kad praplėstum savo tinklą.
|
||||
|
||||
Mastodon sukurtas daugiausia dėmesio skiriant privatumui ir saugumui. Nuspręsk, ar tavo įrašai bus bendrinami tavo sekėjams, tik tavo paminėtiems žmonėms, ar visam pasauliui. Turinio įspėjimai leidžia paslėpti įrašus, kuriuose yra jautrios ar dirginančios medžiagos, kol būsi pasiruošęs (-usi) su jais bendrauti. Kiekviena bendruomenė turi savo gaires ir prižiūrėtojus, kad jos nariai būtų saugūs, o patikimi blokavimo ir pranešimo įrankiai padeda užkirsti kelią piktnaudžiavimui.
|
||||
„Mastodon“ sukurtas daugiausia dėmesio skiriant privatumui ir saugumui. Nuspręsk, ar tavo įrašai bus bendrinami tavo sekėjams, tik tavo paminėtiems žmonėms, ar visam pasauliui. Turinio įspėjimai leidžia paslėpti įrašus, kuriuose yra jautrios ar dirginančios medžiagos, kol būsi pasiruošęs (-usi) su jais bendrauti. Kiekviena bendruomenė turi savo gaires ir prižiūrėtojus, kad jos nariai būtų saugūs, o patikimi blokavimo ir pranešimo įrankiai padeda užkirsti kelią piktnaudžiavimui.
|
||||
|
||||
Daugiau funkcijų:
|
||||
|
||||
• Tamsusis režimas: skaityk įrašus šviesiu, tamsiu arba tikru juodu režimu
|
||||
• Apklausos: paklausk sekėjų nuomonės ir suskaičiuok balsus
|
||||
• Naršyti: tendencingos saitažodžiai ir paskyros – vos nuo vieno prisilietimo
|
||||
• Pranešimai: gauk pranešimus apie naujus sekėjus, atsakymus ir tinklaraščių perrašymus
|
||||
• Bendrinimas: skelbk tiesiogiai į Mastodon iš bet kurio bendrinimo lapo bet kurioje programėlėje
|
||||
• Mielumas: mūsų talismanas yra žavus drambliukas, kurį retkarčiais pamatysi
|
||||
• Tamsusis režimas: skaityk įrašus šviesiu, tamsiu arba tikru juodu režimu.
|
||||
• Apklausos: paklausk sekėjų nuomonės ir suskaičiuok balsus.
|
||||
• Naršyti: tendencingos saitažodžiai ir paskyros – vos nuo vieno prisilietimo.
|
||||
• Pranešimai: gauk pranešimus apie naujus sekėjus, atsakymus ir pasidalinimus.
|
||||
• Bendrinti: skelbk tiesiogiai į „Mastodon“ iš bet kurio bendrinimo lapo bet kurioje programėlėje
|
||||
• Mielumas: mūsų talismanas – žavus drambliukas, kurį retkarčiais pamatysi.
|
||||
|
||||
Mastodon yra registruota ne pelno siekianti organizacija, kurios plėtra yra tiesiogiai paremta aukomis. Nėra jokios reklamos, jokių monetizacijos ir rizikos kapitalo, ir mes planuojame, kad taip ir liks.
|
||||
„Mastodon“ registruota ne pelno siekianti organizacija, kurios plėtra yra tiesiogiai paremta aukomis. Nėra jokių reklamų, jokių monetizacijos ir rizikos kapitalo, ir mes planuojame, kad taip ir liks.
|
||||
|
||||
@@ -2,7 +2,7 @@ Mastodon เป็นเครือข่ายสังคมแบบกร
|
||||
|
||||
เข้าร่วมชุมชนและสร้างโปรไฟล์ ค้นหาและติดตามผู้คนที่น่าสนใจและอ่านโพสต์ของเขาในเส้นเวลาที่ไม่มีโฆษณาและเรียงตามลำดับเวลา แสดงความรู้สึกของตัวคุณเองด้วยอีโมจิที่กำหนดเอง รูปภาพ GIF วิดีโอ และเสียงในโพสต์ 500 ตัวอักษร ตอบกลับและดันโพสต์จากคนอื่น ๆ เพื่อแชร์สิ่งดี ๆ และค้นหาบัญชีใหม่ ๆ ที่จะติดตามและแฮชแท็กที่เป็นที่นิยมเพื่อขยายเครือข่ายของคุณ
|
||||
|
||||
Mastodon สร้างขึ้นโดยเน้นความเป็นส่วนตัวและความปลอดภัยเป็นสำคัญ คุณสามารถตัดสินใจได้ว่าโพสต์ของคุณจะถูกแชร์กับผู้ติดตามของคุณ คนที่คุณกล่าวถึง หรือคนทั้งโลกก็ได้ ฟังก์ชั่นคำเตือนเนื้อหาช่วยให้คุณซ่อนโพสต์ที่มีเนื้อหาที่ละเอียดอ่อนจนกว่าคุณจะพร้อมเห็นเนื้อหานั้น แต่ละชุมชนจะมีหลักเกณฑ์และผู้ควบคุมเป็นของตนเองเพื่อรักษาความปลอดภัยของสมาชิก รวมถึงเครื่องมือการปิดกั้นและรายงานที่มีประสิทธิภาพเพื่อช่วยป้องกันการกระทำผิด
|
||||
Mastodon สร้างขึ้นโดยเน้นความเป็นส่วนตัวและความปลอดภัยเป็นสำคัญ คุณสามารถตัดสินใจได้ว่าโพสต์ของคุณจะถูกแชร์กับผู้ติดตามของคุณ คนที่คุณกล่าวถึง หรือคนทั้งโลกก็ได้ ฟังก์ชั่นคำเตือนเนื้อหาช่วยให้คุณซ่อนโพสต์ที่มีเนื้อหาที่ละเอียดอ่อนจนกว่าคุณจะพร้อมเห็นเนื้อหานั้น แต่ละชุมชนจะมีหลักเกณฑ์และผู้กลั่นกรองเป็นของตนเองเพื่อรักษาความปลอดภัยของสมาชิก รวมถึงเครื่องมือการปิดกั้นและรายงานที่มีประสิทธิภาพเพื่อช่วยป้องกันการกระทำผิด
|
||||
|
||||
คุณสมบัติอื่น ๆ:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Mastodon, internetteki merkezi olmayan en büyük sosyal ağdır. Tek bir web siteye bağlı kalmaksızın, milyonlarca kullanıcının bağımsız olarak birbiri ile kolayca etkileşebileceği bir ağdır. Hangi konuyla ilgili olduğunuz önemli değil, Mastodon'da onunla ilgili gönderi paylaşan tutkulu insanlarla tanışabilirsiniz!
|
||||
Mastodon, internetteki merkezi olmayan en büyük sosyal ağdır. Tek bir web siteye bağlı kalmaksızın, milyonlarca kullanıcının bağımsız olarak birbirleriyle kolayca etkileşebileceği bir ağdır. Hangi konuyla ilgili olduğunuz önemli değil, Mastodon'da onunla ilgili gönderi paylaşan tutkulu insanlarla tanışabilirsiniz!
|
||||
|
||||
Bir topluluğa katılın ve profilinizi oluşturun. Olağanüstü kişileri bulun ve takip edin; gönderilerini reklamsız ve kronolojik bir zaman çizelgesinde okuyun. Gönderilerinizde şimdilik 500 karakter sınırlamasıyla kendinizi emojiler, görseller, GIFler, videolar ve sesler ile ifade edin. Harika içerikler paylaşmak için başlıklara yanıt yazın, insanların gönderilerini yineleyin. Ağınızı genişletmek için takip edilecek yeni hesaplar ve hashtagler bulun.
|
||||
|
||||
@@ -13,4 +13,4 @@ Diğer özellikler:
|
||||
• Paylaşım: Doğrudan Mastodon'a herhangi bir türde gönderi paylaş
|
||||
• Sevimlilik: Maskotumuz şirin bir fil ve onu uygulamada zaman zaman göreceksiniz
|
||||
|
||||
Mastodon kar amacı gütmeyen bir kuruluştur ve geliştirilmesi doğrudan bağışlarınızla sağlanmaktadır. Reklam yok, para kazanma güdüsü yok, risk sermayesi yok ve bu şekilde devam etmeyi planlıyoruz.
|
||||
Mastodon kâr amacı gütmeyen bir kuruluştur ve geliştirilmesi doğrudan bağışlarınızla sağlanmaktadır. Reklam yok, para kazanma güdüsü yok, risk sermayesi yok ve bu şekilde devam etmeyi planlıyoruz.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Mastodon 是互联网上最大的去中心化社交网络。 它不是一个网站,而是由独立社区节点及其数以百万计的用户组成的网络,所有这些用户都能够无缝地相互交流。 无论你关注什么话题,你都能在 Mastodon 上找到兴趣相投的人进行交流。
|
||||
Mastodon 是互联网上最大的去中心化社交网络。 它不是一个单一的网站,而是由一个个独立社区中的数百万用户组成的网络,所有这些用户都可以无缝交流。 无论你关注什么话题,你都能在 Mastodon 上找到兴趣相投的人进行交流。
|
||||
|
||||
加入一个社区节点并创建你的账户。 查找、关注有趣的同好,无广告、无时间线干扰地阅读他们的帖子。 借助自定义 emoji、图像、GIF、视频和音频,在最多 500 字的帖文中表达自我。 通过回复或转发其他人的帖文来分享美好的事物。 通过准寻新账户并关注热门话题标签来扩展你的社交网络。
|
||||
加入一个社区并创建你的账户。 查找、关注有趣的同好,无广告、无时间线干扰地阅读他们的帖子。 借助自定义 emoji、图像、GIF、视频和音频,在最多 500 字的帖文中表达自我。 通过回复或转发其他人的帖文来分享美好的事物。 通过准寻新账户并关注热门话题标签来扩展你的社交网络。
|
||||
|
||||
Mastodon 以隐私和安全为首要目标。 你可以自主决定帖文的分享分享对象,可以是你的关注者、你提到的人或是整个世界。 在你做好充足的互动准备之前,内容警告可以隐藏包含敏感或刺激内容的帖文。 每个社区都有自己的规则和管理员来保证其成员安全,同时还有强力的屏蔽和举报工具来避免滥用。
|
||||
|
||||
@@ -10,7 +10,7 @@ Mastodon 以隐私和安全为首要目标。 你可以自主决定帖文的分
|
||||
• 投票:询问关注者的意见并统计他们的投票
|
||||
• 探索:热门的话题标签及账号只有一触之遥
|
||||
• 通知:获取关注、回复和转发相关的通知提醒
|
||||
• 分享:从其他应用中的分享菜单中直接发布到 Mastodon
|
||||
• 分享:从任意应用的分享菜单直接发布到 Mastodon
|
||||
• 吉祥物:你会不时地看到我们可爱的长毛象
|
||||
|
||||
Mastodon 是一个直接由用户捐赠支持、已注册非营利开发项目。 它没有广告、没有商业化,也没有风险资本,并且我们也计划保持这种方式。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
Mastodon 是網際網路上最大的去中心化社群網路。 它是一個由能無縫互動的獨立社群中,數百萬使用者組成的網路,而非單一網站。 無論您對什麼事情感興趣,您都能在 Mastodon 上遇到充滿熱情的人們討論該話題。
|
||||
Mastodon 是網際網路上最大的去中心化社群網路。 它是一個由能無縫互動的獨立社群中,數百萬使用者組成的網路,而非單一網站。 無論您對什麼事情感興趣,您都能於 Mastodon 上遇到充滿熱情的人們討論該話題。
|
||||
|
||||
加入社群並建立您的個人檔案。 尋找並跟隨迷人的朋友們,並在無廣告、按時間順序排列的時間軸上閱讀他們的嘟文。 在 500 個字元的嘟文中使用自訂表情符號、GIF、視訊與音訊來表達您自己。 回覆任何人的話題與轉發貼文以分享精彩內容。 尋找要跟隨的新帳號與熱門主題標籤來拓展您的網路。
|
||||
加入社群並建立您的個人檔案。 尋找並跟隨迷人的朋友們,並於無廣告、按時間順序排列的時間軸上閱讀他們的嘟文。 於 500 個字元的嘟文中使用自訂表情符號、GIF、視訊與音訊表達您自己。 回覆任何人的話題與轉發貼文以分享精彩內容。 尋找要跟隨的新帳號與熱門主題標籤以拓展您的網路。
|
||||
|
||||
Mastodon 以隱私與安全為要。 決定您的嘟文要與您的跟隨者分享、只與您提及的人們分享,又或是與全世界分享。 內容警告可讓您隱藏包含敏感或可能觸發強烈情緒反應的嘟文,直到您準備好與它們進行互動。 每個社群都有它們自己的指導方針與管理原來確保其成員安全,強大的封鎖與回報工具有助於防止濫用。
|
||||
Mastodon 以隱私與安全為要。 決定您的嘟文要與您的跟隨者分享、只與您提及的人們分享,又或是與全世界分享。 內容警告可讓您隱藏包含敏感或可能觸發強烈情緒反應的嘟文,直到您準備好與它們進行互動。 每個社群都有它們自己的指導方針與管理員以確保其成員安全,強大的封鎖與回報工具有助於防止濫用。
|
||||
|
||||
更多功能:
|
||||
|
||||
@@ -10,7 +10,7 @@ Mastodon 以隱私與安全為要。 決定您的嘟文要與您的跟隨者分
|
||||
• 投票:詢問跟隨者們的意見並計票
|
||||
• 探索:僅需輕點一下,即可看到熱門主題標籤與帳號
|
||||
• 通知:取得關於新跟隨者們、回覆與轉發的通知
|
||||
• 分享:從任何應用程式中的分享表中直接發表嘟文到 Mastodon 中
|
||||
• 分享:自任何應用程式中的分享表中直接發表嘟文到 Mastodon 中
|
||||
• 可愛:我們的吉祥物是一隻可愛的大象,您會不時看到牠出現
|
||||
|
||||
Mastodon 是一家註冊的非營利組織,您的捐款會直接支援開發工作。 沒有廣告、沒有貨幣化、沒有風險投資,我們計畫維持這種狀態。 沒有廣告、沒有貨幣化、沒有風險投資,我們計畫維持這種狀態。
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
#Sat Jun 03 23:40:27 MSK 2023
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -4,15 +4,18 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
compileSdk 33
|
||||
defaultConfig {
|
||||
applicationId "org.joinmastodon.android"
|
||||
minSdk 23
|
||||
targetSdk 33
|
||||
versionCode 84
|
||||
versionName "2.3.0"
|
||||
versionCode 108
|
||||
versionName "2.5.6"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
resConfigs "ar-rSA", "be-rBY", "bn-rBD", "bs-rBA", "ca-rES", "cs-rCZ", "da-rDK", "de-rDE", "el-rGR", "es-rES", "eu-rES", "fa-rIR", "fi-rFI", "fil-rPH", "fr-rFR", "ga-rIE", "gd-rGB", "gl-rES", "hi-rIN", "hr-rHR", "hu-rHU", "hy-rAM", "ig-rNG", "in-rID", "is-rIS", "it-rIT", "iw-rIL", "ja-rJP", "ka-rGE", "kab", "ko-rKR", "lt-rLT", "my-rMM", "nl-rNL", "no-rNO", "oc-rFR", "pl-rPL", "pt-rBR", "pt-rPT", "ro-rRO", "ru-rRU", "si-rLK", "sl-rSI", "sv-rSE", "th-rTH", "tr-rTR", "uk-rUA", "ur-rIN", "vi-rVN", "zh-rCN", "zh-rTW"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -64,6 +67,17 @@ android {
|
||||
checkReleaseBuilds false
|
||||
abortOnError false
|
||||
}
|
||||
buildFeatures{
|
||||
aidl true
|
||||
buildConfig true
|
||||
}
|
||||
dependenciesInfo{
|
||||
// Disables dependency metadata when building APKs.
|
||||
includeInApk false
|
||||
// Disables dependency metadata when building Android App Bundles.
|
||||
includeInBundle false
|
||||
}
|
||||
namespace 'org.joinmastodon.android'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -76,11 +90,13 @@ dependencies {
|
||||
implementation 'me.grishka.litex:viewpager:1.0.0'
|
||||
implementation 'me.grishka.litex:viewpager2:1.0.0'
|
||||
implementation 'me.grishka.litex:palette:1.0.0'
|
||||
implementation 'me.grishka.appkit:appkit:1.2.16'
|
||||
implementation 'me.grishka.appkit:appkit:1.3.0'
|
||||
implementation 'com.google.code.gson:gson:2.8.9'
|
||||
implementation 'org.jsoup:jsoup:1.14.3'
|
||||
implementation 'com.squareup:otto:1.3.8'
|
||||
implementation 'de.psdev:async-otto:1.0.3'
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
implementation 'org.microg:safe-parcel:1.5.0'
|
||||
implementation 'org.parceler:parceler-api:1.1.12'
|
||||
annotationProcessor 'org.parceler:parceler:1.1.12'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
||||
|
||||
20
mastodon/ci_signing.gradle
Normal file
20
mastodon/ci_signing.gradle
Normal file
@@ -0,0 +1,20 @@
|
||||
// Included into build.gradle when running in a CI pipeline
|
||||
|
||||
android{
|
||||
signingConfigs{
|
||||
release{
|
||||
keyAlias "key0"
|
||||
keyPassword System.getenv("KEYSTORE_PASSWORD")
|
||||
storeFile file(System.getenv("KEYSTORE_FILE"))
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD")
|
||||
}
|
||||
}
|
||||
buildTypes{
|
||||
release{
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
githubRelease{
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
8
mastodon/proguard-rules.pro
vendored
8
mastodon/proguard-rules.pro
vendored
@@ -22,7 +22,7 @@
|
||||
|
||||
# Keep all model classes as they're used with gson and their names are shown in errors
|
||||
-keep public class org.joinmastodon.android.model.**{
|
||||
<fields>;
|
||||
*;
|
||||
}
|
||||
|
||||
# Inner classes in api requests are used with gson
|
||||
@@ -50,4 +50,8 @@
|
||||
|
||||
-keep class org.joinmastodon.android.AppCenterWrapper { *; }
|
||||
|
||||
-keepattributes LineNumberTable
|
||||
-keepattributes LineNumberTable
|
||||
-keepattributes Signature
|
||||
-keep class com.google.gson.reflect.TypeToken { *; }
|
||||
-keep class * extends com.google.gson.reflect.TypeToken
|
||||
-keep public class * implements java.lang.reflect.Type
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
|
||||
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/>
|
||||
@@ -19,6 +20,10 @@
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TRANSLATE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="http"/>
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@@ -26,10 +31,15 @@
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:theme="@style/Theme.Mastodon.AutoLightDark"
|
||||
android:largeHeap="true">
|
||||
android:largeHeap="true"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="barcode_ui"/>
|
||||
|
||||
<activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize" android:windowSoftInputMode="adjustResize" android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
@@ -71,6 +81,7 @@
|
||||
</activity>
|
||||
|
||||
<service android:name=".AudioPlayerService" android:foregroundServiceType="mediaPlayback"/>
|
||||
<service android:name=".NotificationActionHandlerService" android:exported="false"/>
|
||||
|
||||
<receiver android:name=".PushNotificationReceiver" android:exported="true" android:permission="com.google.android.c2dm.permission.SEND">
|
||||
<intent-filter>
|
||||
@@ -84,6 +95,14 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:name=".TweakedFileProvider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/fileprovider_paths"/>
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.google.android.gms.common.api;
|
||||
|
||||
parcelable Status;
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.google.android.gms.common.api.internal;
|
||||
|
||||
import com.google.android.gms.common.api.Status;
|
||||
|
||||
interface IStatusCallback {
|
||||
void onResult(in Status status);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020, microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.google.android.gms.common.internal;
|
||||
parcelable ConnectionInfo;
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.google.android.gms.common.internal;
|
||||
|
||||
parcelable GetServiceRequest;
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020, microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.google.android.gms.common.internal;
|
||||
|
||||
import android.os.Bundle;
|
||||
import com.google.android.gms.common.internal.ConnectionInfo;
|
||||
|
||||
interface IGmsCallbacks {
|
||||
void onPostInitComplete(int statusCode, IBinder binder, in Bundle params);
|
||||
void onAccountValidationComplete(int statusCode, in Bundle params);
|
||||
void onPostInitCompleteWithConnectionInfo(int statusCode, IBinder binder, in ConnectionInfo info);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.google.android.gms.common.internal;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.google.android.gms.common.internal.IGmsCallbacks;
|
||||
import com.google.android.gms.common.internal.GetServiceRequest;
|
||||
|
||||
interface IGmsServiceBroker {
|
||||
void getService(IGmsCallbacks callback, in GetServiceRequest request) = 45;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.google.android.gms.common.moduleinstall;
|
||||
|
||||
parcelable ModuleAvailabilityResponse;
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.google.android.gms.common.moduleinstall;
|
||||
|
||||
parcelable ModuleInstallIntentResponse;
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.google.android.gms.common.moduleinstall;
|
||||
|
||||
parcelable ModuleInstallResponse;
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.google.android.gms.common.moduleinstall;
|
||||
|
||||
parcelable ModuleInstallStatusUpdate;
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.google.android.gms.common.moduleinstall.internal;
|
||||
|
||||
parcelable ApiFeatureRequest;
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.google.android.gms.common.moduleinstall.internal;
|
||||
|
||||
import com.google.android.gms.common.api.Status;
|
||||
import com.google.android.gms.common.moduleinstall.ModuleAvailabilityResponse;
|
||||
import com.google.android.gms.common.moduleinstall.ModuleInstallIntentResponse;
|
||||
import com.google.android.gms.common.moduleinstall.ModuleInstallResponse;
|
||||
|
||||
interface IModuleInstallCallbacks {
|
||||
void onModuleAvailabilityResponse(in Status status, in ModuleAvailabilityResponse response) = 0;
|
||||
void onModuleInstallResponse(in Status status, in ModuleInstallResponse response) = 1;
|
||||
void onModuleInstallIntentResponse(in Status status, in ModuleInstallIntentResponse response) = 2;
|
||||
void onStatus(in Status status) = 3;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.google.android.gms.common.moduleinstall.internal;
|
||||
|
||||
import com.google.android.gms.common.api.internal.IStatusCallback;
|
||||
import com.google.android.gms.common.moduleinstall.internal.ApiFeatureRequest;
|
||||
import com.google.android.gms.common.moduleinstall.internal.IModuleInstallCallbacks;
|
||||
import com.google.android.gms.common.moduleinstall.internal.IModuleInstallStatusListener;
|
||||
|
||||
interface IModuleInstallService {
|
||||
void areModulesAvailable(IModuleInstallCallbacks callbacks, in ApiFeatureRequest request) = 0;
|
||||
void installModules(IModuleInstallCallbacks callbacks, in ApiFeatureRequest request, IModuleInstallStatusListener listener) = 1;
|
||||
void getInstallModulesIntent(IModuleInstallCallbacks callbacks, in ApiFeatureRequest request) = 2;
|
||||
void releaseModules(IStatusCallback callback, in ApiFeatureRequest request) = 3;
|
||||
void unregisterListener(IStatusCallback callback, IModuleInstallStatusListener listener) = 5;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.google.android.gms.common.moduleinstall.internal;
|
||||
|
||||
import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate;
|
||||
|
||||
interface IModuleInstallStatusListener {
|
||||
void onModuleInstallStatusUpdate(in ModuleInstallStatusUpdate statusUpdate) = 0;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.google.android.gms.common;
|
||||
|
||||
import org.microg.safeparcel.AutoSafeParcelable;
|
||||
import org.microg.safeparcel.SafeParceled;
|
||||
|
||||
public class Feature extends AutoSafeParcelable{
|
||||
@SafeParceled(1)
|
||||
public String name;
|
||||
@SafeParceled(2)
|
||||
public int oldVersion;
|
||||
@SafeParceled(3)
|
||||
public long version=-1;
|
||||
|
||||
public static final Creator<Feature> CREATOR=new AutoCreator<>(Feature.class);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.google.android.gms.common.api;
|
||||
|
||||
import org.microg.safeparcel.AutoSafeParcelable;
|
||||
import org.microg.safeparcel.SafeParceled;
|
||||
|
||||
public class Scope extends AutoSafeParcelable{
|
||||
@SafeParceled(1)
|
||||
public int versionCode=1;
|
||||
@SafeParceled(2)
|
||||
public String scopeUri;
|
||||
|
||||
public static final Creator<Scope> CREATOR=new AutoCreator<>(Scope.class);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.google.android.gms.common.api;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
|
||||
import org.joinmastodon.android.googleservices.ConnectionResult;
|
||||
import org.microg.safeparcel.AutoSafeParcelable;
|
||||
import org.microg.safeparcel.SafeParceled;
|
||||
|
||||
public class Status extends AutoSafeParcelable{
|
||||
@SafeParceled(1000)
|
||||
public int versionCode;
|
||||
@SafeParceled(1)
|
||||
public int statusCode;
|
||||
@SafeParceled(2)
|
||||
public String statusMessage;
|
||||
@SafeParceled(3)
|
||||
public PendingIntent pendingIntent;
|
||||
@SafeParceled(4)
|
||||
public ConnectionResult connectionResult;
|
||||
|
||||
public static final Creator<Status> CREATOR=new AutoCreator<>(Status.class);
|
||||
|
||||
@Override
|
||||
public String toString(){
|
||||
return "Status{"+
|
||||
"versionCode="+versionCode+
|
||||
", statusCode="+statusCode+
|
||||
", statusMessage='"+statusMessage+'\''+
|
||||
", pendingIntent="+pendingIntent+
|
||||
", connectionResult="+connectionResult+
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.google.android.gms.common.internal;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.google.android.gms.common.Feature;
|
||||
|
||||
import org.microg.safeparcel.AutoSafeParcelable;
|
||||
import org.microg.safeparcel.SafeParceled;
|
||||
|
||||
public class ConnectionInfo extends AutoSafeParcelable{
|
||||
@SafeParceled(1)
|
||||
public Bundle params;
|
||||
@SafeParceled(2)
|
||||
public Feature[] features;
|
||||
@SafeParceled(3)
|
||||
public int unknown3;
|
||||
|
||||
public static final Creator<ConnectionInfo> CREATOR=new AutoCreator<>(ConnectionInfo.class);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.google.android.gms.common.internal;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.accounts.Account;
|
||||
|
||||
import com.google.android.gms.common.Feature;
|
||||
import com.google.android.gms.common.api.Scope;
|
||||
|
||||
import org.microg.safeparcel.AutoSafeParcelable;
|
||||
import org.microg.safeparcel.SafeParceled;
|
||||
|
||||
public class GetServiceRequest extends AutoSafeParcelable{
|
||||
@SafeParceled(1)
|
||||
int versionCode=6;
|
||||
@SafeParceled(2)
|
||||
public int serviceId;
|
||||
@SafeParceled(3)
|
||||
public int gmsVersion;
|
||||
@SafeParceled(4)
|
||||
public String packageName;
|
||||
@SafeParceled(5)
|
||||
public IBinder accountAccessor;
|
||||
@SafeParceled(6)
|
||||
public Scope[] scopes;
|
||||
@SafeParceled(7)
|
||||
public Bundle extras;
|
||||
@SafeParceled(8)
|
||||
public Account account;
|
||||
@SafeParceled(9)
|
||||
@Deprecated
|
||||
long field9;
|
||||
@SafeParceled(10)
|
||||
public Feature[] defaultFeatures;
|
||||
@SafeParceled(11)
|
||||
public Feature[] apiFeatures;
|
||||
@SafeParceled(12)
|
||||
boolean supportsConnectionInfo;
|
||||
@SafeParceled(13)
|
||||
int field13;
|
||||
@SafeParceled(14)
|
||||
boolean field14;
|
||||
@SafeParceled(15)
|
||||
String attributionTag;
|
||||
|
||||
public static final Creator<GetServiceRequest> CREATOR=new AutoCreator<>(GetServiceRequest.class);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.google.android.gms.common.moduleinstall;
|
||||
|
||||
import org.microg.safeparcel.AutoSafeParcelable;
|
||||
import org.microg.safeparcel.SafeParceled;
|
||||
|
||||
public class ModuleAvailabilityResponse extends AutoSafeParcelable{
|
||||
@SafeParceled(1)
|
||||
public boolean modulesAvailable;
|
||||
@SafeParceled(2)
|
||||
public int availabilityStatus;
|
||||
|
||||
public static final Creator<ModuleAvailabilityResponse> CREATOR=new AutoCreator<>(ModuleAvailabilityResponse.class);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.google.android.gms.common.moduleinstall;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
|
||||
import org.microg.safeparcel.AutoSafeParcelable;
|
||||
import org.microg.safeparcel.SafeParceled;
|
||||
|
||||
public class ModuleInstallIntentResponse extends AutoSafeParcelable{
|
||||
@SafeParceled(1)
|
||||
public PendingIntent pendingIntent;
|
||||
|
||||
public static final Creator<ModuleInstallIntentResponse> CREATOR=new AutoCreator<>(ModuleInstallIntentResponse.class);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.google.android.gms.common.moduleinstall;
|
||||
|
||||
import org.microg.safeparcel.AutoSafeParcelable;
|
||||
import org.microg.safeparcel.SafeParceled;
|
||||
|
||||
public class ModuleInstallResponse extends AutoSafeParcelable{
|
||||
@SafeParceled(1)
|
||||
public int sessionID;
|
||||
@SafeParceled(2)
|
||||
public boolean shouldUnregisterListener;
|
||||
|
||||
public static final Creator<ModuleInstallResponse> CREATOR=new AutoCreator<>(ModuleInstallResponse.class);
|
||||
|
||||
@Override
|
||||
public String toString(){
|
||||
return "ModuleInstallResponse{"+
|
||||
"sessionID="+sessionID+
|
||||
", shouldUnregisterListener="+shouldUnregisterListener+
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.google.android.gms.common.moduleinstall;
|
||||
|
||||
import org.microg.safeparcel.AutoSafeParcelable;
|
||||
import org.microg.safeparcel.SafeParceled;
|
||||
|
||||
public class ModuleInstallStatusUpdate extends AutoSafeParcelable{
|
||||
public static final int STATE_UNKNOWN = 0;
|
||||
/**
|
||||
* The request is pending and will be processed soon.
|
||||
*/
|
||||
public static final int STATE_PENDING = 1;
|
||||
/**
|
||||
* The optional module download is in progress.
|
||||
*/
|
||||
public static final int STATE_DOWNLOADING = 2;
|
||||
/**
|
||||
* The optional module download has been canceled.
|
||||
*/
|
||||
public static final int STATE_CANCELED = 3;
|
||||
/**
|
||||
* Installation is completed; the optional modules are available to the client app.
|
||||
*/
|
||||
public static final int STATE_COMPLETED = 4;
|
||||
/**
|
||||
* The optional module download or installation has failed.
|
||||
*/
|
||||
public static final int STATE_FAILED = 5;
|
||||
/**
|
||||
* The optional modules have been downloaded and the installation is in progress.
|
||||
*/
|
||||
public static final int STATE_INSTALLING = 6;
|
||||
/**
|
||||
* The optional module download has been paused.
|
||||
* <p>
|
||||
* This usually happens when connectivity requirements can't be met during download. Once the connectivity requirements
|
||||
* are met, the download will be resumed automatically.
|
||||
*/
|
||||
public static final int STATE_DOWNLOAD_PAUSED = 7;
|
||||
|
||||
@SafeParceled(1)
|
||||
public int sessionID;
|
||||
@SafeParceled(2)
|
||||
public int installState;
|
||||
@SafeParceled(3)
|
||||
public Long bytesDownloaded;
|
||||
@SafeParceled(4)
|
||||
public Long totalBytesToDownload;
|
||||
@SafeParceled(5)
|
||||
public int errorCode;
|
||||
|
||||
@Override
|
||||
public String toString(){
|
||||
return "ModuleInstallStatusUpdate{"+
|
||||
"sessionID="+sessionID+
|
||||
", installState="+installState+
|
||||
", bytesDownloaded="+bytesDownloaded+
|
||||
", totalBytesToDownload="+totalBytesToDownload+
|
||||
", errorCode="+errorCode+
|
||||
'}';
|
||||
}
|
||||
|
||||
public static final Creator<ModuleInstallStatusUpdate> CREATOR=new AutoCreator<>(ModuleInstallStatusUpdate.class);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.google.android.gms.common.moduleinstall.internal;
|
||||
|
||||
import com.google.android.gms.common.Feature;
|
||||
|
||||
import org.microg.safeparcel.AutoSafeParcelable;
|
||||
import org.microg.safeparcel.SafeParceled;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ApiFeatureRequest extends AutoSafeParcelable{
|
||||
@SafeParceled(value=1, subClass=Feature.class)
|
||||
public List<Feature> features;
|
||||
@SafeParceled(2)
|
||||
public boolean urgent;
|
||||
@SafeParceled(3)
|
||||
public String sessionId;
|
||||
@SafeParceled(4)
|
||||
public String callingPackage;
|
||||
|
||||
public static final Creator<ApiFeatureRequest> CREATOR=new AutoCreator<>(ApiFeatureRequest.class);
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
/*
|
||||
* Copyright (C) 2013 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.joinmastodon.android;
|
||||
|
||||
import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT;
|
||||
import static org.xmlpull.v1.XmlPullParser.START_TAG;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ProviderInfo;
|
||||
import android.content.res.XmlResourceParser;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.text.TextUtils;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.annotation.GuardedBy;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* FileProvider is a special subclass of {@link ContentProvider} that facilitates secure sharing
|
||||
* of files associated with an app by creating a <code>content://</code> {@link Uri} for a file
|
||||
* instead of a <code>file:///</code> {@link Uri}.
|
||||
* <p>
|
||||
* A content URI allows you to grant read and write access using
|
||||
* temporary access permissions. When you create an {@link Intent} containing
|
||||
* a content URI, in order to send the content URI
|
||||
* to a client app, you can also call {@link Intent#setFlags(int) Intent.setFlags()} to add
|
||||
* permissions. These permissions are available to the client app for as long as the stack for
|
||||
* a receiving {@link android.app.Activity} is active. For an {@link Intent} going to a
|
||||
* {@link android.app.Service}, the permissions are available as long as the
|
||||
* {@link android.app.Service} is running.
|
||||
* <p>
|
||||
* In comparison, to control access to a <code>file:///</code> {@link Uri} you have to modify the
|
||||
* file system permissions of the underlying file. The permissions you provide become available to
|
||||
* <em>any</em> app, and remain in effect until you change them. This level of access is
|
||||
* fundamentally insecure.
|
||||
* <p>
|
||||
* The increased level of file access security offered by a content URI
|
||||
* makes FileProvider a key part of Android's security infrastructure.
|
||||
* <p>
|
||||
* This overview of FileProvider includes the following topics:
|
||||
* </p>
|
||||
* <ol>
|
||||
* <li><a href="#ProviderDefinition">Defining a FileProvider</a></li>
|
||||
* <li><a href="#SpecifyFiles">Specifying Available Files</a></li>
|
||||
* <li><a href="#GetUri">Retrieving the Content URI for a File</li>
|
||||
* <li><a href="#Permissions">Granting Temporary Permissions to a URI</a></li>
|
||||
* <li><a href="#ServeUri">Serving a Content URI to Another App</a></li>
|
||||
* </ol>
|
||||
* <h3 id="ProviderDefinition">Defining a FileProvider</h3>
|
||||
* <p>
|
||||
* Since the default functionality of FileProvider includes content URI generation for files, you
|
||||
* don't need to define a subclass in code. Instead, you can include a FileProvider in your app
|
||||
* by specifying it entirely in XML. To specify the FileProvider component itself, add a
|
||||
* <code><a href="{@docRoot}guide/topics/manifest/provider-element.html"><provider></a></code>
|
||||
* element to your app manifest. Set the <code>android:name</code> attribute to
|
||||
* <code>androidx.core.content.FileProvider</code>. Set the <code>android:authorities</code>
|
||||
* attribute to a URI authority based on a domain you control; for example, if you control the
|
||||
* domain <code>mydomain.com</code> you should use the authority
|
||||
* <code>com.mydomain.fileprovider</code>. Set the <code>android:exported</code> attribute to
|
||||
* <code>false</code>; the FileProvider does not need to be public. Set the
|
||||
* <a href="{@docRoot}guide/topics/manifest/provider-element.html#gprmsn"
|
||||
* >android:grantUriPermissions</a> attribute to <code>true</code>, to allow you
|
||||
* to grant temporary access to files. For example:
|
||||
* <pre class="prettyprint">
|
||||
*<manifest>
|
||||
* ...
|
||||
* <application>
|
||||
* ...
|
||||
* <provider
|
||||
* android:name="androidx.core.content.FileProvider"
|
||||
* android:authorities="com.mydomain.fileprovider"
|
||||
* android:exported="false"
|
||||
* android:grantUriPermissions="true">
|
||||
* ...
|
||||
* </provider>
|
||||
* ...
|
||||
* </application>
|
||||
*</manifest></pre>
|
||||
* <p>
|
||||
* If you want to override any of the default behavior of FileProvider methods, extend
|
||||
* the FileProvider class and use the fully-qualified class name in the <code>android:name</code>
|
||||
* attribute of the <code><provider></code> element.
|
||||
* <h3 id="SpecifyFiles">Specifying Available Files</h3>
|
||||
* A FileProvider can only generate a content URI for files in directories that you specify
|
||||
* beforehand. To specify a directory, specify the its storage area and path in XML, using child
|
||||
* elements of the <code><paths></code> element.
|
||||
* For example, the following <code>paths</code> element tells FileProvider that you intend to
|
||||
* request content URIs for the <code>images/</code> subdirectory of your private file area.
|
||||
* <pre class="prettyprint">
|
||||
*<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
* <files-path name="my_images" path="images/"/>
|
||||
* ...
|
||||
*</paths>
|
||||
*</pre>
|
||||
* <p>
|
||||
* The <code><paths></code> element must contain one or more of the following child elements:
|
||||
* </p>
|
||||
* <dl>
|
||||
* <dt>
|
||||
* <pre class="prettyprint">
|
||||
*<files-path name="<i>name</i>" path="<i>path</i>" />
|
||||
*</pre>
|
||||
* </dt>
|
||||
* <dd>
|
||||
* Represents files in the <code>files/</code> subdirectory of your app's internal storage
|
||||
* area. This subdirectory is the same as the value returned by {@link Context#getFilesDir()
|
||||
* Context.getFilesDir()}.
|
||||
* </dd>
|
||||
* <dt>
|
||||
* <pre>
|
||||
*<cache-path name="<i>name</i>" path="<i>path</i>" />
|
||||
*</pre>
|
||||
* <dt>
|
||||
* <dd>
|
||||
* Represents files in the cache subdirectory of your app's internal storage area. The root path
|
||||
* of this subdirectory is the same as the value returned by {@link Context#getCacheDir()
|
||||
* getCacheDir()}.
|
||||
* </dd>
|
||||
* <dt>
|
||||
* <pre class="prettyprint">
|
||||
*<external-path name="<i>name</i>" path="<i>path</i>" />
|
||||
*</pre>
|
||||
* </dt>
|
||||
* <dd>
|
||||
* Represents files in the root of the external storage area. The root path of this subdirectory
|
||||
* is the same as the value returned by
|
||||
* {@link Environment#getExternalStorageDirectory() Environment.getExternalStorageDirectory()}.
|
||||
* </dd>
|
||||
* <dt>
|
||||
* <pre class="prettyprint">
|
||||
*<external-files-path name="<i>name</i>" path="<i>path</i>" />
|
||||
*</pre>
|
||||
* </dt>
|
||||
* <dd>
|
||||
* Represents files in the root of your app's external storage area. The root path of this
|
||||
* subdirectory is the same as the value returned by
|
||||
* {@code Context#getExternalFilesDir(String) Context.getExternalFilesDir(null)}.
|
||||
* </dd>
|
||||
* <dt>
|
||||
* <pre class="prettyprint">
|
||||
*<external-cache-path name="<i>name</i>" path="<i>path</i>" />
|
||||
*</pre>
|
||||
* </dt>
|
||||
* <dd>
|
||||
* Represents files in the root of your app's external cache area. The root path of this
|
||||
* subdirectory is the same as the value returned by
|
||||
* {@link Context#getExternalCacheDir() Context.getExternalCacheDir()}.
|
||||
* </dd>
|
||||
* <dt>
|
||||
* <pre class="prettyprint">
|
||||
*<external-media-path name="<i>name</i>" path="<i>path</i>" />
|
||||
*</pre>
|
||||
* </dt>
|
||||
* <dd>
|
||||
* Represents files in the root of your app's external media area. The root path of this
|
||||
* subdirectory is the same as the value returned by the first result of
|
||||
* {@link Context#getExternalMediaDirs() Context.getExternalMediaDirs()}.
|
||||
* <p><strong>Note:</strong> this directory is only available on API 21+ devices.</p>
|
||||
* </dd>
|
||||
* </dl>
|
||||
* <p>
|
||||
* These child elements all use the same attributes:
|
||||
* </p>
|
||||
* <dl>
|
||||
* <dt>
|
||||
* <code>name="<i>name</i>"</code>
|
||||
* </dt>
|
||||
* <dd>
|
||||
* A URI path segment. To enforce security, this value hides the name of the subdirectory
|
||||
* you're sharing. The subdirectory name for this value is contained in the
|
||||
* <code>path</code> attribute.
|
||||
* </dd>
|
||||
* <dt>
|
||||
* <code>path="<i>path</i>"</code>
|
||||
* </dt>
|
||||
* <dd>
|
||||
* The subdirectory you're sharing. While the <code>name</code> attribute is a URI path
|
||||
* segment, the <code>path</code> value is an actual subdirectory name. Notice that the
|
||||
* value refers to a <b>subdirectory</b>, not an individual file or files. You can't
|
||||
* share a single file by its file name, nor can you specify a subset of files using
|
||||
* wildcards.
|
||||
* </dd>
|
||||
* </dl>
|
||||
* <p>
|
||||
* You must specify a child element of <code><paths></code> for each directory that contains
|
||||
* files for which you want content URIs. For example, these XML elements specify two directories:
|
||||
* <pre class="prettyprint">
|
||||
*<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
* <files-path name="my_images" path="images/"/>
|
||||
* <files-path name="my_docs" path="docs/"/>
|
||||
*</paths>
|
||||
*</pre>
|
||||
* <p>
|
||||
* Put the <code><paths></code> element and its children in an XML file in your project.
|
||||
* For example, you can add them to a new file called <code>res/xml/file_paths.xml</code>.
|
||||
* To link this file to the FileProvider, add a
|
||||
* <a href="{@docRoot}guide/topics/manifest/meta-data-element.html"><meta-data></a> element
|
||||
* as a child of the <code><provider></code> element that defines the FileProvider. Set the
|
||||
* <code><meta-data></code> element's "android:name" attribute to
|
||||
* <code>android.support.FILE_PROVIDER_PATHS</code>. Set the element's "android:resource" attribute
|
||||
* to <code>@xml/file_paths</code> (notice that you don't specify the <code>.xml</code>
|
||||
* extension). For example:
|
||||
* <pre class="prettyprint">
|
||||
*<provider
|
||||
* android:name="androidx.core.content.FileProvider"
|
||||
* android:authorities="com.mydomain.fileprovider"
|
||||
* android:exported="false"
|
||||
* android:grantUriPermissions="true">
|
||||
* <meta-data
|
||||
* android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
* android:resource="@xml/file_paths" />
|
||||
*</provider>
|
||||
*</pre>
|
||||
* <h3 id="GetUri">Generating the Content URI for a File</h3>
|
||||
* <p>
|
||||
* To share a file with another app using a content URI, your app has to generate the content URI.
|
||||
* To generate the content URI, create a new {@link File} for the file, then pass the {@link File}
|
||||
* to {@link #getUriForFile(Context, String, File) getUriForFile()}. You can send the content URI
|
||||
* returned by {@link #getUriForFile(Context, String, File) getUriForFile()} to another app in an
|
||||
* {@link Intent}. The client app that receives the content URI can open the file
|
||||
* and access its contents by calling
|
||||
* {@link android.content.ContentResolver#openFileDescriptor(Uri, String)
|
||||
* ContentResolver.openFileDescriptor} to get a {@link ParcelFileDescriptor}.
|
||||
* <p>
|
||||
* For example, suppose your app is offering files to other apps with a FileProvider that has the
|
||||
* authority <code>com.mydomain.fileprovider</code>. To get a content URI for the file
|
||||
* <code>default_image.jpg</code> in the <code>images/</code> subdirectory of your internal storage
|
||||
* add the following code:
|
||||
* <pre class="prettyprint">
|
||||
*File imagePath = new File(Context.getFilesDir(), "images");
|
||||
*File newFile = new File(imagePath, "default_image.jpg");
|
||||
*Uri contentUri = getUriForFile(getContext(), "com.mydomain.fileprovider", newFile);
|
||||
*</pre>
|
||||
* As a result of the previous snippet,
|
||||
* {@link #getUriForFile(Context, String, File) getUriForFile()} returns the content URI
|
||||
* <code>content://com.mydomain.fileprovider/my_images/default_image.jpg</code>.
|
||||
* <h3 id="Permissions">Granting Temporary Permissions to a URI</h3>
|
||||
* To grant an access permission to a content URI returned from
|
||||
* {@link #getUriForFile(Context, String, File) getUriForFile()}, do one of the following:
|
||||
* <ul>
|
||||
* <li>
|
||||
* Call the method
|
||||
* {@link Context#grantUriPermission(String, Uri, int)
|
||||
* Context.grantUriPermission(package, Uri, mode_flags)} for the <code>content://</code>
|
||||
* {@link Uri}, using the desired mode flags. This grants temporary access permission for the
|
||||
* content URI to the specified package, according to the value of the
|
||||
* the <code>mode_flags</code> parameter, which you can set to
|
||||
* {@link Intent#FLAG_GRANT_READ_URI_PERMISSION}, {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}
|
||||
* or both. The permission remains in effect until you revoke it by calling
|
||||
* {@link Context#revokeUriPermission(Uri, int) revokeUriPermission()} or until the device
|
||||
* reboots.
|
||||
* </li>
|
||||
* <li>
|
||||
* Put the content URI in an {@link Intent} by calling {@link Intent#setData(Uri) setData()}.
|
||||
* </li>
|
||||
* <li>
|
||||
* Next, call the method {@link Intent#setFlags(int) Intent.setFlags()} with either
|
||||
* {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} or
|
||||
* {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION} or both.
|
||||
* </li>
|
||||
* <li>
|
||||
* Finally, send the {@link Intent} to
|
||||
* another app. Most often, you do this by calling
|
||||
* {@link android.app.Activity#setResult(int, Intent) setResult()}.
|
||||
* <p>
|
||||
* Permissions granted in an {@link Intent} remain in effect while the stack of the receiving
|
||||
* {@link android.app.Activity} is active. When the stack finishes, the permissions are
|
||||
* automatically removed. Permissions granted to one {@link android.app.Activity} in a client
|
||||
* app are automatically extended to other components of that app.
|
||||
* </p>
|
||||
* </li>
|
||||
* </ul>
|
||||
* <h3 id="ServeUri">Serving a Content URI to Another App</h3>
|
||||
* <p>
|
||||
* There are a variety of ways to serve the content URI for a file to a client app. One common way
|
||||
* is for the client app to start your app by calling
|
||||
* {@link android.app.Activity#startActivityForResult(Intent, int, Bundle) startActivityResult()},
|
||||
* which sends an {@link Intent} to your app to start an {@link android.app.Activity} in your app.
|
||||
* In response, your app can immediately return a content URI to the client app or present a user
|
||||
* interface that allows the user to pick a file. In the latter case, once the user picks the file
|
||||
* your app can return its content URI. In both cases, your app returns the content URI in an
|
||||
* {@link Intent} sent via {@link android.app.Activity#setResult(int, Intent) setResult()}.
|
||||
* </p>
|
||||
* <p>
|
||||
* You can also put the content URI in a {@link android.content.ClipData} object and then add the
|
||||
* object to an {@link Intent} you send to a client app. To do this, call
|
||||
* {@link Intent#setClipData(ClipData) Intent.setClipData()}. When you use this approach, you can
|
||||
* add multiple {@link android.content.ClipData} objects to the {@link Intent}, each with its own
|
||||
* content URI. When you call {@link Intent#setFlags(int) Intent.setFlags()} on the {@link Intent}
|
||||
* to set temporary access permissions, the same permissions are applied to all of the content
|
||||
* URIs.
|
||||
* </p>
|
||||
* <p class="note">
|
||||
* <strong>Note:</strong> The {@link Intent#setClipData(ClipData) Intent.setClipData()} method is
|
||||
* only available in platform version 16 (Android 4.1) and later. If you want to maintain
|
||||
* compatibility with previous versions, you should send one content URI at a time in the
|
||||
* {@link Intent}. Set the action to {@link Intent#ACTION_SEND} and put the URI in data by calling
|
||||
* {@link Intent#setData setData()}.
|
||||
* </p>
|
||||
* <h3 id="">More Information</h3>
|
||||
* <p>
|
||||
* To learn more about FileProvider, see the Android training class
|
||||
* <a href="{@docRoot}training/secure-file-sharing/index.html">Sharing Files Securely with URIs</a>.
|
||||
* </p>
|
||||
*/
|
||||
public class FileProvider extends ContentProvider {
|
||||
private static final String[] COLUMNS = {
|
||||
OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
|
||||
|
||||
private static final String
|
||||
META_DATA_FILE_PROVIDER_PATHS = "android.support.FILE_PROVIDER_PATHS";
|
||||
|
||||
private static final String TAG_ROOT_PATH = "root-path";
|
||||
private static final String TAG_FILES_PATH = "files-path";
|
||||
private static final String TAG_CACHE_PATH = "cache-path";
|
||||
private static final String TAG_EXTERNAL = "external-path";
|
||||
private static final String TAG_EXTERNAL_FILES = "external-files-path";
|
||||
private static final String TAG_EXTERNAL_CACHE = "external-cache-path";
|
||||
private static final String TAG_EXTERNAL_MEDIA = "external-media-path";
|
||||
|
||||
private static final String ATTR_NAME = "name";
|
||||
private static final String ATTR_PATH = "path";
|
||||
|
||||
private static final File DEVICE_ROOT = new File("/");
|
||||
|
||||
@GuardedBy("sCache")
|
||||
private static HashMap<String, PathStrategy> sCache = new HashMap<String, PathStrategy>();
|
||||
|
||||
private PathStrategy mStrategy;
|
||||
|
||||
/**
|
||||
* The default FileProvider implementation does not need to be initialized. If you want to
|
||||
* override this method, you must provide your own subclass of FileProvider.
|
||||
*/
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* After the FileProvider is instantiated, this method is called to provide the system with
|
||||
* information about the provider.
|
||||
*
|
||||
* @param context A {@link Context} for the current component.
|
||||
* @param info A {@link ProviderInfo} for the new provider.
|
||||
*/
|
||||
@Override
|
||||
public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) {
|
||||
super.attachInfo(context, info);
|
||||
|
||||
// Sanity check our security
|
||||
if (info.exported) {
|
||||
throw new SecurityException("Provider must not be exported");
|
||||
}
|
||||
if (!info.grantUriPermissions) {
|
||||
throw new SecurityException("Provider must grant uri permissions");
|
||||
}
|
||||
|
||||
mStrategy = getPathStrategy(context, info.authority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a content URI for a given {@link File}. Specific temporary
|
||||
* permissions for the content URI can be set with
|
||||
* {@link Context#grantUriPermission(String, Uri, int)}, or added
|
||||
* to an {@link Intent} by calling {@link Intent#setData(Uri) setData()} and then
|
||||
* {@link Intent#setFlags(int) setFlags()}; in both cases, the applicable flags are
|
||||
* {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} and
|
||||
* {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}. A FileProvider can only return a
|
||||
* <code>content</code> {@link Uri} for file paths defined in their <code><paths></code>
|
||||
* meta-data element. See the Class Overview for more information.
|
||||
*
|
||||
* @param context A {@link Context} for the current component.
|
||||
* @param authority The authority of a {@link FileProvider} defined in a
|
||||
* {@code <provider>} element in your app's manifest.
|
||||
* @param file A {@link File} pointing to the filename for which you want a
|
||||
* <code>content</code> {@link Uri}.
|
||||
* @return A content URI for the file.
|
||||
* @throws IllegalArgumentException When the given {@link File} is outside
|
||||
* the paths supported by the provider.
|
||||
*/
|
||||
public static Uri getUriForFile(@NonNull Context context, @NonNull String authority,
|
||||
@NonNull File file) {
|
||||
final PathStrategy strategy = getPathStrategy(context, authority);
|
||||
return strategy.getUriForFile(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a content URI returned by
|
||||
* {@link #getUriForFile(Context, String, File) getUriForFile()} to get information about a file
|
||||
* managed by the FileProvider.
|
||||
* FileProvider reports the column names defined in {@link OpenableColumns}:
|
||||
* <ul>
|
||||
* <li>{@link OpenableColumns#DISPLAY_NAME}</li>
|
||||
* <li>{@link OpenableColumns#SIZE}</li>
|
||||
* </ul>
|
||||
* For more information, see
|
||||
* {@link ContentProvider#query(Uri, String[], String, String[], String)
|
||||
* ContentProvider.query()}.
|
||||
*
|
||||
* @param uri A content URI returned by {@link #getUriForFile}.
|
||||
* @param projection The list of columns to put into the {@link Cursor}. If null all columns are
|
||||
* included.
|
||||
* @param selection Selection criteria to apply. If null then all data that matches the content
|
||||
* URI is returned.
|
||||
* @param selectionArgs An array of {@link String}, containing arguments to bind to
|
||||
* the <i>selection</i> parameter. The <i>query</i> method scans <i>selection</i> from left to
|
||||
* right and iterates through <i>selectionArgs</i>, replacing the current "?" character in
|
||||
* <i>selection</i> with the value at the current position in <i>selectionArgs</i>. The
|
||||
* values are bound to <i>selection</i> as {@link String} values.
|
||||
* @param sortOrder A {@link String} containing the column name(s) on which to sort
|
||||
* the resulting {@link Cursor}.
|
||||
* @return A {@link Cursor} containing the results of the query.
|
||||
*
|
||||
*/
|
||||
@Override
|
||||
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
|
||||
@Nullable String[] selectionArgs,
|
||||
@Nullable String sortOrder) {
|
||||
// ContentProvider has already checked granted permissions
|
||||
final File file = mStrategy.getFileForUri(uri);
|
||||
|
||||
if (projection == null) {
|
||||
projection = COLUMNS;
|
||||
}
|
||||
|
||||
String[] cols = new String[projection.length];
|
||||
Object[] values = new Object[projection.length];
|
||||
int i = 0;
|
||||
for (String col : projection) {
|
||||
if (OpenableColumns.DISPLAY_NAME.equals(col)) {
|
||||
cols[i] = OpenableColumns.DISPLAY_NAME;
|
||||
values[i++] = file.getName();
|
||||
} else if (OpenableColumns.SIZE.equals(col)) {
|
||||
cols[i] = OpenableColumns.SIZE;
|
||||
values[i++] = file.length();
|
||||
}
|
||||
}
|
||||
|
||||
cols = copyOf(cols, i);
|
||||
values = copyOf(values, i);
|
||||
|
||||
final MatrixCursor cursor = new MatrixCursor(cols, 1);
|
||||
cursor.addRow(values);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the MIME type of a content URI returned by
|
||||
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
|
||||
*
|
||||
* @param uri A content URI returned by
|
||||
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
|
||||
* @return If the associated file has an extension, the MIME type associated with that
|
||||
* extension; otherwise <code>application/octet-stream</code>.
|
||||
*/
|
||||
@Override
|
||||
public String getType(@NonNull Uri uri) {
|
||||
// ContentProvider has already checked granted permissions
|
||||
final File file = mStrategy.getFileForUri(uri);
|
||||
|
||||
final int lastDot = file.getName().lastIndexOf('.');
|
||||
if (lastDot >= 0) {
|
||||
final String extension = file.getName().substring(lastDot + 1);
|
||||
final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
|
||||
if (mime != null) {
|
||||
return mime;
|
||||
}
|
||||
}
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* By default, this method throws an {@link UnsupportedOperationException}. You must
|
||||
* subclass FileProvider if you want to provide different functionality.
|
||||
*/
|
||||
@Override
|
||||
public Uri insert(@NonNull Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("No external inserts");
|
||||
}
|
||||
|
||||
/**
|
||||
* By default, this method throws an {@link UnsupportedOperationException}. You must
|
||||
* subclass FileProvider if you want to provide different functionality.
|
||||
*/
|
||||
@Override
|
||||
public int update(@NonNull Uri uri, ContentValues values, @Nullable String selection,
|
||||
@Nullable String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("No external updates");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file associated with the specified content URI, as
|
||||
* returned by {@link #getUriForFile(Context, String, File) getUriForFile()}. Notice that this
|
||||
* method does <b>not</b> throw an {@link IOException}; you must check its return value.
|
||||
*
|
||||
* @param uri A content URI for a file, as returned by
|
||||
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
|
||||
* @param selection Ignored. Set to {@code null}.
|
||||
* @param selectionArgs Ignored. Set to {@code null}.
|
||||
* @return 1 if the delete succeeds; otherwise, 0.
|
||||
*/
|
||||
@Override
|
||||
public int delete(@NonNull Uri uri, @Nullable String selection,
|
||||
@Nullable String[] selectionArgs) {
|
||||
// ContentProvider has already checked granted permissions
|
||||
final File file = mStrategy.getFileForUri(uri);
|
||||
return file.delete() ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* By default, FileProvider automatically returns the
|
||||
* {@link ParcelFileDescriptor} for a file associated with a <code>content://</code>
|
||||
* {@link Uri}. To get the {@link ParcelFileDescriptor}, call
|
||||
* {@link android.content.ContentResolver#openFileDescriptor(Uri, String)
|
||||
* ContentResolver.openFileDescriptor}.
|
||||
*
|
||||
* To override this method, you must provide your own subclass of FileProvider.
|
||||
*
|
||||
* @param uri A content URI associated with a file, as returned by
|
||||
* {@link #getUriForFile(Context, String, File) getUriForFile()}.
|
||||
* @param mode Access mode for the file. May be "r" for read-only access, "rw" for read and
|
||||
* write access, or "rwt" for read and write access that truncates any existing file.
|
||||
* @return A new {@link ParcelFileDescriptor} with which you can access the file.
|
||||
*/
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
|
||||
throws FileNotFoundException {
|
||||
// ContentProvider has already checked granted permissions
|
||||
final File file = mStrategy.getFileForUri(uri);
|
||||
final int fileMode = modeToMode(mode);
|
||||
return ParcelFileDescriptor.open(file, fileMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return {@link PathStrategy} for given authority, either by parsing or
|
||||
* returning from cache.
|
||||
*/
|
||||
private static PathStrategy getPathStrategy(Context context, String authority) {
|
||||
PathStrategy strat;
|
||||
synchronized (sCache) {
|
||||
strat = sCache.get(authority);
|
||||
if (strat == null) {
|
||||
try {
|
||||
strat = parsePathStrategy(context, authority);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
|
||||
} catch (XmlPullParserException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
|
||||
}
|
||||
sCache.put(authority, strat);
|
||||
}
|
||||
}
|
||||
return strat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and return {@link PathStrategy} for given authority as defined in
|
||||
* {@link #META_DATA_FILE_PROVIDER_PATHS} {@code <meta-data>}.
|
||||
*
|
||||
* @see #getPathStrategy(Context, String)
|
||||
*/
|
||||
private static PathStrategy parsePathStrategy(Context context, String authority)
|
||||
throws IOException, XmlPullParserException {
|
||||
final SimplePathStrategy strat = new SimplePathStrategy(authority);
|
||||
|
||||
final ProviderInfo info = context.getPackageManager()
|
||||
.resolveContentProvider(authority, PackageManager.GET_META_DATA);
|
||||
if (info == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Couldn't find meta-data for provider with authority " + authority);
|
||||
}
|
||||
|
||||
final XmlResourceParser in = info.loadXmlMetaData(
|
||||
context.getPackageManager(), META_DATA_FILE_PROVIDER_PATHS);
|
||||
if (in == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Missing " + META_DATA_FILE_PROVIDER_PATHS + " meta-data");
|
||||
}
|
||||
|
||||
int type;
|
||||
while ((type = in.next()) != END_DOCUMENT) {
|
||||
if (type == START_TAG) {
|
||||
final String tag = in.getName();
|
||||
|
||||
final String name = in.getAttributeValue(null, ATTR_NAME);
|
||||
String path = in.getAttributeValue(null, ATTR_PATH);
|
||||
|
||||
File target = null;
|
||||
if (TAG_ROOT_PATH.equals(tag)) {
|
||||
target = DEVICE_ROOT;
|
||||
} else if (TAG_FILES_PATH.equals(tag)) {
|
||||
target = context.getFilesDir();
|
||||
} else if (TAG_CACHE_PATH.equals(tag)) {
|
||||
target = context.getCacheDir();
|
||||
} else if (TAG_EXTERNAL.equals(tag)) {
|
||||
target = Environment.getExternalStorageDirectory();
|
||||
} else if (TAG_EXTERNAL_FILES.equals(tag)) {
|
||||
File[] externalFilesDirs = context.getExternalFilesDirs(null);
|
||||
if (externalFilesDirs.length > 0) {
|
||||
target = externalFilesDirs[0];
|
||||
}
|
||||
} else if (TAG_EXTERNAL_CACHE.equals(tag)) {
|
||||
File[] externalCacheDirs = context.getExternalCacheDirs();
|
||||
if (externalCacheDirs.length > 0) {
|
||||
target = externalCacheDirs[0];
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
|
||||
&& TAG_EXTERNAL_MEDIA.equals(tag)) {
|
||||
File[] externalMediaDirs = context.getExternalMediaDirs();
|
||||
if (externalMediaDirs.length > 0) {
|
||||
target = externalMediaDirs[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (target != null) {
|
||||
strat.addRoot(name, buildPath(target, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy for mapping between {@link File} and {@link Uri}.
|
||||
* <p>
|
||||
* Strategies must be symmetric so that mapping a {@link File} to a
|
||||
* {@link Uri} and then back to a {@link File} points at the original
|
||||
* target.
|
||||
* <p>
|
||||
* Strategies must remain consistent across app launches, and not rely on
|
||||
* dynamic state. This ensures that any generated {@link Uri} can still be
|
||||
* resolved if your process is killed and later restarted.
|
||||
*
|
||||
* @see SimplePathStrategy
|
||||
*/
|
||||
interface PathStrategy {
|
||||
/**
|
||||
* Return a {@link Uri} that represents the given {@link File}.
|
||||
*/
|
||||
Uri getUriForFile(File file);
|
||||
|
||||
/**
|
||||
* Return a {@link File} that represents the given {@link Uri}.
|
||||
*/
|
||||
File getFileForUri(Uri uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy that provides access to files living under a narrow whitelist of
|
||||
* filesystem roots. It will throw {@link SecurityException} if callers try
|
||||
* accessing files outside the configured roots.
|
||||
* <p>
|
||||
* For example, if configured with
|
||||
* {@code addRoot("myfiles", context.getFilesDir())}, then
|
||||
* {@code context.getFileStreamPath("foo.txt")} would map to
|
||||
* {@code content://myauthority/myfiles/foo.txt}.
|
||||
*/
|
||||
static class SimplePathStrategy implements PathStrategy {
|
||||
private final String mAuthority;
|
||||
private final HashMap<String, File> mRoots = new HashMap<String, File>();
|
||||
|
||||
SimplePathStrategy(String authority) {
|
||||
mAuthority = authority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a mapping from a name to a filesystem root. The provider only offers
|
||||
* access to files that live under configured roots.
|
||||
*/
|
||||
void addRoot(String name, File root) {
|
||||
if (TextUtils.isEmpty(name)) {
|
||||
throw new IllegalArgumentException("Name must not be empty");
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve to canonical path to keep path checking fast
|
||||
root = root.getCanonicalFile();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"Failed to resolve canonical path for " + root, e);
|
||||
}
|
||||
|
||||
mRoots.put(name, root);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getUriForFile(File file) {
|
||||
String path;
|
||||
try {
|
||||
path = file.getCanonicalPath();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Failed to resolve canonical path for " + file);
|
||||
}
|
||||
|
||||
// Find the most-specific root path
|
||||
Map.Entry<String, File> mostSpecific = null;
|
||||
for (Map.Entry<String, File> root : mRoots.entrySet()) {
|
||||
final String rootPath = root.getValue().getPath();
|
||||
if (path.startsWith(rootPath) && (mostSpecific == null
|
||||
|| rootPath.length() > mostSpecific.getValue().getPath().length())) {
|
||||
mostSpecific = root;
|
||||
}
|
||||
}
|
||||
|
||||
if (mostSpecific == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Failed to find configured root that contains " + path);
|
||||
}
|
||||
|
||||
// Start at first char of path under root
|
||||
final String rootPath = mostSpecific.getValue().getPath();
|
||||
if (rootPath.endsWith("/")) {
|
||||
path = path.substring(rootPath.length());
|
||||
} else {
|
||||
path = path.substring(rootPath.length() + 1);
|
||||
}
|
||||
|
||||
// Encode the tag and path separately
|
||||
path = Uri.encode(mostSpecific.getKey()) + '/' + Uri.encode(path, "/");
|
||||
return new Uri.Builder().scheme("content")
|
||||
.authority(mAuthority).encodedPath(path).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public File getFileForUri(Uri uri) {
|
||||
String path = uri.getEncodedPath();
|
||||
|
||||
final int splitIndex = path.indexOf('/', 1);
|
||||
final String tag = Uri.decode(path.substring(1, splitIndex));
|
||||
path = Uri.decode(path.substring(splitIndex + 1));
|
||||
|
||||
final File root = mRoots.get(tag);
|
||||
if (root == null) {
|
||||
throw new IllegalArgumentException("Unable to find configured root for " + uri);
|
||||
}
|
||||
|
||||
File file = new File(root, path);
|
||||
try {
|
||||
file = file.getCanonicalFile();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Failed to resolve canonical path for " + file);
|
||||
}
|
||||
|
||||
if (!file.getPath().startsWith(root.getPath())) {
|
||||
throw new SecurityException("Resolved path jumped beyond configured root");
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from ContentResolver.java
|
||||
*/
|
||||
private static int modeToMode(String mode) {
|
||||
int modeBits;
|
||||
if ("r".equals(mode)) {
|
||||
modeBits = ParcelFileDescriptor.MODE_READ_ONLY;
|
||||
} else if ("w".equals(mode) || "wt".equals(mode)) {
|
||||
modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY
|
||||
| ParcelFileDescriptor.MODE_CREATE
|
||||
| ParcelFileDescriptor.MODE_TRUNCATE;
|
||||
} else if ("wa".equals(mode)) {
|
||||
modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY
|
||||
| ParcelFileDescriptor.MODE_CREATE
|
||||
| ParcelFileDescriptor.MODE_APPEND;
|
||||
} else if ("rw".equals(mode)) {
|
||||
modeBits = ParcelFileDescriptor.MODE_READ_WRITE
|
||||
| ParcelFileDescriptor.MODE_CREATE;
|
||||
} else if ("rwt".equals(mode)) {
|
||||
modeBits = ParcelFileDescriptor.MODE_READ_WRITE
|
||||
| ParcelFileDescriptor.MODE_CREATE
|
||||
| ParcelFileDescriptor.MODE_TRUNCATE;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Invalid mode: " + mode);
|
||||
}
|
||||
return modeBits;
|
||||
}
|
||||
|
||||
private static File buildPath(File base, String... segments) {
|
||||
File cur = base;
|
||||
for (String segment : segments) {
|
||||
if (segment != null) {
|
||||
cur = new File(cur, segment);
|
||||
}
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
private static String[] copyOf(String[] original, int newLength) {
|
||||
final String[] result = new String[newLength];
|
||||
System.arraycopy(original, 0, result, 0, newLength);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Object[] copyOf(Object[] original, int newLength) {
|
||||
final Object[] result = new Object[newLength];
|
||||
System.arraycopy(original, 0, result, 0, newLength);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ public class GlobalUserPreferences{
|
||||
public static boolean playGifs;
|
||||
public static boolean useCustomTabs;
|
||||
public static boolean altTextReminders, confirmUnfollow, confirmBoost, confirmDeletePost;
|
||||
public static ThemePreference theme;
|
||||
public static ThemePreference theme=ThemePreference.AUTO;
|
||||
|
||||
private static SharedPreferences getPrefs(){
|
||||
return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE);
|
||||
|
||||
@@ -61,6 +61,7 @@ public class MainActivity extends FragmentStackActivity{
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent){
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
if(intent.getBooleanExtra("fromNotification", false)){
|
||||
String accountID=intent.getStringExtra("accountID");
|
||||
AccountSession accountSession;
|
||||
@@ -85,6 +86,8 @@ public class MainActivity extends FragmentStackActivity{
|
||||
showCompose();
|
||||
}else if(Intent.ACTION_VIEW.equals(intent.getAction())){
|
||||
handleURL(intent.getData(), null);
|
||||
}else if(intent.getBooleanExtra("explore", false)){
|
||||
restartHomeFragment();
|
||||
}/*else if(intent.hasExtra(PackageInstaller.EXTRA_STATUS) && GithubSelfUpdater.needSelfUpdating()){
|
||||
GithubSelfUpdater.getInstance().handleIntentFromInstaller(intent, this);
|
||||
}*/
|
||||
@@ -152,6 +155,11 @@ public class MainActivity extends FragmentStackActivity{
|
||||
}
|
||||
fragment.setArguments(args);
|
||||
showFragment(fragment);
|
||||
Intent intent=getIntent();
|
||||
intent.removeExtra("fromNotification");
|
||||
intent.removeExtra("notification");
|
||||
intent.removeExtra("accountID");
|
||||
setIntent(intent);
|
||||
}
|
||||
|
||||
private void showCompose(){
|
||||
@@ -206,6 +214,8 @@ public class MainActivity extends FragmentStackActivity{
|
||||
}
|
||||
}else if(intent.getBooleanExtra("compose", false)){
|
||||
showCompose();
|
||||
}else if(intent.getBooleanExtra("explore", false) && fragment instanceof HomeFragment hf){
|
||||
getWindow().getDecorView().post(()->hf.setCurrentTab(R.id.tab_search));
|
||||
}else if(Intent.ACTION_VIEW.equals(intent.getAction())){
|
||||
handleURL(intent.getData(), null);
|
||||
}else{
|
||||
@@ -213,4 +223,10 @@ public class MainActivity extends FragmentStackActivity{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Fragment getTopmostFragment(){
|
||||
if(fragmentContainers.isEmpty())
|
||||
return null;
|
||||
return getFragmentManager().findFragmentById(fragmentContainers.get(fragmentContainers.size()-1).getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package org.joinmastodon.android;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.RemoteInput;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
|
||||
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
|
||||
import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited;
|
||||
import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged;
|
||||
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
|
||||
import org.joinmastodon.android.events.StatusCreatedEvent;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.model.StatusPrivacy;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
|
||||
public class NotificationActionHandlerService extends Service{
|
||||
private static final String TAG="NotificationActionHandl";
|
||||
private int runningRequestCount=0;
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent){
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId){
|
||||
String action=intent.getStringExtra("action");
|
||||
String account=intent.getStringExtra("account");
|
||||
String postID=intent.getStringExtra("post");
|
||||
String notificationTag=intent.getStringExtra("notificationTag");
|
||||
if(action==null || account==null || postID==null || notificationTag==null){
|
||||
maybeStopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
NotificationManager nm=getSystemService(NotificationManager.class);
|
||||
StatusBarNotification notification=findNotification(notificationTag);
|
||||
if("reply".equals(action)){
|
||||
Bundle remoteInputResults=RemoteInput.getResultsFromIntent(intent);
|
||||
if(remoteInputResults==null){
|
||||
maybeStopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
CharSequence replyText=remoteInputResults.getCharSequence("replyText");
|
||||
if(replyText==null){
|
||||
maybeStopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
CreateStatus.Request req=new CreateStatus.Request();
|
||||
req.inReplyToId=postID;
|
||||
req.status=intent.getStringExtra("replyPrefix")+replyText;
|
||||
req.visibility=StatusPrivacy.valueOf(intent.getStringExtra("visibility"));
|
||||
runningRequestCount++;
|
||||
new CreateStatus(req, UUID.randomUUID().toString())
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Status result){
|
||||
E.post(new StatusCreatedEvent(result, account));
|
||||
if(notification!=null){
|
||||
Notification n=notification.getNotification();
|
||||
nm.notify(notificationTag, PushNotificationReceiver.NOTIFICATION_ID, n);
|
||||
}
|
||||
runningRequestCount--;
|
||||
maybeStopSelf();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
error.showToast(NotificationActionHandlerService.this);
|
||||
if(notification!=null){
|
||||
Notification n=notification.getNotification();
|
||||
nm.notify(notificationTag, PushNotificationReceiver.NOTIFICATION_ID, n);
|
||||
}
|
||||
runningRequestCount--;
|
||||
maybeStopSelf();
|
||||
}
|
||||
})
|
||||
.exec(account);
|
||||
}else if("favorite".equals(action)){
|
||||
PendingIntent prevActionIntent;
|
||||
if(notification!=null){
|
||||
Notification n=notification.getNotification();
|
||||
prevActionIntent=n.actions[1].actionIntent;
|
||||
n.actions[1].actionIntent=null;
|
||||
n.actions[1].title=getString(R.string.button_favorited);
|
||||
nm.notify(notificationTag, PushNotificationReceiver.NOTIFICATION_ID, n);
|
||||
}else{
|
||||
prevActionIntent=null;
|
||||
}
|
||||
runningRequestCount++;
|
||||
new SetStatusFavorited(postID, true)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Status result){
|
||||
E.post(new StatusCountersUpdatedEvent(result));
|
||||
runningRequestCount--;
|
||||
maybeStopSelf();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
if(notification!=null){
|
||||
Notification n=notification.getNotification();
|
||||
n.actions[1].actionIntent=prevActionIntent;
|
||||
n.actions[1].title=getString(R.string.button_favorite);
|
||||
nm.notify(notificationTag, PushNotificationReceiver.NOTIFICATION_ID, n);
|
||||
}
|
||||
error.showToast(NotificationActionHandlerService.this);
|
||||
runningRequestCount--;
|
||||
maybeStopSelf();
|
||||
}
|
||||
})
|
||||
.exec(account);
|
||||
}else if("boost".equals(action)){
|
||||
PendingIntent prevActionIntent;
|
||||
if(notification!=null){
|
||||
Notification n=notification.getNotification();
|
||||
prevActionIntent=n.actions[2].actionIntent;
|
||||
n.actions[2].actionIntent=null;
|
||||
n.actions[2].title=getString(R.string.button_reblogged);
|
||||
nm.notify(notificationTag, PushNotificationReceiver.NOTIFICATION_ID, n);
|
||||
}else{
|
||||
prevActionIntent=null;
|
||||
}
|
||||
runningRequestCount++;
|
||||
new SetStatusReblogged(postID, true)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Status result){
|
||||
E.post(new StatusCountersUpdatedEvent(result));
|
||||
runningRequestCount--;
|
||||
maybeStopSelf();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
if(notification!=null){
|
||||
Notification n=notification.getNotification();
|
||||
n.actions[2].actionIntent=prevActionIntent;
|
||||
n.actions[2].title=getString(R.string.button_reblog);
|
||||
nm.notify(notificationTag, PushNotificationReceiver.NOTIFICATION_ID, n);
|
||||
}
|
||||
error.showToast(NotificationActionHandlerService.this);
|
||||
runningRequestCount--;
|
||||
maybeStopSelf();
|
||||
}
|
||||
})
|
||||
.exec(account);
|
||||
}
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
private void maybeStopSelf(){
|
||||
if(runningRequestCount==0)
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
private StatusBarNotification findNotification(String tag){
|
||||
for(StatusBarNotification sbn:getSystemService(NotificationManager.class).getActiveNotifications()){
|
||||
if(tag.equals(sbn.getTag())){
|
||||
return sbn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,15 @@ import android.app.NotificationChannel;
|
||||
import android.app.NotificationChannelGroup;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.RemoteInput;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.Icon;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
@@ -21,10 +22,13 @@ import org.joinmastodon.android.api.requests.notifications.GetNotificationByID;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.Mention;
|
||||
import org.joinmastodon.android.model.PushNotification;
|
||||
import org.joinmastodon.android.model.StatusPrivacy;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -75,7 +79,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{
|
||||
}
|
||||
String accountID=account.getID();
|
||||
PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s);
|
||||
new GetNotificationByID(pn.notificationId+"")
|
||||
new GetNotificationByID(pn.notificationId)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(org.joinmastodon.android.model.Notification result){
|
||||
@@ -144,19 +148,114 @@ public class PushNotificationReceiver extends BroadcastReceiver{
|
||||
.setContentText(pn.body)
|
||||
.setStyle(new Notification.BigTextStyle().bigText(pn.body))
|
||||
.setSmallIcon(R.drawable.ic_ntf_logo)
|
||||
.setContentIntent(PendingIntent.getActivity(context, accountID.hashCode() & 0xFFFF, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setContentIntent(PendingIntent.getActivity(context, (accountID+pn.notificationId).hashCode() & 0xFFFF, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setWhen(notification==null ? System.currentTimeMillis() : notification.createdAt.toEpochMilli())
|
||||
.setShowWhen(true)
|
||||
.setCategory(Notification.CATEGORY_SOCIAL)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setLights(context.getColor(R.color.primary_700), 500, 1000)
|
||||
.setColor(context.getColor(R.color.primary_700));
|
||||
.setColor(context.getColor(R.color.primary_700))
|
||||
.setGroup(accountID);
|
||||
if(avatar!=null){
|
||||
builder.setLargeIcon(UiUtils.getBitmapFromDrawable(avatar));
|
||||
}
|
||||
if(AccountSessionManager.getInstance().getLoggedInAccounts().size()>1){
|
||||
builder.setSubText(accountName);
|
||||
}
|
||||
nm.notify(accountID, NOTIFICATION_ID, builder.build());
|
||||
String notificationTag=accountID+"_"+(notification==null ? 0 : notification.id);
|
||||
if(notification!=null && (notification.type==org.joinmastodon.android.model.Notification.Type.MENTION)){
|
||||
ArrayList<String> mentions=new ArrayList<>();
|
||||
String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id;
|
||||
if(!notification.status.account.id.equals(ownID))
|
||||
mentions.add('@'+notification.status.account.acct);
|
||||
for(Mention mention:notification.status.mentions){
|
||||
if(mention.id.equals(ownID))
|
||||
continue;
|
||||
String m='@'+mention.acct;
|
||||
if(!mentions.contains(m))
|
||||
mentions.add(m);
|
||||
}
|
||||
String replyPrefix=mentions.isEmpty() ? "" : TextUtils.join(" ", mentions)+" ";
|
||||
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
|
||||
Intent replyIntent=new Intent(context, NotificationActionHandlerService.class);
|
||||
replyIntent.putExtra("action", "reply");
|
||||
replyIntent.putExtra("account", accountID);
|
||||
replyIntent.putExtra("post", notification.status.id);
|
||||
replyIntent.putExtra("notificationTag", notificationTag);
|
||||
replyIntent.putExtra("visibility", notification.status.visibility.toString());
|
||||
replyIntent.putExtra("replyPrefix", replyPrefix);
|
||||
builder.addAction(new Notification.Action.Builder(Icon.createWithResource(context, R.drawable.ic_reply_24px),
|
||||
context.getString(R.string.button_reply), PendingIntent.getService(context, (accountID+pn.notificationId+"reply").hashCode(), replyIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE))
|
||||
.addRemoteInput(new RemoteInput.Builder("replyText").setLabel(context.getString(R.string.button_reply)).build())
|
||||
.build());
|
||||
}
|
||||
|
||||
Intent favIntent=new Intent(context, NotificationActionHandlerService.class);
|
||||
favIntent.putExtra("action", "favorite");
|
||||
favIntent.putExtra("account", accountID);
|
||||
favIntent.putExtra("post", notification.status.id);
|
||||
favIntent.putExtra("notificationTag", notificationTag);
|
||||
builder.addAction(new Notification.Action.Builder(Icon.createWithResource(context, R.drawable.ic_star_24px),
|
||||
context.getString(R.string.button_favorite), PendingIntent.getService(context, (accountID+pn.notificationId+"favorite").hashCode(), favIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)).build());
|
||||
|
||||
PendingIntent boostActionIntent;
|
||||
if(notification.status.visibility!=StatusPrivacy.DIRECT){
|
||||
Intent boostIntent=new Intent(context, NotificationActionHandlerService.class);
|
||||
boostIntent.putExtra("action", "boost");
|
||||
boostIntent.putExtra("account", accountID);
|
||||
boostIntent.putExtra("post", notification.status.id);
|
||||
boostIntent.putExtra("notificationTag", notificationTag);
|
||||
boostActionIntent=PendingIntent.getService(context, (accountID+pn.notificationId+"boost").hashCode(), boostIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
}else{
|
||||
boostActionIntent=null;
|
||||
}
|
||||
builder.addAction(new Notification.Action.Builder(Icon.createWithResource(context, R.drawable.ic_boost_24px),
|
||||
context.getString(R.string.button_reblog), boostActionIntent).build());
|
||||
}
|
||||
nm.notify(notificationTag, NOTIFICATION_ID, builder.build());
|
||||
|
||||
StatusBarNotification[] activeNotifications=nm.getActiveNotifications();
|
||||
ArrayList<String> summaryLines=new ArrayList<>();
|
||||
int notificationCount=0;
|
||||
for(StatusBarNotification sbn:activeNotifications){
|
||||
String tag=sbn.getTag();
|
||||
if(tag!=null && tag.startsWith(accountID+"_")){
|
||||
if((sbn.getNotification().flags & Notification.FLAG_GROUP_SUMMARY)==0){
|
||||
if(summaryLines.size()<5){
|
||||
summaryLines.add(sbn.getNotification().extras.getString("android.title"));
|
||||
}
|
||||
notificationCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(summaryLines.size()>1){
|
||||
Notification.Builder summaryBuilder;
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){
|
||||
summaryBuilder=new Notification.Builder(context, accountID+"_"+pn.notificationType);
|
||||
}else{
|
||||
summaryBuilder=new Notification.Builder(context)
|
||||
.setPriority(Notification.PRIORITY_DEFAULT);
|
||||
}
|
||||
Notification.InboxStyle inboxStyle=new Notification.InboxStyle();
|
||||
for(String line:summaryLines){
|
||||
inboxStyle.addLine(line);
|
||||
}
|
||||
summaryBuilder.setContentTitle(context.getString(R.string.app_name))
|
||||
.setContentText(context.getResources().getQuantityString(R.plurals.x_new_notifications, notificationCount, notificationCount))
|
||||
.setSmallIcon(R.drawable.ic_ntf_logo)
|
||||
.setColor(context.getColor(R.color.primary_700))
|
||||
.setContentIntent(PendingIntent.getActivity(context, accountID.hashCode() & 0xFFFF, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setWhen(notification==null ? System.currentTimeMillis() : notification.createdAt.toEpochMilli())
|
||||
.setShowWhen(true)
|
||||
.setCategory(Notification.CATEGORY_SOCIAL)
|
||||
.setAutoCancel(true)
|
||||
.setGroup(accountID)
|
||||
.setGroupSummary(true)
|
||||
.setStyle(inboxStyle.setSummaryText(accountName));
|
||||
nm.notify(accountID+"_summary", NOTIFICATION_ID, summaryBuilder.build());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.joinmastodon.android;
|
||||
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.Arrays;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class TweakedFileProvider extends FileProvider{
|
||||
private static final String TAG="TweakedFileProvider";
|
||||
|
||||
@Override
|
||||
public String getType(@NonNull Uri uri){
|
||||
Log.d(TAG, "getType() called with: uri = ["+uri+"]");
|
||||
if(uri.getPathSegments().get(0).equals("image_cache")){
|
||||
Log.i(TAG, "getType: HERE!");
|
||||
return "image/jpeg"; // might as well be a png but image decoding APIs don't care, needs to be image/* though
|
||||
}
|
||||
return super.getType(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder){
|
||||
Log.d(TAG, "query() called with: uri = ["+uri+"], projection = ["+Arrays.toString(projection)+"], selection = ["+selection+"], selectionArgs = ["+Arrays.toString(selectionArgs)+"], sortOrder = ["+sortOrder+"]");
|
||||
return super.query(uri, projection, selection, selectionArgs, sortOrder);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException{
|
||||
Log.d(TAG, "openFile() called with: uri = ["+uri+"], mode = ["+mode+"]");
|
||||
return super.openFile(uri, mode);
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,10 @@ public class MastodonAPIController{
|
||||
|
||||
@Override
|
||||
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException{
|
||||
if(req.canceled)
|
||||
if(req.canceled){
|
||||
response.close();
|
||||
return;
|
||||
}
|
||||
if(BuildConfig.DEBUG)
|
||||
Log.d(TAG, logTag(session)+hreq+" received response: "+response);
|
||||
synchronized(req){
|
||||
|
||||
@@ -5,31 +5,62 @@ import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.google.gson.JsonIOException;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
|
||||
public class MastodonErrorResponse extends ErrorResponse{
|
||||
public final String error;
|
||||
public final int httpStatus;
|
||||
public final Throwable underlyingException;
|
||||
public final int messageResource;
|
||||
|
||||
public MastodonErrorResponse(String error, int httpStatus, Throwable exception){
|
||||
this.error=error;
|
||||
this.httpStatus=httpStatus;
|
||||
this.underlyingException=exception;
|
||||
|
||||
if(exception instanceof UnknownHostException){
|
||||
this.messageResource=R.string.could_not_reach_server;
|
||||
}else if(exception instanceof SocketTimeoutException){
|
||||
this.messageResource=R.string.connection_timed_out;
|
||||
}else if(exception instanceof JsonSyntaxException || exception instanceof JsonIOException || httpStatus>=500){
|
||||
this.messageResource=R.string.server_error;
|
||||
}else if(httpStatus==404){
|
||||
this.messageResource=R.string.not_found;
|
||||
}else{
|
||||
this.messageResource=0;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bindErrorView(View view){
|
||||
TextView text=view.findViewById(R.id.error_text);
|
||||
text.setText(error);
|
||||
String message;
|
||||
if(messageResource>0){
|
||||
message=view.getContext().getString(messageResource, error);
|
||||
}else{
|
||||
message=error;
|
||||
}
|
||||
text.setText(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showToast(Context context){
|
||||
if(context==null)
|
||||
return;
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show();
|
||||
String message;
|
||||
if(messageResource>0){
|
||||
message=context.getString(messageResource, error);
|
||||
}else{
|
||||
message=error;
|
||||
}
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.joinmastodon.android.api.requests.accounts;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
@@ -10,7 +12,7 @@ import java.util.List;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class GetAccountStatuses extends MastodonAPIRequest<List<Status>>{
|
||||
public GetAccountStatuses(String id, String maxID, String minID, int limit, @NonNull Filter filter){
|
||||
public GetAccountStatuses(String id, String maxID, String minID, int limit, @NonNull Filter filter, String hashtag){
|
||||
super(HttpMethod.GET, "/accounts/"+id+"/statuses", new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
@@ -29,6 +31,8 @@ public class GetAccountStatuses extends MastodonAPIRequest<List<Status>>{
|
||||
case OWN_POSTS_AND_REPLIES -> addQueryParameter("exclude_reblogs", "true");
|
||||
case PINNED -> addQueryParameter("pinned", "true");
|
||||
}
|
||||
if(!TextUtils.isEmpty(hashtag))
|
||||
addQueryParameter("tagged", hashtag);
|
||||
}
|
||||
|
||||
public enum Filter{
|
||||
|
||||
@@ -4,10 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
|
||||
public class SetAccountFollowed extends MastodonAPIRequest<Relationship>{
|
||||
public SetAccountFollowed(String id, boolean followed, boolean showReblogs){
|
||||
public SetAccountFollowed(String id, boolean followed, boolean showReblogs, boolean notify){
|
||||
super(HttpMethod.POST, "/accounts/"+id+"/"+(followed ? "follow" : "unfollow"), Relationship.class);
|
||||
if(followed)
|
||||
setRequestBody(new Request(showReblogs, null));
|
||||
setRequestBody(new Request(showReblogs, notify));
|
||||
else
|
||||
setRequestBody(new Object());
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ import java.util.List;
|
||||
public class GetCatalogInstances extends MastodonAPIRequest<List<CatalogInstance>>{
|
||||
|
||||
private String lang, category;
|
||||
private boolean includeClosedSignups;
|
||||
|
||||
public GetCatalogInstances(String lang, String category){
|
||||
public GetCatalogInstances(String lang, String category, boolean includeClosedSignups){
|
||||
super(HttpMethod.GET, null, new TypeToken<>(){});
|
||||
this.lang=lang;
|
||||
this.category=category;
|
||||
this.includeClosedSignups=includeClosedSignups;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -30,6 +32,8 @@ public class GetCatalogInstances extends MastodonAPIRequest<List<CatalogInstance
|
||||
builder.appendQueryParameter("language", lang);
|
||||
if(!TextUtils.isEmpty(category))
|
||||
builder.appendQueryParameter("category", category);
|
||||
if(includeClosedSignups)
|
||||
builder.appendQueryParameter("registrations", "all");
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.joinmastodon.android.api.requests.notifications;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
|
||||
import org.joinmastodon.android.model.NotificationRequest;
|
||||
|
||||
public class GetNotificationRequests extends HeaderPaginationRequest<NotificationRequest>{
|
||||
public GetNotificationRequests(String maxID){
|
||||
super(HttpMethod.GET, "/notifications/requests", new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.joinmastodon.android.api.requests.notifications;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.ApiUtils;
|
||||
@@ -12,6 +13,10 @@ import java.util.List;
|
||||
|
||||
public class GetNotifications extends MastodonAPIRequest<List<Notification>>{
|
||||
public GetNotifications(String maxID, int limit, EnumSet<Notification.Type> includeTypes){
|
||||
this(maxID, limit, includeTypes, null);
|
||||
}
|
||||
|
||||
public GetNotifications(String maxID, int limit, EnumSet<Notification.Type> includeTypes, String onlyAccountID){
|
||||
super(HttpMethod.GET, "/notifications", new TypeToken<>(){});
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
@@ -25,6 +30,8 @@ public class GetNotifications extends MastodonAPIRequest<List<Notification>>{
|
||||
addQueryParameter("exclude_types[]", type);
|
||||
}
|
||||
}
|
||||
if(!TextUtils.isEmpty(onlyAccountID))
|
||||
addQueryParameter("account_id", onlyAccountID);
|
||||
removeUnsupportedItems=true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.joinmastodon.android.api.requests.notifications;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.NotificationsPolicy;
|
||||
|
||||
public class GetNotificationsPolicy extends MastodonAPIRequest<NotificationsPolicy>{
|
||||
public GetNotificationsPolicy(){
|
||||
super(HttpMethod.GET, "/notifications/policy", NotificationsPolicy.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.joinmastodon.android.api.requests.notifications;
|
||||
|
||||
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
|
||||
|
||||
public class RespondToNotificationRequest extends ResultlessMastodonAPIRequest{
|
||||
public RespondToNotificationRequest(String id, boolean allow){
|
||||
super(HttpMethod.POST, "/notifications/requests/"+id+(allow ? "/accept" : "/dismiss"));
|
||||
setRequestBody(new Object());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.joinmastodon.android.api.requests.notifications;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.NotificationsPolicy;
|
||||
|
||||
public class SetNotificationsPolicy extends MastodonAPIRequest<NotificationsPolicy>{
|
||||
public SetNotificationsPolicy(NotificationsPolicy policy){
|
||||
super(HttpMethod.PUT, "/notifications/policy", NotificationsPolicy.class);
|
||||
setRequestBody(policy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.joinmastodon.android.api.requests.statuses;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
|
||||
public class SetStatusConversationMuted extends MastodonAPIRequest<Status>{
|
||||
public SetStatusConversationMuted(String id, boolean muted){
|
||||
super(HttpMethod.POST, "/statuses/"+id+(muted ? "/mute" : "/unmute"), Status.class);
|
||||
setRequestBody(new Object());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.joinmastodon.android.api.requests.statuses;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
|
||||
public class SetStatusPinned extends MastodonAPIRequest<Status>{
|
||||
public SetStatusPinned(String id, boolean pinned){
|
||||
super(HttpMethod.POST, "/statuses/"+id+"/"+(pinned ? "pin" : "unpin"), Status.class);
|
||||
setRequestBody(new Object());
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ public class AccountSessionManager{
|
||||
Log.e(TAG, "Error loading accounts", x);
|
||||
}
|
||||
lastActiveAccountID=prefs.getString("lastActiveAccount", null);
|
||||
MastodonAPIController.runInBackground(()->readInstanceInfo(domains));
|
||||
readInstanceInfo(domains);
|
||||
maybeUpdateShortcuts();
|
||||
}
|
||||
|
||||
@@ -270,11 +270,11 @@ public class AccountSessionManager{
|
||||
}
|
||||
}
|
||||
if(loadedInstances){
|
||||
maybeUpdateCustomEmojis(domains);
|
||||
maybeUpdateInstanceInfo(domains);
|
||||
}
|
||||
}
|
||||
|
||||
private void maybeUpdateCustomEmojis(Set<String> domains){
|
||||
private void maybeUpdateInstanceInfo(Set<String> domains){
|
||||
long now=System.currentTimeMillis();
|
||||
for(String domain:domains){
|
||||
Long lastUpdated=instancesLastUpdated.get(domain);
|
||||
@@ -388,7 +388,7 @@ public class AccountSessionManager{
|
||||
}
|
||||
if(!loadedInstances){
|
||||
loadedInstances=true;
|
||||
maybeUpdateCustomEmojis(domains);
|
||||
MastodonAPIController.runInBackground(()->maybeUpdateInstanceInfo(domains));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +425,7 @@ public class AccountSessionManager{
|
||||
ShortcutManager sm=MastodonApp.context.getSystemService(ShortcutManager.class);
|
||||
if((sm.getDynamicShortcuts().isEmpty() || BuildConfig.DEBUG) && !sessions.isEmpty()){
|
||||
// There are no shortcuts, but there are accounts. Add a compose shortcut.
|
||||
ShortcutInfo info=new ShortcutInfo.Builder(MastodonApp.context, "compose")
|
||||
ShortcutInfo compose=new ShortcutInfo.Builder(MastodonApp.context, "compose")
|
||||
.setActivity(ComponentName.createRelative(MastodonApp.context, MainActivity.class.getName()))
|
||||
.setShortLabel(MastodonApp.context.getString(R.string.new_post))
|
||||
.setIcon(Icon.createWithResource(MastodonApp.context, R.mipmap.ic_shortcut_compose))
|
||||
@@ -433,12 +433,20 @@ public class AccountSessionManager{
|
||||
.setAction(Intent.ACTION_MAIN)
|
||||
.putExtra("compose", true))
|
||||
.build();
|
||||
sm.setDynamicShortcuts(Collections.singletonList(info));
|
||||
ShortcutInfo explore=new ShortcutInfo.Builder(MastodonApp.context, "explore")
|
||||
.setActivity(ComponentName.createRelative(MastodonApp.context, MainActivity.class.getName()))
|
||||
.setShortLabel(MastodonApp.context.getString(R.string.tab_search))
|
||||
.setIcon(Icon.createWithResource(MastodonApp.context, R.mipmap.ic_shortcut_explore))
|
||||
.setIntent(new Intent(MastodonApp.context, MainActivity.class)
|
||||
.setAction(Intent.ACTION_MAIN)
|
||||
.putExtra("explore", true))
|
||||
.build();
|
||||
sm.setDynamicShortcuts(List.of(compose, explore));
|
||||
}else if(sessions.isEmpty()){
|
||||
// There are shortcuts, but no accounts. Disable existing shortcuts.
|
||||
sm.disableShortcuts(Collections.singletonList("compose"), MastodonApp.context.getString(R.string.err_not_logged_in));
|
||||
sm.disableShortcuts(List.of("compose", "explore"), MastodonApp.context.getString(R.string.err_not_logged_in));
|
||||
}else{
|
||||
sm.enableShortcuts(Collections.singletonList("compose"));
|
||||
sm.enableShortcuts(List.of("compose", "explore"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.joinmastodon.android.events;
|
||||
|
||||
public class NotificationRequestRespondedEvent{
|
||||
public final String accountID, requestID;
|
||||
|
||||
public NotificationRequestRespondedEvent(String accountID, String requestID){
|
||||
this.accountID=accountID;
|
||||
this.requestID=requestID;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.notifications.GetNotifications;
|
||||
import org.joinmastodon.android.api.requests.notifications.RespondToNotificationRequest;
|
||||
import org.joinmastodon.android.events.NotificationRequestRespondedEvent;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.Notification;
|
||||
import org.joinmastodon.android.ui.Snackbar;
|
||||
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.utils.MergeRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
|
||||
|
||||
public class AccountNotificationsListFragment extends BaseNotificationsListFragment{
|
||||
private Account account;
|
||||
private String requestID;
|
||||
private TextView expandedTitle;
|
||||
private boolean choiceMade, allowed;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
|
||||
requestID=getArguments().getString("requestID");
|
||||
setTitleMarqueeEnabled(false);
|
||||
loadData();
|
||||
setTitle(getString(R.string.notifications_from_user, account.displayName));
|
||||
setHasOptionsMenu(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
if(!refreshing && endMark!=null)
|
||||
endMark.setVisibility(View.GONE);
|
||||
currentRequest=new GetNotifications(offset==0 ? null : maxID, count, EnumSet.allOf(Notification.Type.class), account.id)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Notification> result){
|
||||
onDataLoaded(result, !result.isEmpty());
|
||||
maxID=result.isEmpty() ? null : result.get(result.size()-1).id;
|
||||
endMark.setVisibility(result.isEmpty() ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean needDividerForExtraItem(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder){
|
||||
return super.needDividerForExtraItem(child, bottomSibling, holder, siblingHolder) || (siblingHolder!=null && siblingHolder.getAbsoluteAdapterPosition()>=list.getAdapter().getItemCount());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RecyclerView.Adapter getAdapter(){
|
||||
MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
|
||||
|
||||
expandedTitle=(TextView) LayoutInflater.from(getActivity()).inflate(R.layout.expanded_title_medium, list, false);
|
||||
expandedTitle.setText(getTitle());
|
||||
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(expandedTitle));
|
||||
|
||||
mergeAdapter.addAdapter(super.getAdapter());
|
||||
return mergeAdapter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
|
||||
@Override
|
||||
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
|
||||
if(recyclerView.getChildCount()==0)
|
||||
return;
|
||||
float fraction;
|
||||
View topChild=recyclerView.getChildAt(0);
|
||||
if(recyclerView.getChildAdapterPosition(topChild)>0){
|
||||
fraction=1;
|
||||
}else{
|
||||
fraction=(-topChild.getTop())/(float)(topChild.getHeight()-topChild.getPaddingBottom());
|
||||
}
|
||||
expandedTitle.setAlpha(1f-fraction);
|
||||
toolbarTitleView.setAlpha(fraction);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
|
||||
inflater.inflate(R.menu.notification_request, menu);
|
||||
MenuItem mute=menu.findItem(R.id.mute);
|
||||
MenuItem allow=menu.findItem(R.id.allow);
|
||||
if(choiceMade && allowed){
|
||||
allow.setIcon(R.drawable.ic_check_wght700_24px);
|
||||
tintMenuIcon(allow, R.attr.colorM3Primary);
|
||||
}else{
|
||||
tintMenuIcon(allow, R.attr.colorM3OnSurfaceVariant);
|
||||
}
|
||||
if(choiceMade && !allowed){
|
||||
mute.setIcon(R.drawable.ic_delete_wght700_24px);
|
||||
tintMenuIcon(mute, R.attr.colorM3Primary);
|
||||
}else{
|
||||
tintMenuIcon(mute, R.attr.colorM3OnSurfaceVariant);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item){
|
||||
if(choiceMade)
|
||||
return true;
|
||||
allowed=item.getItemId()==R.id.allow;
|
||||
new RespondToNotificationRequest(requestID, allowed)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Void result){
|
||||
choiceMade=true;
|
||||
invalidateOptionsMenu();
|
||||
E.post(new NotificationRequestRespondedEvent(accountID, requestID));
|
||||
new Snackbar.Builder(getActivity())
|
||||
.setText(getString(allowed ? R.string.notifications_allowed : R.string.notifications_muted, account.displayName))
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
error.showToast(getActivity());
|
||||
}
|
||||
})
|
||||
.wrapProgress(getActivity(), R.string.loading, false)
|
||||
.exec(accountID);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<StatusDisplayItem> buildDisplayItems(Notification n){
|
||||
if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){
|
||||
return StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, StatusDisplayItem.FLAG_MEDIA_FORCE_HIDDEN);
|
||||
}
|
||||
return super.buildDisplayItems(n);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean wantsToolbarMenuIconsTinted(){
|
||||
return false;
|
||||
}
|
||||
|
||||
private void tintMenuIcon(MenuItem item, int color){
|
||||
int tintColor=UiUtils.getThemeColor(getActivity(), color);
|
||||
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.O){
|
||||
Drawable icon=item.getIcon();
|
||||
if(icon!=null && icon.getColorFilter()==null){
|
||||
icon=icon.mutate();
|
||||
icon.setTintList(ColorStateList.valueOf(tintColor));
|
||||
item.setIcon(icon);
|
||||
}
|
||||
}else{
|
||||
item.setIconTintList(ColorStateList.valueOf(tintColor));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
|
||||
import org.joinmastodon.android.events.StatusCreatedEvent;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.FilterContext;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
@@ -58,7 +57,7 @@ public class AccountTimelineFragment extends StatusListFragment{
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter)
|
||||
currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter, null)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Status> result){
|
||||
@@ -141,11 +140,6 @@ public class AccountTimelineFragment extends StatusListFragment{
|
||||
return mergeAdapter;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getMainAdapterOffset(){
|
||||
return super.getMainAdapterOffset()+1;
|
||||
}
|
||||
|
||||
private FilterChipView getViewForFilter(GetAccountStatuses.Filter filter){
|
||||
return switch(filter){
|
||||
case DEFAULT -> defaultFilter;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.model.Notification;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.displayitems.NotificationHeaderStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.Nav;
|
||||
|
||||
public abstract class BaseNotificationsListFragment extends BaseStatusListFragment<Notification>{
|
||||
protected String maxID;
|
||||
protected View endMark;
|
||||
|
||||
@Override
|
||||
protected List<StatusDisplayItem> buildDisplayItems(Notification n){
|
||||
NotificationHeaderStatusDisplayItem titleItem;
|
||||
if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){
|
||||
titleItem=null;
|
||||
}else{
|
||||
titleItem=new NotificationHeaderStatusDisplayItem(n.id, this, n, accountID);
|
||||
if(n.status!=null){
|
||||
n.status.card=null;
|
||||
n.status.spoilerText=null;
|
||||
}
|
||||
}
|
||||
if(n.status!=null){
|
||||
int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_HEADER);
|
||||
ArrayList<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, flags);
|
||||
if(titleItem!=null)
|
||||
items.add(0, titleItem);
|
||||
return items;
|
||||
}else if(titleItem!=null){
|
||||
return Collections.singletonList(titleItem);
|
||||
}else{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addAccountToKnown(Notification s){
|
||||
if(!knownAccounts.containsKey(s.account.id))
|
||||
knownAccounts.put(s.account.id, s.account);
|
||||
if(s.status!=null && !knownAccounts.containsKey(s.status.account.id))
|
||||
knownAccounts.put(s.status.account.id, s.status.account);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemClick(String id){
|
||||
Notification n=getNotificationByID(id);
|
||||
if(n.status!=null){
|
||||
Status status=n.status;
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("status", Parcels.wrap(status.clone()));
|
||||
if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId))
|
||||
args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId)));
|
||||
Nav.go(getActivity(), ThreadFragment.class, args);
|
||||
}else{
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("profileAccount", Parcels.wrap(n.account));
|
||||
Nav.go(getActivity(), ProfileFragment.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
private Notification getNotificationByID(String id){
|
||||
for(Notification n : data){
|
||||
if(n.id.equals(id))
|
||||
return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected void removeNotification(Notification n){
|
||||
data.remove(n);
|
||||
preloadedData.remove(n);
|
||||
int index=-1;
|
||||
for(int i=0; i<displayItems.size(); i++){
|
||||
if(n.id.equals(displayItems.get(i).parentID)){
|
||||
index=i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(index==-1)
|
||||
return;
|
||||
int lastIndex;
|
||||
for(lastIndex=index; lastIndex<displayItems.size(); lastIndex++){
|
||||
if(!displayItems.get(lastIndex).parentID.equals(n.id))
|
||||
break;
|
||||
}
|
||||
displayItems.subList(index, lastIndex).clear();
|
||||
adapter.notifyItemRangeRemoved(index, lastIndex-index);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected View onCreateFooterView(LayoutInflater inflater){
|
||||
View v=inflater.inflate(R.layout.load_more_with_end_mark, null);
|
||||
endMark=v.findViewById(R.id.end_mark);
|
||||
endMark.setVisibility(View.GONE);
|
||||
return v;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
list.addItemDecoration(new InsetStatusItemDecoration(this));
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,9 @@ import java.util.stream.Collectors;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.dynamicanimation.animation.DynamicAnimation;
|
||||
import androidx.dynamicanimation.animation.SpringAnimation;
|
||||
import androidx.dynamicanimation.animation.SpringForce;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
@@ -79,6 +82,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
protected HashMap<String, Relationship> relationships=new HashMap<>();
|
||||
protected Rect tmpRect=new Rect();
|
||||
protected TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, MediaAttachmentViewController> attachmentViewsPool=new TypedObjectPool<>(this::makeNewMediaAttachmentView);
|
||||
private SpringAnimation listShakeAnimation;
|
||||
|
||||
public BaseStatusListFragment(){
|
||||
super(20);
|
||||
@@ -283,8 +287,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
outRect.set(view.getLeft(), view.getTop(), view.getRight(), view.getBottom());
|
||||
}
|
||||
RecyclerView.ViewHolder holder=list.getChildViewHolder(view);
|
||||
if(holder instanceof StatusDisplayItem.Holder){
|
||||
if(((StatusDisplayItem.Holder<?>) holder).getItem().getType()==StatusDisplayItem.Type.GAP){
|
||||
if(holder instanceof StatusDisplayItem.Holder<?> sih){
|
||||
if(sih.getItem() instanceof StatusDisplayItem sdi && sdi.getType()==StatusDisplayItem.Type.GAP){
|
||||
outRect.setEmpty();
|
||||
return;
|
||||
}
|
||||
@@ -292,8 +296,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
for(int i=0;i<list.getChildCount();i++){
|
||||
View child=list.getChildAt(i);
|
||||
holder=list.getChildViewHolder(child);
|
||||
if(holder instanceof StatusDisplayItem.Holder){
|
||||
String otherID=((StatusDisplayItem.Holder<?>) holder).getItemID();
|
||||
if(holder instanceof StatusDisplayItem.Holder<?> sih2){
|
||||
String otherID=sih2.getItemID();
|
||||
if(otherID.equals(id)){
|
||||
list.getDecoratedBoundsWithMargins(child, tmpRect);
|
||||
outRect.left=Math.min(outRect.left, tmpRect.left);
|
||||
@@ -325,7 +329,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
toolbar.setNavigationContentDescription(R.string.back);
|
||||
}
|
||||
|
||||
protected int getMainAdapterOffset(){
|
||||
public int getMainAdapterOffset(){
|
||||
if(list.getAdapter() instanceof MergeRecyclerAdapter mergeAdapter){
|
||||
return mergeAdapter.getPositionForAdapter(adapter);
|
||||
}
|
||||
@@ -662,7 +666,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
proceed.run();
|
||||
}, status.account, accountID).show();
|
||||
}else if(!GlobalUserPreferences.isOptedOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.OLD_POST, null, null) &&
|
||||
status.createdAt.isBefore(Instant.now().minus(90, ChronoUnit.DAYS))){
|
||||
status.createdAt.isBefore(Instant.now().minus(90, ChronoUnit.DAYS)) && !status.account.id.equals(AccountSessionManager.get(accountID).self.id)){
|
||||
new OldPostPreReplySheet(getActivity(), notAgain->{
|
||||
if(notAgain)
|
||||
GlobalUserPreferences.optOutOfPreReplySheet(GlobalUserPreferences.PreReplySheetType.OLD_POST, null, null);
|
||||
@@ -675,6 +679,17 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
|
||||
protected void onModifyItemViewHolder(BindableViewHolder<StatusDisplayItem> holder){}
|
||||
|
||||
public void shakeListView(){
|
||||
if(listShakeAnimation!=null)
|
||||
listShakeAnimation.cancel();
|
||||
SpringAnimation anim=new SpringAnimation(list, DynamicAnimation.TRANSLATION_X, 0);
|
||||
anim.setStartVelocity(V.dp(-500));
|
||||
anim.getSpring().setStiffness(500).setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY);
|
||||
listShakeAnimation=anim;
|
||||
anim.addEndListener((animation, canceled, value, velocity)->listShakeAnimation=null);
|
||||
anim.start();
|
||||
}
|
||||
|
||||
protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{
|
||||
|
||||
public DisplayItemsAdapter(){
|
||||
@@ -745,7 +760,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
// Do not draw dividers between hashtag and/or account rows
|
||||
if((ih instanceof HashtagStatusDisplayItem.Holder || ih instanceof AccountStatusDisplayItem.Holder) && (sh instanceof HashtagStatusDisplayItem.Holder || sh instanceof AccountStatusDisplayItem.Holder))
|
||||
return false;
|
||||
return !ih.getItemID().equals(sh.getItemID()) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP;
|
||||
return !ih.getItemID().equals(sh.getItemID()) && ih.getItem() instanceof StatusDisplayItem sdi && sdi.getType()!=StatusDisplayItem.Type.GAP;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.animation.AnimatorSet;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
@@ -22,6 +23,10 @@ import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.text.style.BackgroundColorSpan;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.transition.ChangeBounds;
|
||||
import android.transition.Fade;
|
||||
import android.transition.TransitionManager;
|
||||
import android.transition.TransitionSet;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
@@ -31,15 +36,14 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewOutlineProvider;
|
||||
import android.view.WindowManager;
|
||||
import android.view.accessibility.AccessibilityNodeInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.PopupMenu;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
@@ -65,7 +69,9 @@ import org.joinmastodon.android.model.Mention;
|
||||
import org.joinmastodon.android.model.Preferences;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.model.StatusPrivacy;
|
||||
import org.joinmastodon.android.model.viewmodel.ListItem;
|
||||
import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard;
|
||||
import org.joinmastodon.android.ui.ExtendedPopupMenu;
|
||||
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.PopupKeyboard;
|
||||
@@ -86,21 +92,22 @@ import org.parceler.Parcels;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.fragments.CustomTransitionsFragment;
|
||||
import me.grishka.appkit.fragments.OnBackPressedListener;
|
||||
import me.grishka.appkit.imageloader.ViewImageLoader;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.CubicBezierInterpolator;
|
||||
import me.grishka.appkit.utils.V;
|
||||
|
||||
public class ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener, CustomTransitionsFragment{
|
||||
public class ComposeFragment extends MastodonToolbarFragment implements ComposeEditText.SelectionListener, CustomTransitionsFragment{
|
||||
|
||||
private static final int MEDIA_RESULT=717;
|
||||
public static final int IMAGE_DESCRIPTION_RESULT=363;
|
||||
@@ -130,7 +137,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
|
||||
private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn, languageBtn;
|
||||
private TextView replyText;
|
||||
private Button visibilityBtn;
|
||||
private LinearLayout visibilityBtn;
|
||||
private TextView visibilityText1, visibilityText2, visibilityCurrentText;
|
||||
private LinearLayout bottomBar;
|
||||
private View autocompleteDivider;
|
||||
|
||||
@@ -164,6 +172,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
private BackgroundColorSpan overLimitBG;
|
||||
private ForegroundColorSpan overLimitFG;
|
||||
|
||||
private Runnable emojiKeyboardHider;
|
||||
private Runnable sendingBackButtonBlocker=()->{};
|
||||
private Runnable discardConfirmationCallback=this::confirmDiscardDraftAndFinish;
|
||||
private boolean prevHadDraft;
|
||||
|
||||
public ComposeFragment(){
|
||||
super(R.layout.toolbar_fragment_with_progressbar);
|
||||
}
|
||||
@@ -240,6 +253,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
getActivity().dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL));
|
||||
}
|
||||
});
|
||||
emojiKeyboardHider=emojiKeyboard::hide;
|
||||
|
||||
View view=inflater.inflate(R.layout.fragment_compose, container, false);
|
||||
mainLayout=view.findViewById(R.id.compose_main_ll);
|
||||
@@ -270,6 +284,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
emojiBtn=view.findViewById(R.id.btn_emoji);
|
||||
spoilerBtn=view.findViewById(R.id.btn_spoiler);
|
||||
visibilityBtn=view.findViewById(R.id.btn_visibility);
|
||||
visibilityText1=view.findViewById(R.id.visibility_text1);
|
||||
visibilityText2=view.findViewById(R.id.visibility_text2);
|
||||
visibilityCurrentText=visibilityText1;
|
||||
languageBtn=view.findViewById(R.id.btn_language);
|
||||
replyText=view.findViewById(R.id.reply_text);
|
||||
|
||||
@@ -279,14 +296,24 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
spoilerBtn.setOnClickListener(v->toggleSpoiler());
|
||||
languageBtn.setOnClickListener(v->showLanguageAlert());
|
||||
visibilityBtn.setOnClickListener(this::onVisibilityClick);
|
||||
visibilityBtn.setAccessibilityDelegate(new View.AccessibilityDelegate(){
|
||||
@Override
|
||||
public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info){
|
||||
super.onInitializeAccessibilityNodeInfo(host, info);
|
||||
info.setClassName("android.widget.Spinner");
|
||||
}
|
||||
});
|
||||
Drawable arrow=getResources().getDrawable(R.drawable.ic_baseline_arrow_drop_down_18, getActivity().getTheme()).mutate();
|
||||
arrow.setTint(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface));
|
||||
visibilityBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrow, null);
|
||||
emojiKeyboard.setOnIconChangedListener(new PopupKeyboard.OnIconChangeListener(){
|
||||
@Override
|
||||
public void onIconChanged(int icon){
|
||||
emojiBtn.setSelected(icon!=PopupKeyboard.ICON_HIDDEN);
|
||||
updateNavigationBarColor(icon!=PopupKeyboard.ICON_HIDDEN);
|
||||
if(icon!=PopupKeyboard.ICON_HIDDEN)
|
||||
addBackCallback(emojiKeyboardHider);
|
||||
else
|
||||
removeBackCallback(emojiKeyboardHider);
|
||||
if(autocompleteViewController.getMode()==ComposeAutocompleteViewController.Mode.EMOJIS){
|
||||
contentView.layout(contentView.getLeft(), contentView.getTop(), contentView.getRight(), contentView.getBottom());
|
||||
if(icon==PopupKeyboard.ICON_HIDDEN)
|
||||
@@ -322,7 +349,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
if(editingStatus!=null && editingStatus.visibility!=null) {
|
||||
statusVisibility=editingStatus.visibility;
|
||||
}
|
||||
updateVisibilityIcon();
|
||||
updateVisibilityIcon(false);
|
||||
|
||||
autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID);
|
||||
autocompleteViewController.setCompletionSelectedListener(new ComposeAutocompleteViewController.AutocompleteListener(){
|
||||
@@ -377,6 +404,21 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
super.onResume();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onHidden(){
|
||||
super.onHidden();
|
||||
if(prevHadDraft){
|
||||
prevHadDraft=false;
|
||||
removeBackCallback(discardConfirmationCallback);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onShown(){
|
||||
super.onShown();
|
||||
updateDraftState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
@@ -462,6 +504,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
}
|
||||
|
||||
updateCharCounter();
|
||||
updateDraftState();
|
||||
}
|
||||
});
|
||||
spoilerEdit.addTextChangedListener(new SimpleTextWatcher(e->updateCharCounter()));
|
||||
@@ -501,7 +544,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
ignoreSelectionChanges=true;
|
||||
mainEditText.setSelection(mainEditText.length());
|
||||
ignoreSelectionChanges=false;
|
||||
mediaViewController.onViewCreated(savedInstanceState);;
|
||||
mediaViewController.onViewCreated(savedInstanceState);
|
||||
}else{
|
||||
String prefilledText=getArguments().getString("prefilledText");
|
||||
if(!TextUtils.isEmpty(prefilledText)){
|
||||
@@ -603,6 +646,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
if(publishButton==null)
|
||||
return;
|
||||
publishButton.setEnabled((trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1));
|
||||
updateDraftState();
|
||||
}
|
||||
|
||||
private void onCustomEmojiClick(Emoji emoji){
|
||||
@@ -678,6 +722,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
overlayParams.softInputMode=WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED;
|
||||
overlayParams.token=mainEditText.getWindowToken();
|
||||
wm.addView(sendingOverlay, overlayParams);
|
||||
addBackCallback(sendingBackButtonBlocker);
|
||||
|
||||
publishButton.setEnabled(false);
|
||||
V.setVisibilityAnimated(sendProgress, View.VISIBLE);
|
||||
@@ -702,8 +747,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
if(!pollViewController.isEmpty()){
|
||||
req.poll=pollViewController.getPollForRequest();
|
||||
}
|
||||
if(hasSpoiler && spoilerEdit.length()>0){
|
||||
req.spoilerText=spoilerEdit.getText().toString();
|
||||
if(hasSpoiler){
|
||||
if(spoilerEdit.length()>0)
|
||||
req.spoilerText=spoilerEdit.getText().toString();
|
||||
else
|
||||
req.sensitive=true;
|
||||
}
|
||||
if(postLang!=null){
|
||||
req.language=postLang.locale.toLanguageTag();
|
||||
@@ -716,6 +764,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
public void onSuccess(Status result){
|
||||
wm.removeView(sendingOverlay);
|
||||
sendingOverlay=null;
|
||||
removeBackCallback(sendingBackButtonBlocker);
|
||||
removeBackCallback(discardConfirmationCallback);
|
||||
removeBackCallback(emojiKeyboardHider);
|
||||
if(editingStatus==null){
|
||||
E.post(new StatusCreatedEvent(result, accountID));
|
||||
if(replyTo!=null){
|
||||
@@ -748,6 +799,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
private void handlePublishError(ErrorResponse error){
|
||||
wm.removeView(sendingOverlay);
|
||||
sendingOverlay=null;
|
||||
removeBackCallback(sendingBackButtonBlocker);
|
||||
V.setVisibilityAnimated(sendProgress, View.GONE);
|
||||
publishButton.setEnabled(true);
|
||||
if(error instanceof MastodonErrorResponse me){
|
||||
@@ -775,19 +827,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !mediaViewController.isEmpty() || pollFieldsHaveContent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBackPressed(){
|
||||
if(emojiKeyboard.isVisible()){
|
||||
emojiKeyboard.hide();
|
||||
return true;
|
||||
private void updateDraftState(){
|
||||
boolean hasDraft=hasDraft();
|
||||
if(hasDraft!=prevHadDraft){
|
||||
prevHadDraft=hasDraft;
|
||||
if(hasDraft){
|
||||
addBackCallback(discardConfirmationCallback);
|
||||
}else{
|
||||
removeBackCallback(discardConfirmationCallback);
|
||||
}
|
||||
}
|
||||
if(hasDraft()){
|
||||
confirmDiscardDraftAndFinish();
|
||||
return true;
|
||||
}
|
||||
if(sendingOverlay!=null)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -821,7 +870,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
private void confirmDiscardDraftAndFinish(){
|
||||
new M3AlertDialogBuilder(getActivity())
|
||||
.setTitle(editingStatus==null ? R.string.discard_draft : R.string.discard_changes)
|
||||
.setPositiveButton(R.string.discard, (dialog, which)->Nav.finish(this))
|
||||
.setPositiveButton(R.string.discard, (dialog, which)->{
|
||||
removeBackCallback(discardConfirmationCallback);
|
||||
Nav.finish(this);
|
||||
})
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
@@ -908,22 +960,20 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
}
|
||||
|
||||
private void onVisibilityClick(View v){
|
||||
PopupMenu menu=new PopupMenu(getActivity(), v);
|
||||
menu.inflate(R.menu.compose_visibility);
|
||||
menu.setOnMenuItemClickListener(item->{
|
||||
int id=item.getItemId();
|
||||
if(id==R.id.vis_public){
|
||||
statusVisibility=StatusPrivacy.PUBLIC;
|
||||
}else if(id==R.id.vis_followers){
|
||||
statusVisibility=StatusPrivacy.PRIVATE;
|
||||
}else if(id==R.id.vis_private){
|
||||
statusVisibility=StatusPrivacy.DIRECT;
|
||||
ArrayList<ListItem<StatusPrivacy>> items=new ArrayList<>();
|
||||
ExtendedPopupMenu menu=new ExtendedPopupMenu(getActivity(), items);
|
||||
Consumer<ListItem<StatusPrivacy>> onClick=i->{
|
||||
if(statusVisibility!=i.parentObject){
|
||||
statusVisibility=i.parentObject;
|
||||
updateVisibilityIcon(true);
|
||||
}
|
||||
item.setChecked(true);
|
||||
updateVisibilityIcon();
|
||||
return true;
|
||||
});
|
||||
menu.show();
|
||||
menu.dismiss();
|
||||
};
|
||||
items.add(new ListItem<>(R.string.visibility_public, R.string.visibility_subtitle_public, R.drawable.ic_public_24px, StatusPrivacy.PUBLIC, onClick));
|
||||
items.add(new ListItem<>(R.string.visibility_unlisted, R.string.visibility_subtitle_unlisted, R.drawable.ic_clear_night_24px, StatusPrivacy.UNLISTED, onClick));
|
||||
items.add(new ListItem<>(R.string.visibility_followers_only, R.string.visibility_subtitle_followers, R.drawable.ic_lock_24px, StatusPrivacy.PRIVATE, onClick));
|
||||
items.add(new ListItem<>(R.string.visibility_private, R.string.visibility_subtitle_private, R.drawable.ic_alternate_email_24px, StatusPrivacy.DIRECT, onClick));
|
||||
menu.showAsDropDown(v);
|
||||
}
|
||||
|
||||
private void loadDefaultStatusVisibility(Bundle savedInstanceState){
|
||||
@@ -949,12 +999,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
private void applyPreferencesForPostVisibility(Preferences prefs, Bundle savedInstanceState){
|
||||
// Only override the reply visibility if our preference is more private
|
||||
if(prefs.postingDefaultVisibility.isLessVisibleThan(statusVisibility)){
|
||||
// Map unlisted from the API onto public, because we don't have unlisted in the UI
|
||||
statusVisibility=switch(prefs.postingDefaultVisibility){
|
||||
case PUBLIC, UNLISTED -> StatusPrivacy.PUBLIC;
|
||||
case PRIVATE -> StatusPrivacy.PRIVATE;
|
||||
case DIRECT -> StatusPrivacy.DIRECT;
|
||||
};
|
||||
statusVisibility=prefs.postingDefaultVisibility;
|
||||
}
|
||||
|
||||
// A saved privacy setting from a previous compose session wins over all
|
||||
@@ -962,28 +1007,45 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
statusVisibility=(StatusPrivacy) savedInstanceState.getSerializable("visibility");
|
||||
}
|
||||
|
||||
updateVisibilityIcon();
|
||||
updateVisibilityIcon(false);
|
||||
}
|
||||
|
||||
private void updateVisibilityIcon(){
|
||||
private void updateVisibilityIcon(boolean animated){
|
||||
if(getActivity()==null)
|
||||
return;
|
||||
if(statusVisibility==null){ // TODO find out why this happens
|
||||
statusVisibility=StatusPrivacy.PUBLIC;
|
||||
}
|
||||
visibilityBtn.setText(switch(statusVisibility){
|
||||
case PUBLIC, UNLISTED -> R.string.visibility_public;
|
||||
TextView visibilityText;
|
||||
if(!animated){
|
||||
visibilityText=visibilityCurrentText;
|
||||
}else{
|
||||
TransitionManager.beginDelayedTransition(visibilityBtn, new TransitionSet()
|
||||
.addTransition(new Fade(Fade.IN | Fade.OUT))
|
||||
.addTransition(new ChangeBounds().excludeTarget(TextView.class, true))
|
||||
.setDuration(250)
|
||||
.setInterpolator(CubicBezierInterpolator.DEFAULT)
|
||||
);
|
||||
visibilityText=visibilityCurrentText==visibilityText1 ? visibilityText2 : visibilityText1;
|
||||
visibilityText.setVisibility(View.VISIBLE);
|
||||
visibilityCurrentText.setVisibility(View.GONE);
|
||||
visibilityCurrentText=visibilityText;
|
||||
}
|
||||
visibilityText.setText(switch(statusVisibility){
|
||||
case PUBLIC -> R.string.visibility_public;
|
||||
case UNLISTED -> R.string.visibility_unlisted;
|
||||
case PRIVATE -> R.string.visibility_followers_only;
|
||||
case DIRECT -> R.string.visibility_private;
|
||||
});
|
||||
Drawable icon=getResources().getDrawable(switch(statusVisibility){
|
||||
case PUBLIC, UNLISTED -> R.drawable.ic_public_20px;
|
||||
case PUBLIC -> R.drawable.ic_public_20px;
|
||||
case UNLISTED -> R.drawable.ic_clear_night_20px;
|
||||
case PRIVATE -> R.drawable.ic_group_20px;
|
||||
case DIRECT -> R.drawable.ic_alternate_email_20px;
|
||||
}, getActivity().getTheme()).mutate();
|
||||
icon.setBounds(0, 0, V.dp(18), V.dp(18));
|
||||
icon.setTint(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary));
|
||||
visibilityBtn.setCompoundDrawablesRelative(icon, null, visibilityBtn.getCompoundDrawablesRelative()[2], null);
|
||||
visibilityText.setCompoundDrawablesRelative(icon, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1120,12 +1182,15 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
private void showLanguageAlert(){
|
||||
Preferences prefs=AccountSessionManager.getInstance().getAccount(accountID).preferences;
|
||||
ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), prefs!=null ? prefs.postingDefaultLanguage : null, postLang, mainEditText.getText().toString());
|
||||
new M3AlertDialogBuilder(getActivity())
|
||||
final AlertDialog dlg=new M3AlertDialogBuilder(getActivity())
|
||||
.setTitle(R.string.language)
|
||||
.setView(vc.getView())
|
||||
.setPositiveButton(R.string.ok, (dialog, which)->setPostLanguage(vc.getSelectedOption()))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.cancel, null)
|
||||
.show();
|
||||
vc.setSelectionListener(opt->{
|
||||
setPostLanguage(opt);
|
||||
dlg.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
private void setPostLanguage(ComposeLanguageAlertViewController.SelectedOption language){
|
||||
|
||||
@@ -32,12 +32,11 @@ import java.util.Collections;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import me.grishka.appkit.fragments.OnBackPressedListener;
|
||||
import me.grishka.appkit.imageloader.ViewImageLoader;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.V;
|
||||
|
||||
public class ComposeImageDescriptionFragment extends MastodonToolbarFragment implements OnBackPressedListener{
|
||||
public class ComposeImageDescriptionFragment extends MastodonToolbarFragment{
|
||||
private static final String TAG="ComposeImageDescription";
|
||||
|
||||
private String accountID, attachmentID;
|
||||
@@ -138,9 +137,9 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBackPressed(){
|
||||
public void onStop(){
|
||||
super.onStop();
|
||||
deliverResult();
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -42,13 +42,11 @@ import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.fragments.OnBackPressedListener;
|
||||
import me.grishka.appkit.fragments.WindowInsetsAwareFragment;
|
||||
import me.grishka.appkit.utils.CubicBezierInterpolator;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.FragmentRootLinearLayout;
|
||||
|
||||
public class CreateListAddMembersFragment extends BaseAccountListFragment implements OnBackPressedListener, AddNewListMembersFragment.Listener{
|
||||
public class CreateListAddMembersFragment extends BaseAccountListFragment implements AddNewListMembersFragment.Listener{
|
||||
private FollowList followList;
|
||||
private Button nextButton;
|
||||
private View buttonBar;
|
||||
@@ -59,6 +57,7 @@ public class CreateListAddMembersFragment extends BaseAccountListFragment implem
|
||||
private WindowInsets lastInsets;
|
||||
private boolean dismissingSearchFragment;
|
||||
private HashSet<String> accountIDsInList=new HashSet<>();
|
||||
private Runnable searchFragmentDismisser=this::dismissSearchFragment;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
@@ -156,6 +155,7 @@ public class CreateListAddMembersFragment extends BaseAccountListFragment implem
|
||||
searchFragmentContainer.animate().translationX(0).alpha(1).setDuration(300).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{
|
||||
rootView.setVisibility(View.GONE);
|
||||
}).start();
|
||||
addBackCallback(searchFragmentDismisser);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ public class CreateListAddMembersFragment extends BaseAccountListFragment implem
|
||||
private void dismissSearchFragment(){
|
||||
if(searchFragment==null || dismissingSearchFragment)
|
||||
return;
|
||||
removeBackCallback(searchFragmentDismisser);
|
||||
dismissingSearchFragment=true;
|
||||
rootView.setVisibility(View.VISIBLE);
|
||||
searchFragmentContainer.animate().translationX(V.dp(100)).alpha(0).setDuration(200).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{
|
||||
@@ -201,15 +202,6 @@ public class CreateListAddMembersFragment extends BaseAccountListFragment implem
|
||||
Nav.finish(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBackPressed(){
|
||||
if(searchFragment!=null){
|
||||
dismissSearchFragment();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountInList(AccountViewModel account){
|
||||
return accountIDsInList.contains(account.account.id);
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.Hashtag;
|
||||
import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.Collections;
|
||||
@@ -19,6 +18,7 @@ import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.Nav;
|
||||
|
||||
public class FeaturedHashtagsListFragment extends BaseStatusListFragment<Hashtag>{
|
||||
private Account account;
|
||||
@@ -45,7 +45,11 @@ public class FeaturedHashtagsListFragment extends BaseStatusListFragment<Hashtag
|
||||
|
||||
@Override
|
||||
public void onItemClick(String id){
|
||||
UiUtils.openHashtagTimeline(getActivity(), accountID, Objects.requireNonNull(findItemOfType(id, HashtagStatusDisplayItem.class)).tag);
|
||||
Bundle args=new Bundle();
|
||||
args.putParcelable("targetAccount", Parcels.wrap(account));
|
||||
args.putParcelable("hashtag", Parcels.wrap(Objects.requireNonNull(findItemOfType(id, HashtagStatusDisplayItem.class)).tag));
|
||||
args.putString("account", accountID);
|
||||
Nav.go(getActivity(), HashtagFeaturedTimelineFragment.class, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.FilterContext;
|
||||
import org.joinmastodon.android.model.Hashtag;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
|
||||
// The difference between this and HashtagTimelineFragment is that this opens from the featured hashtags
|
||||
// and only shows posts by that account.
|
||||
public class HashtagFeaturedTimelineFragment extends StatusListFragment{
|
||||
private Account targetAccount;
|
||||
private Hashtag hashtag;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
targetAccount=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
|
||||
hashtag=Parcels.unwrap(getArguments().getParcelable("hashtag"));
|
||||
setTitle("#"+hashtag.name);
|
||||
loadData();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
currentRequest=new GetAccountStatuses(targetAccount.id, offset>0 ? getMaxID() : null, null, count, GetAccountStatuses.Filter.DEFAULT, hashtag.name)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Status> result){
|
||||
if(getActivity()==null)
|
||||
return;
|
||||
boolean empty=result.isEmpty();
|
||||
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.ACCOUNT);
|
||||
onDataLoaded(result, !empty);
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.utils.CubicBezierInterpolator;
|
||||
import me.grishka.appkit.utils.MergeRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.V;
|
||||
@@ -155,7 +154,7 @@ public class HashtagTimelineFragment extends StatusListFragment{
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getMainAdapterOffset(){
|
||||
public int getMainAdapterOffset(){
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -276,4 +275,8 @@ public class HashtagTimelineFragment extends StatusListFragment{
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
public String getHashtagName(){
|
||||
return hashtagName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestions
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.Notification;
|
||||
import org.joinmastodon.android.model.PaginatedResponse;
|
||||
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.views.TabBar;
|
||||
import org.joinmastodon.android.utils.ObjectIdComparator;
|
||||
@@ -48,13 +48,12 @@ import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.fragments.AppKitFragment;
|
||||
import me.grishka.appkit.fragments.LoaderFragment;
|
||||
import me.grishka.appkit.fragments.OnBackPressedListener;
|
||||
import me.grishka.appkit.imageloader.ViewImageLoader;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.FragmentRootLinearLayout;
|
||||
|
||||
public class HomeFragment extends AppKitFragment implements OnBackPressedListener{
|
||||
public class HomeFragment extends AppKitFragment{
|
||||
private FragmentRootLinearLayout content;
|
||||
private HomeTimelineFragment homeTimelineFragment;
|
||||
private NotificationsListFragment notificationsFragment;
|
||||
@@ -112,7 +111,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
|
||||
content.setOrientation(LinearLayout.VERTICAL);
|
||||
|
||||
FrameLayout fragmentContainer=new FrameLayout(getActivity());
|
||||
fragmentContainer.setId(R.id.fragment_wrap);
|
||||
fragmentContainer.setId(me.grishka.appkit.R.id.fragment_wrap);
|
||||
content.addView(fragmentContainer, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
|
||||
inflater.inflate(R.layout.tab_bar, content);
|
||||
@@ -131,10 +130,10 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
|
||||
|
||||
if(savedInstanceState==null){
|
||||
getChildFragmentManager().beginTransaction()
|
||||
.add(R.id.fragment_wrap, homeTimelineFragment)
|
||||
.add(R.id.fragment_wrap, searchFragment).hide(searchFragment)
|
||||
.add(R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment)
|
||||
.add(R.id.fragment_wrap, profileFragment).hide(profileFragment)
|
||||
.add(me.grishka.appkit.R.id.fragment_wrap, homeTimelineFragment)
|
||||
.add(me.grishka.appkit.R.id.fragment_wrap, searchFragment).hide(searchFragment)
|
||||
.add(me.grishka.appkit.R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment)
|
||||
.add(me.grishka.appkit.R.id.fragment_wrap, profileFragment).hide(profileFragment)
|
||||
.commit();
|
||||
|
||||
String defaultTab=getArguments().getString("tab");
|
||||
@@ -272,15 +271,6 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBackPressed(){
|
||||
if(currentTab==R.id.tab_profile)
|
||||
return profileFragment.onBackPressed();
|
||||
if(currentTab==R.id.tab_search)
|
||||
return searchFragment.onBackPressed();
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle outState){
|
||||
super.onSaveInstanceState(outState);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.ActionMode;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageButton;
|
||||
|
||||
import com.squareup.otto.Subscribe;
|
||||
@@ -19,7 +24,7 @@ import org.joinmastodon.android.api.requests.lists.GetListAccounts;
|
||||
import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
|
||||
import org.joinmastodon.android.events.AccountAddedToListEvent;
|
||||
import org.joinmastodon.android.events.AccountRemovedFromListEvent;
|
||||
import org.joinmastodon.android.fragments.account_list.AddListMembersFragment;
|
||||
import org.joinmastodon.android.fragments.account_list.AddNewListMembersFragment;
|
||||
import org.joinmastodon.android.fragments.account_list.PaginatedAccountListFragment;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.FollowList;
|
||||
@@ -33,24 +38,32 @@ import org.parceler.Parcels;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.utils.CubicBezierInterpolator;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.FragmentRootLinearLayout;
|
||||
|
||||
public class ListMembersFragment extends PaginatedAccountListFragment{
|
||||
private static final int ADD_MEMBER_RESULT=600;
|
||||
|
||||
public class ListMembersFragment extends PaginatedAccountListFragment implements AddNewListMembersFragment.Listener{
|
||||
private ImageButton fab;
|
||||
private FollowList followList;
|
||||
private boolean inSelectionMode;
|
||||
private Set<String> selectedAccounts=new HashSet<>();
|
||||
private ActionMode actionMode;
|
||||
private MenuItem deleteItem;
|
||||
private FrameLayout searchFragmentContainer;
|
||||
private FrameLayout fragmentContentWrap;
|
||||
private AddNewListMembersFragment searchFragment;
|
||||
private FragmentRootLinearLayout rootView;
|
||||
private WindowInsets lastInsets;
|
||||
private HashSet<String> accountIDsInList=new HashSet<>();
|
||||
private boolean dismissingSearchFragment;
|
||||
private Runnable searchFragmentDismisser=this::dismissSearchFragment;;
|
||||
private Runnable actionModeDismisser=()->actionMode.finish();
|
||||
|
||||
public ListMembersFragment(){
|
||||
setListLayoutId(R.layout.recycler_fragment_with_fab);
|
||||
@@ -76,6 +89,26 @@ public class ListMembersFragment extends PaginatedAccountListFragment{
|
||||
return new GetListAccounts(followList.id, maxID, count);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDataLoaded(List<AccountViewModel> d, boolean more){
|
||||
if(refreshing)
|
||||
accountIDsInList.clear();
|
||||
for(AccountViewModel a:d){
|
||||
accountIDsInList.add(a.account.id);
|
||||
}
|
||||
super.onDataLoaded(d, more);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
|
||||
View view=super.onCreateView(inflater, container, savedInstanceState);
|
||||
FrameLayout wrapper=new FrameLayout(getActivity());
|
||||
wrapper.addView(view);
|
||||
rootView=(FragmentRootLinearLayout) view;
|
||||
fragmentContentWrap=wrapper;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onConfigureViewHolder(AccountViewHolder holder){
|
||||
super.onConfigureViewHolder(holder);
|
||||
@@ -132,16 +165,19 @@ public class ListMembersFragment extends PaginatedAccountListFragment{
|
||||
|
||||
@Override
|
||||
public void onApplyWindowInsets(WindowInsets insets){
|
||||
super.onApplyWindowInsets(insets);
|
||||
lastInsets=insets;
|
||||
if(searchFragment!=null)
|
||||
searchFragment.onApplyWindowInsets(insets);
|
||||
UiUtils.applyBottomInsetToFAB(fab, insets);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFragmentResult(int reqCode, boolean success, Bundle result){
|
||||
if(reqCode==ADD_MEMBER_RESULT && success){
|
||||
Account acc=Objects.requireNonNull(Parcels.unwrap(result.getParcelable("selectedAccount")));
|
||||
addAccounts(List.of(acc));
|
||||
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
|
||||
list.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom());
|
||||
emptyView.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom());
|
||||
progress.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom());
|
||||
insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom());
|
||||
}else{
|
||||
list.setPadding(0, 0, 0, 0);
|
||||
}
|
||||
rootView.onApplyWindowInsets(insets);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
@@ -160,9 +196,26 @@ public class ListMembersFragment extends PaginatedAccountListFragment{
|
||||
}
|
||||
|
||||
private void onFabClick(){
|
||||
searchFragmentContainer=new FrameLayout(getActivity());
|
||||
searchFragmentContainer.setId(R.id.search_fragment);
|
||||
fragmentContentWrap.addView(searchFragmentContainer);
|
||||
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
Nav.goForResult(getActivity(), AddListMembersFragment.class, args, ADD_MEMBER_RESULT, this);
|
||||
args.putParcelable("list", Parcels.wrap(followList));
|
||||
args.putBoolean("_can_go_back", true);
|
||||
searchFragment=new AddNewListMembersFragment(this);
|
||||
searchFragment.setArguments(args);
|
||||
getChildFragmentManager().beginTransaction().add(R.id.search_fragment, searchFragment).commit();
|
||||
getChildFragmentManager().executePendingTransactions();
|
||||
if(lastInsets!=null)
|
||||
searchFragment.onApplyWindowInsets(lastInsets);
|
||||
searchFragmentContainer.setTranslationX(V.dp(100));
|
||||
searchFragmentContainer.setAlpha(0f);
|
||||
searchFragmentContainer.animate().translationX(0).alpha(1).setDuration(300).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{
|
||||
rootView.setVisibility(View.GONE);
|
||||
}).start();
|
||||
addBackCallback(searchFragmentDismisser);
|
||||
}
|
||||
|
||||
private void onItemClick(AccountViewHolder holder){
|
||||
@@ -198,7 +251,7 @@ public class ListMembersFragment extends PaginatedAccountListFragment{
|
||||
if(id==R.id.remove_from_list){
|
||||
new M3AlertDialogBuilder(getActivity())
|
||||
.setTitle(R.string.confirm_remove_list_member)
|
||||
.setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(Set.of(holder.getItem().account.id)))
|
||||
.setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(Set.of(holder.getItem().account.id), null))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
@@ -229,7 +282,7 @@ public class ListMembersFragment extends PaginatedAccountListFragment{
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem item){
|
||||
new M3AlertDialogBuilder(getActivity())
|
||||
.setTitle(R.string.confirm_remove_list_members)
|
||||
.setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(new HashSet<>(selectedAccounts)))
|
||||
.setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(new HashSet<>(selectedAccounts), null))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show();
|
||||
return true;
|
||||
@@ -242,22 +295,27 @@ public class ListMembersFragment extends PaginatedAccountListFragment{
|
||||
selectedAccounts.clear();
|
||||
updateItemsForSelectionModeTransition();
|
||||
V.setVisibilityAnimated(fab, View.VISIBLE);
|
||||
removeBackCallback(actionModeDismisser);
|
||||
}
|
||||
});
|
||||
updateActionModeTitle();
|
||||
addBackCallback(actionModeDismisser);
|
||||
}
|
||||
|
||||
private void updateActionModeTitle(){
|
||||
actionMode.setTitle(getResources().getQuantityString(R.plurals.x_items_selected, selectedAccounts.size(), selectedAccounts.size()));
|
||||
}
|
||||
|
||||
private void removeAccounts(Set<String> ids){
|
||||
private void removeAccounts(Set<String> ids, Runnable onDone){
|
||||
new RemoveAccountsFromList(followList.id, ids)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Void result){
|
||||
if(onDone!=null)
|
||||
onDone.run();
|
||||
if(inSelectionMode)
|
||||
actionMode.finish();
|
||||
accountIDsInList.removeAll(ids);
|
||||
removeAccountRows(ids);
|
||||
}
|
||||
|
||||
@@ -270,12 +328,15 @@ public class ListMembersFragment extends PaginatedAccountListFragment{
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
private void addAccounts(Collection<Account> accounts){
|
||||
private void addAccounts(Collection<Account> accounts, Runnable onDone){
|
||||
new AddAccountsToList(followList.id, accounts.stream().map(a->a.id).collect(Collectors.toSet()))
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Void result){
|
||||
if(onDone!=null)
|
||||
onDone.run();
|
||||
for(Account acc:accounts){
|
||||
accountIDsInList.add(acc.id);
|
||||
data.add(new AccountViewModel(acc, accountID));
|
||||
}
|
||||
list.getAdapter().notifyItemRangeInserted(data.size()-accounts.size(), accounts.size());
|
||||
@@ -298,4 +359,46 @@ public class ListMembersFragment extends PaginatedAccountListFragment{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountInList(AccountViewModel account){
|
||||
return accountIDsInList.contains(account.account.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addAccountToList(AccountViewModel account, Runnable onDone){
|
||||
addAccounts(Set.of(account.account), onDone);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAccountAccountFromList(AccountViewModel account, Runnable onDone){
|
||||
removeAccounts(Set.of(account.account.id), onDone);
|
||||
}
|
||||
|
||||
private void dismissSearchFragment(){
|
||||
if(searchFragment==null || dismissingSearchFragment)
|
||||
return;
|
||||
dismissingSearchFragment=true;
|
||||
rootView.setVisibility(View.VISIBLE);
|
||||
searchFragmentContainer.animate().translationX(V.dp(100)).alpha(0).setDuration(200).withLayer().setInterpolator(CubicBezierInterpolator.DEFAULT).withEndAction(()->{
|
||||
getChildFragmentManager().beginTransaction().remove(searchFragment).commit();
|
||||
getChildFragmentManager().executePendingTransactions();
|
||||
fragmentContentWrap.removeView(searchFragmentContainer);
|
||||
searchFragmentContainer=null;
|
||||
searchFragment=null;
|
||||
dismissingSearchFragment=false;
|
||||
}).start();
|
||||
removeBackCallback(searchFragmentDismisser);
|
||||
getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setStatusBarColor(int color){
|
||||
rootView.setStatusBarColor(color);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setNavigationBarColor(int color){
|
||||
rootView.setNavigationBarColor(color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.squareup.otto.Subscribe;
|
||||
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.notifications.GetNotificationRequests;
|
||||
import org.joinmastodon.android.api.requests.notifications.RespondToNotificationRequest;
|
||||
import org.joinmastodon.android.events.NotificationRequestRespondedEvent;
|
||||
import org.joinmastodon.android.model.HeaderPaginationList;
|
||||
import org.joinmastodon.android.model.NotificationRequest;
|
||||
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
|
||||
import org.joinmastodon.android.ui.BetterItemAnimator;
|
||||
import org.joinmastodon.android.ui.DividerItemDecoration;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.Snackbar;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
|
||||
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.BindableViewHolder;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
|
||||
public class NotificationRequestsFragment extends MastodonRecyclerFragment<NotificationRequest>{
|
||||
private String accountID;
|
||||
private String maxID;
|
||||
private HashMap<String, AccountViewModel> accountViewModels=new HashMap<>();
|
||||
private View endMark;
|
||||
private NotificationRequestsAdapter adapter;
|
||||
|
||||
public NotificationRequestsFragment(){
|
||||
super(50);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
accountID=getArguments().getString("account");
|
||||
setTitle(R.string.filtered_notifications);
|
||||
loadData();
|
||||
E.register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy(){
|
||||
E.unregister(this);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
if(!refreshing && endMark!=null)
|
||||
endMark.setVisibility(View.GONE);
|
||||
currentRequest=new GetNotificationRequests(offset==0 ? null : maxID)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(HeaderPaginationList<NotificationRequest> result){
|
||||
if(data.isEmpty() || refreshing)
|
||||
accountViewModels.clear();
|
||||
maxID=result.getNextPageMaxID();
|
||||
for(NotificationRequest req:result){
|
||||
accountViewModels.put(req.account.id, new AccountViewModel(req.account, accountID, false));
|
||||
}
|
||||
onDataLoaded(result, !TextUtils.isEmpty(maxID));
|
||||
endMark.setVisibility(TextUtils.isEmpty(maxID) ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RecyclerView.Adapter<?> getAdapter(){
|
||||
return adapter=new NotificationRequestsAdapter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
list.setItemAnimator(new BetterItemAnimator());
|
||||
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 1, 0, 0, vh->vh instanceof NotificationRequestViewHolder).setDrawBelowLastItem(true));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected View onCreateFooterView(LayoutInflater inflater){
|
||||
View v=inflater.inflate(R.layout.load_more_with_end_mark, null);
|
||||
endMark=v.findViewById(R.id.end_mark);
|
||||
endMark.setVisibility(View.GONE);
|
||||
return v;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onNotificationRequestResponded(NotificationRequestRespondedEvent ev){
|
||||
if(adapter==null || !ev.accountID.equals(accountID))
|
||||
return;
|
||||
for(int i=0;i<data.size();i++){
|
||||
if(data.get(i).id.equals(ev.requestID)){
|
||||
data.remove(i);
|
||||
adapter.notifyItemRemoved(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for(NotificationRequest nr:preloadedData){
|
||||
if(nr.id.equals(ev.requestID)){
|
||||
preloadedData.remove(nr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class NotificationRequestsAdapter extends UsableRecyclerView.Adapter<NotificationRequestViewHolder> implements ImageLoaderRecyclerAdapter{
|
||||
|
||||
public NotificationRequestsAdapter(){
|
||||
super(imgLoader);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public NotificationRequestViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||
return new NotificationRequestViewHolder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount(){
|
||||
return data.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(NotificationRequestViewHolder holder, int position){
|
||||
holder.bind(data.get(position));
|
||||
super.onBindViewHolder(holder, position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getImageCountForItem(int position){
|
||||
return Objects.requireNonNull(accountViewModels.get(data.get(position).account.id)).emojiHelper.getImageCount()+1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImageLoaderRequest getImageRequest(int position, int image){
|
||||
AccountViewModel model=Objects.requireNonNull(accountViewModels.get(data.get(position).account.id));
|
||||
return switch(image){
|
||||
case 0 -> model.avaRequest;
|
||||
default -> model.emojiHelper.getImageRequest(image-1);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private class NotificationRequestViewHolder extends BindableViewHolder<NotificationRequest> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
|
||||
private final TextView name, username, badge;
|
||||
private final ImageView ava;
|
||||
private final ImageButton allow, mute;
|
||||
|
||||
public NotificationRequestViewHolder(){
|
||||
super(getActivity(), R.layout.item_notification_request, list);
|
||||
name=findViewById(R.id.name);
|
||||
username=findViewById(R.id.username);
|
||||
badge=findViewById(R.id.badge);
|
||||
ava=findViewById(R.id.ava);
|
||||
allow=findViewById(R.id.btn_allow);
|
||||
mute=findViewById(R.id.btn_mute);
|
||||
ava.setOutlineProvider(OutlineProviders.roundedRect(8));
|
||||
ava.setClipToOutline(true);
|
||||
allow.setOnClickListener(this::onAllowClick);
|
||||
mute.setOnClickListener(this::onMuteClick);
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@Override
|
||||
public void onBind(NotificationRequest item){
|
||||
AccountViewModel model=Objects.requireNonNull(accountViewModels.get(item.account.id));
|
||||
name.setText(model.parsedName);
|
||||
username.setText(item.account.getDisplayUsername());
|
||||
badge.setText(item.notificationsCount>99 ? String.format("%d+", 99) : String.format("%d", item.notificationsCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setImage(int index, Drawable image){
|
||||
if(index==0){
|
||||
if(image==null)
|
||||
ava.setImageResource(R.drawable.image_placeholder);
|
||||
else
|
||||
ava.setImageDrawable(image);
|
||||
}else{
|
||||
AccountViewModel model=Objects.requireNonNull(accountViewModels.get(item.account.id));
|
||||
model.emojiHelper.setImageDrawable(index-1, image);
|
||||
name.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("targetAccount", Parcels.wrap(item.account));
|
||||
args.putString("requestID", item.id);
|
||||
Nav.go(getActivity(), AccountNotificationsListFragment.class, args);
|
||||
}
|
||||
|
||||
private void onAllowClick(View v){
|
||||
acceptOrDecline(true);
|
||||
}
|
||||
|
||||
private void onMuteClick(View v){
|
||||
acceptOrDecline(false);
|
||||
}
|
||||
|
||||
private void acceptOrDecline(boolean accept){
|
||||
new RespondToNotificationRequest(item.id, accept)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Void result){
|
||||
int pos=data.indexOf(item);
|
||||
data.remove(pos);
|
||||
adapter.notifyItemRemoved(pos);
|
||||
new Snackbar.Builder(getActivity())
|
||||
.setText(getString(accept ? R.string.notifications_allowed : R.string.notifications_muted, item.account.displayName))
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
error.showToast(getActivity());
|
||||
}
|
||||
})
|
||||
.wrapProgress(getActivity(), R.string.loading, false)
|
||||
.exec(accountID);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,70 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.Dialog;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
|
||||
import com.squareup.otto.Subscribe;
|
||||
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
|
||||
import org.joinmastodon.android.api.requests.notifications.GetNotificationsPolicy;
|
||||
import org.joinmastodon.android.api.requests.notifications.SetNotificationsPolicy;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.events.PollUpdatedEvent;
|
||||
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
|
||||
import org.joinmastodon.android.model.Notification;
|
||||
import org.joinmastodon.android.model.NotificationsPolicy;
|
||||
import org.joinmastodon.android.model.PaginatedResponse;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
|
||||
import org.joinmastodon.android.model.viewmodel.ListItem;
|
||||
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.displayitems.NotificationHeaderStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter;
|
||||
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.viewcontrollers.GenericListItemsViewController;
|
||||
import org.joinmastodon.android.ui.views.NestedRecyclerScrollView;
|
||||
import org.joinmastodon.android.utils.ObjectIdComparator;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.utils.MergeRecyclerAdapter;
|
||||
|
||||
public class NotificationsListFragment extends BaseStatusListFragment<Notification>{
|
||||
public class NotificationsListFragment extends BaseNotificationsListFragment{
|
||||
private boolean onlyMentions;
|
||||
private String maxID;
|
||||
private View tabBar;
|
||||
private View mentionsTab, allTab;
|
||||
private View endMark;
|
||||
private String unreadMarker, realUnreadMarker;
|
||||
private MenuItem markAllReadItem;
|
||||
private boolean reloadingFromCache;
|
||||
private ListItem<Void> requestsItem=new ListItem<>(R.string.filtered_notifications, 0, R.drawable.ic_inventory_2_24px, i->openNotificationRequests());
|
||||
private ArrayList<ListItem<Void>> requestsItems=new ArrayList<>();
|
||||
private GenericListItemsAdapter<Void> requestsRowAdapter=new GenericListItemsAdapter<>(requestsItems);
|
||||
private NotificationsPolicy lastPolicy;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
@@ -74,43 +87,12 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
|
||||
setTitle(R.string.notifications);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<StatusDisplayItem> buildDisplayItems(Notification n){
|
||||
NotificationHeaderStatusDisplayItem titleItem;
|
||||
if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){
|
||||
titleItem=null;
|
||||
}else{
|
||||
titleItem=new NotificationHeaderStatusDisplayItem(n.id, this, n, accountID);
|
||||
if(n.status!=null){
|
||||
n.status.card=null;
|
||||
n.status.spoilerText=null;
|
||||
}
|
||||
}
|
||||
if(n.status!=null){
|
||||
int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_HEADER);
|
||||
ArrayList<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, flags);
|
||||
if(titleItem!=null)
|
||||
items.add(0, titleItem);
|
||||
return items;
|
||||
}else if(titleItem!=null){
|
||||
return Collections.singletonList(titleItem);
|
||||
}else{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addAccountToKnown(Notification s){
|
||||
if(!knownAccounts.containsKey(s.account.id))
|
||||
knownAccounts.put(s.account.id, s.account);
|
||||
if(s.status!=null && !knownAccounts.containsKey(s.status.account.id))
|
||||
knownAccounts.put(s.status.account.id, s.status.account);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
if(!refreshing && !reloadingFromCache)
|
||||
endMark.setVisibility(View.GONE);
|
||||
if(offset==0)
|
||||
reloadPolicy();
|
||||
AccountSessionManager.getInstance()
|
||||
.getAccount(accountID).getCacheController()
|
||||
.getNotifications(offset>0 ? maxID : null, count, onlyMentions, refreshing && !reloadingFromCache, new SimpleCallback<>(this){
|
||||
@@ -142,30 +124,10 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
|
||||
resetUnreadBackground();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemClick(String id){
|
||||
Notification n=getNotificationByID(id);
|
||||
if(n.status!=null){
|
||||
Status status=n.status;
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("status", Parcels.wrap(status.clone()));
|
||||
if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId))
|
||||
args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId)));
|
||||
Nav.go(getActivity(), ThreadFragment.class, args);
|
||||
}else{
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("profileAccount", Parcels.wrap(n.account));
|
||||
Nav.go(getActivity(), ProfileFragment.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
tabBar=view.findViewById(R.id.tabbar);
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
list.addItemDecoration(new InsetStatusItemDecoration(this));
|
||||
|
||||
View tabBarItself=view.findViewById(R.id.tabbar_inner);
|
||||
tabBarItself.setOutlineProvider(OutlineProviders.roundedRect(20));
|
||||
@@ -215,14 +177,6 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
|
||||
return views;
|
||||
}
|
||||
|
||||
private Notification getNotificationByID(String id){
|
||||
for(Notification n:data){
|
||||
if(n.id.equals(id))
|
||||
return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onPollUpdated(PollUpdatedEvent ev){
|
||||
if(!ev.accountID.equals(accountID))
|
||||
@@ -249,25 +203,9 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
|
||||
}
|
||||
}
|
||||
|
||||
private void removeNotification(Notification n){
|
||||
data.remove(n);
|
||||
preloadedData.remove(n);
|
||||
int index=-1;
|
||||
for(int i=0;i<displayItems.size();i++){
|
||||
if(n.id.equals(displayItems.get(i).parentID)){
|
||||
index=i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(index==-1)
|
||||
return;
|
||||
int lastIndex;
|
||||
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
|
||||
if(!displayItems.get(lastIndex).parentID.equals(n.id))
|
||||
break;
|
||||
}
|
||||
displayItems.subList(index, lastIndex).clear();
|
||||
adapter.notifyItemRangeRemoved(index, lastIndex-index);
|
||||
@Override
|
||||
protected boolean needDividerForExtraItem(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder){
|
||||
return super.needDividerForExtraItem(child, bottomSibling, holder, siblingHolder) || (siblingHolder!=null && siblingHolder.getAbsoluteAdapterPosition()>=adapter.getItemCount()) || holder.getAbsoluteAdapterPosition()<requestsItems.size();
|
||||
}
|
||||
|
||||
private void onTabClick(View v){
|
||||
@@ -285,34 +223,34 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
|
||||
AccountSessionManager.get(accountID).setNotificationsMentionsOnly(onlyMentions);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected View onCreateFooterView(LayoutInflater inflater){
|
||||
View v=inflater.inflate(R.layout.load_more_with_end_mark, null);
|
||||
endMark=v.findViewById(R.id.end_mark);
|
||||
endMark.setVisibility(View.GONE);
|
||||
return v;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean needDividerForExtraItem(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder){
|
||||
return super.needDividerForExtraItem(child, bottomSibling, holder, siblingHolder) || (siblingHolder!=null && siblingHolder.getAbsoluteAdapterPosition()>=adapter.getItemCount());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
|
||||
inflater.inflate(R.menu.notifications, menu);
|
||||
markAllReadItem=menu.findItem(R.id.mark_all_read);
|
||||
MenuItem filters=menu.findItem(R.id.filters);
|
||||
filters.setVisible(lastPolicy!=null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item){
|
||||
if(item.getItemId()==R.id.mark_all_read){
|
||||
int id=item.getItemId();
|
||||
if(id==R.id.mark_all_read){
|
||||
markAsRead();
|
||||
resetUnreadBackground();
|
||||
}else if(id==R.id.filters){
|
||||
showFiltersAlert();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RecyclerView.Adapter getAdapter(){
|
||||
MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
|
||||
mergeAdapter.addAdapter(requestsRowAdapter);
|
||||
mergeAdapter.addAdapter(super.getAdapter());
|
||||
return mergeAdapter;
|
||||
}
|
||||
|
||||
private void markAsRead(){
|
||||
if(data.isEmpty())
|
||||
return;
|
||||
@@ -366,4 +304,93 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void updatePolicy(NotificationsPolicy policy){
|
||||
int count=policy.summary==null ? 0 : policy.summary.pendingRequestsCount;
|
||||
boolean isShown=!requestsItems.isEmpty();
|
||||
boolean needShow=count>0;
|
||||
if(isShown && !needShow){
|
||||
requestsItems.clear();
|
||||
requestsRowAdapter.notifyItemRemoved(0);
|
||||
}else if(!isShown && needShow){
|
||||
requestsItem.subtitle=getResources().getQuantityString(R.plurals.x_people_you_may_know, count, count);
|
||||
requestsItems.add(requestsItem);
|
||||
requestsRowAdapter.notifyItemInserted(0);
|
||||
}else if(isShown){
|
||||
requestsItem.subtitle=getResources().getQuantityString(R.plurals.x_people_you_may_know, count, count);
|
||||
requestsRowAdapter.notifyItemChanged(0);
|
||||
}
|
||||
lastPolicy=policy;
|
||||
invalidateOptionsMenu();
|
||||
}
|
||||
|
||||
private void reloadPolicy(){
|
||||
new GetNotificationsPolicy()
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(NotificationsPolicy policy){
|
||||
updatePolicy(policy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse errorResponse){
|
||||
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
private void showFiltersAlert(){
|
||||
GenericListItemsViewController<Void> controller=new GenericListItemsViewController<>(getActivity());
|
||||
Consumer<CheckableListItem<Void>> toggler=item->{
|
||||
item.toggle();
|
||||
controller.rebindItem(item);
|
||||
};
|
||||
CheckableListItem<Void> followingItem, followersItem, newAccountsItem, mentionsItem;
|
||||
List<ListItem<Void>> items=List.of(
|
||||
followingItem=new CheckableListItem<>(R.string.notification_filter_following, R.string.notification_filter_following_explanation, CheckableListItem.Style.CHECKBOX, lastPolicy.filterNotFollowing, toggler, true),
|
||||
followersItem=new CheckableListItem<>(R.string.notification_filter_followers, R.string.notification_filter_followers_explanation, CheckableListItem.Style.CHECKBOX, lastPolicy.filterNotFollowers, toggler, true),
|
||||
newAccountsItem=new CheckableListItem<>(R.string.notification_filter_new_accounts, R.string.notification_filter_new_accounts_explanation, CheckableListItem.Style.CHECKBOX, lastPolicy.filterNewAccounts, toggler, true),
|
||||
mentionsItem=new CheckableListItem<>(R.string.notification_filter_mentions, R.string.notification_filter_mentions_explanation, CheckableListItem.Style.CHECKBOX, lastPolicy.filterPrivateMentions, toggler, true)
|
||||
);
|
||||
controller.setItems(items);
|
||||
AlertDialog dlg=new M3AlertDialogBuilder(getActivity())
|
||||
.setTitle(R.string.filter_notifications)
|
||||
.setView(controller.getView())
|
||||
.setPositiveButton(R.string.save, null)
|
||||
.show();
|
||||
Button btn=dlg.getButton(Dialog.BUTTON_POSITIVE);
|
||||
btn.setOnClickListener(v->{
|
||||
UiUtils.showProgressForAlertButton(btn, true);
|
||||
NotificationsPolicy newPolicy=new NotificationsPolicy();
|
||||
newPolicy.filterNotFollowing=followingItem.checked;
|
||||
newPolicy.filterNotFollowers=followersItem.checked;
|
||||
newPolicy.filterNewAccounts=newAccountsItem.checked;
|
||||
newPolicy.filterPrivateMentions=mentionsItem.checked;
|
||||
new SetNotificationsPolicy(newPolicy)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(NotificationsPolicy policy){
|
||||
updatePolicy(policy);
|
||||
dlg.dismiss();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse errorResponse){
|
||||
Activity activity=getActivity();
|
||||
if(activity==null)
|
||||
return;
|
||||
UiUtils.showProgressForAlertButton(btn, false);
|
||||
errorResponse.showToast(activity);
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
});
|
||||
}
|
||||
|
||||
private void openNotificationRequests(){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
Nav.go(getActivity(), NotificationRequestsFragment.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public class PinnedPostsListFragment extends StatusListFragment{
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
new GetAccountStatuses(account.id, null, null, 100, GetAccountStatuses.Filter.PINNED)
|
||||
new GetAccountStatuses(account.id, null, null, 100, GetAccountStatuses.Filter.PINNED, null)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Status> result){
|
||||
|
||||
@@ -310,7 +310,7 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF
|
||||
public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){
|
||||
super.onSelectedChanged(viewHolder, actionState);
|
||||
if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){
|
||||
viewHolder.itemView.setTag(R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw()
|
||||
viewHolder.itemView.setTag(me.grishka.appkit.R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw()
|
||||
viewHolder.itemView.animate().translationZ(V.dp(1)).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.SectionHeaderStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -93,7 +92,13 @@ public class ProfileFeaturedFragment extends BaseStatusListFragment<SearchResult
|
||||
args.putParcelable("profileAccount", Parcels.wrap(res.account));
|
||||
Nav.go(getActivity(), ProfileFragment.class, args);
|
||||
}
|
||||
case HASHTAG -> UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag);
|
||||
case HASHTAG -> {
|
||||
Bundle args=new Bundle();
|
||||
args.putParcelable("targetAccount", Parcels.wrap(profileAccount));
|
||||
args.putParcelable("hashtag", Parcels.wrap(res.hashtag));
|
||||
args.putString("account", accountID);
|
||||
Nav.go(getActivity(), HashtagFeaturedTimelineFragment.class, args);
|
||||
}
|
||||
case STATUS -> {
|
||||
Status status=res.status.getContentStatus();
|
||||
Bundle args=new Bundle();
|
||||
@@ -109,7 +114,7 @@ public class ProfileFeaturedFragment extends BaseStatusListFragment<SearchResult
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
if(!statusesLoaded){
|
||||
new GetAccountStatuses(profileAccount.id, null, null, 2, GetAccountStatuses.Filter.PINNED)
|
||||
new GetAccountStatuses(profileAccount.id, null, null, 2, GetAccountStatuses.Filter.PINNED, null)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Status> result){
|
||||
@@ -193,6 +198,7 @@ public class ProfileFeaturedFragment extends BaseStatusListFragment<SearchResult
|
||||
private void showAllFeaturedHashtags(){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("profileAccount", Parcels.wrap(profileAccount));
|
||||
ArrayList<Parcelable> tags=featuredTags.stream().map(Parcels::wrap).collect(Collectors.toCollection(ArrayList::new));
|
||||
args.putParcelableArrayList("hashtags", tags);
|
||||
Nav.go(getActivity(), FeaturedHashtagsListFragment.class, args);
|
||||
|
||||
@@ -18,11 +18,14 @@ import android.graphics.drawable.LayerDrawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.text.style.ImageSpan;
|
||||
import android.transition.ChangeBounds;
|
||||
import android.transition.Fade;
|
||||
import android.transition.Transition;
|
||||
import android.transition.TransitionManager;
|
||||
import android.transition.TransitionSet;
|
||||
import android.view.LayoutInflater;
|
||||
@@ -36,6 +39,7 @@ import android.view.ViewTreeObserver;
|
||||
import android.view.WindowInsets;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ProgressBar;
|
||||
@@ -61,6 +65,7 @@ import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.SimpleViewHolder;
|
||||
import org.joinmastodon.android.ui.SingleImagePhotoViewerListener;
|
||||
import org.joinmastodon.android.ui.Snackbar;
|
||||
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
|
||||
import org.joinmastodon.android.ui.sheets.DecentralizationExplainerSheet;
|
||||
import org.joinmastodon.android.ui.tabs.TabLayout;
|
||||
@@ -95,14 +100,13 @@ import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.fragments.BaseRecyclerFragment;
|
||||
import me.grishka.appkit.fragments.LoaderFragment;
|
||||
import me.grishka.appkit.fragments.OnBackPressedListener;
|
||||
import me.grishka.appkit.imageloader.ViewImageLoader;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.CubicBezierInterpolator;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.FragmentRootLinearLayout;
|
||||
|
||||
public class ProfileFragment extends LoaderFragment implements OnBackPressedListener, ScrollableToTop{
|
||||
public class ProfileFragment extends LoaderFragment implements ScrollableToTop{
|
||||
private static final int AVATAR_RESULT=722;
|
||||
private static final int COVER_RESULT=343;
|
||||
|
||||
@@ -129,6 +133,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
private View tabsDivider;
|
||||
private View actionButtonWrap;
|
||||
private CustomDrawingOrderLinearLayout scrollableContent;
|
||||
private ImageButton qrCodeButton;
|
||||
private ProgressBar innerProgress;
|
||||
private View actions;
|
||||
|
||||
private Account account;
|
||||
private String accountID;
|
||||
@@ -150,6 +157,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
private Animator tabBarColorAnim;
|
||||
private MenuItem editSaveMenuItem;
|
||||
private boolean savingEdits;
|
||||
private Runnable editModeBackCallback=this::onEditModeBackCallback;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
@@ -211,6 +219,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
tabsDivider=content.findViewById(R.id.tabs_divider);
|
||||
actionButtonWrap=content.findViewById(R.id.profile_action_btn_wrap);
|
||||
scrollableContent=content.findViewById(R.id.scrollable_content);
|
||||
qrCodeButton=content.findViewById(R.id.qr_code);
|
||||
innerProgress=content.findViewById(R.id.profile_progress);
|
||||
actions=content.findViewById(R.id.profile_actions);
|
||||
|
||||
avatar.setOutlineProvider(OutlineProviders.roundedRect(24));
|
||||
avatar.setClipToOutline(true);
|
||||
@@ -298,6 +309,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
followingBtn.setOnClickListener(this::onFollowersOrFollowingClick);
|
||||
|
||||
username.setOnLongClickListener(v->{
|
||||
if(account==null)
|
||||
return true;
|
||||
String username=account.acct;
|
||||
if(!username.contains("@")){
|
||||
username+="@"+AccountSessionManager.getInstance().getAccount(accountID).domain;
|
||||
@@ -323,7 +336,19 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
nameEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true));
|
||||
bioEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true));
|
||||
|
||||
usernameDomain.setOnClickListener(v->new DecentralizationExplainerSheet(getActivity(), accountID, account).show());
|
||||
usernameDomain.setOnClickListener(v->{
|
||||
if(account==null)
|
||||
return;
|
||||
new DecentralizationExplainerSheet(getActivity(), accountID, account).show();
|
||||
});
|
||||
qrCodeButton.setOnClickListener(v->{
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("targetAccount", Parcels.wrap(account));
|
||||
ProfileQrCodeFragment qf=new ProfileQrCodeFragment();
|
||||
qf.setArguments(args);
|
||||
qf.show(getChildFragmentManager(), "qrDialog");
|
||||
});
|
||||
|
||||
return sizeWrapper;
|
||||
}
|
||||
@@ -446,6 +471,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if(!loaded)
|
||||
bindHeaderViewForPreviewMaybe();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -490,7 +517,41 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
}
|
||||
}
|
||||
|
||||
private void bindHeaderViewForPreviewMaybe(){
|
||||
if(loaded)
|
||||
return;
|
||||
String username=getArguments().getString("accountUsername");
|
||||
String domain=getArguments().getString("accountDomain");
|
||||
if(TextUtils.isEmpty(username) || TextUtils.isEmpty(domain))
|
||||
return;
|
||||
content.setVisibility(View.VISIBLE);
|
||||
progress.setVisibility(View.GONE);
|
||||
errorView.setVisibility(View.GONE);
|
||||
innerProgress.setVisibility(View.VISIBLE);
|
||||
this.username.setText(username);
|
||||
name.setText(username);
|
||||
usernameDomain.setText(domain);
|
||||
avatar.setImageResource(R.drawable.image_placeholder);
|
||||
cover.setImageResource(R.drawable.image_placeholder);
|
||||
actions.setVisibility(View.GONE);
|
||||
bio.setVisibility(View.GONE);
|
||||
countersLayout.setVisibility(View.GONE);
|
||||
tabsDivider.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void bindHeaderView(){
|
||||
if(innerProgress.getVisibility()==View.VISIBLE){
|
||||
TransitionManager.beginDelayedTransition(contentView, new TransitionSet()
|
||||
.addTransition(new Fade(Fade.IN | Fade.OUT))
|
||||
.excludeChildren(actions, true)
|
||||
.setDuration(250)
|
||||
.setInterpolator(CubicBezierInterpolator.DEFAULT)
|
||||
);
|
||||
innerProgress.setVisibility(View.GONE);
|
||||
countersLayout.setVisibility(View.VISIBLE);
|
||||
actions.setVisibility(View.VISIBLE);
|
||||
tabsDivider.setVisibility(View.VISIBLE);
|
||||
}
|
||||
setTitle(account.displayName);
|
||||
setSubtitle(getResources().getQuantityString(R.plurals.x_posts, (int)(account.statusesCount%1000), account.statusesCount));
|
||||
ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(100), V.dp(100)));
|
||||
@@ -583,6 +644,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
}
|
||||
}
|
||||
|
||||
private CharSequence makeRedString(CharSequence s){
|
||||
int color=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Error);
|
||||
SpannableString ss=new SpannableString(s);
|
||||
ss.setSpan(new ForegroundColorSpan(color), 0, ss.length(), 0);
|
||||
return ss;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
|
||||
if(isOwnProfile && isInEditMode){
|
||||
@@ -599,28 +667,36 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
if(isOwnProfile)
|
||||
return;
|
||||
|
||||
menu.findItem(R.id.mute).setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.displayName));
|
||||
menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.displayName));
|
||||
menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.displayName));
|
||||
menu.findItem(R.id.mute).setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername()));
|
||||
menu.findItem(R.id.block).setTitle(makeRedString(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername())));
|
||||
menu.findItem(R.id.report).setTitle(makeRedString(getString(R.string.report_user, account.getDisplayUsername())));
|
||||
if(relationship.following)
|
||||
menu.findItem(R.id.hide_boosts).setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.displayName));
|
||||
menu.findItem(R.id.hide_boosts).setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user));
|
||||
else
|
||||
menu.findItem(R.id.hide_boosts).setVisible(false);
|
||||
if(!account.isLocal())
|
||||
menu.findItem(R.id.block_domain).setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
|
||||
menu.findItem(R.id.block_domain).setTitle(makeRedString(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain())));
|
||||
else
|
||||
menu.findItem(R.id.block_domain).setVisible(false);
|
||||
menu.findItem(R.id.add_to_list).setVisible(relationship.following);
|
||||
|
||||
if(relationship.following){
|
||||
MenuItem notifications=menu.findItem(R.id.notifications);
|
||||
notifications.setVisible(true);
|
||||
notifications.setIcon(relationship.notifying ? R.drawable.ic_notifications_fill1_24px : R.drawable.ic_notifications_24px);
|
||||
notifications.setTitle(getString(relationship.notifying ? R.string.disable_new_post_notifications : R.string.enable_new_post_notifications, account.getDisplayUsername()));
|
||||
}
|
||||
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI() && !UiUtils.isMagic()){
|
||||
menu.setGroupDividerEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item){
|
||||
int id=item.getItemId();
|
||||
if(id==R.id.share){
|
||||
Intent intent=new Intent(Intent.ACTION_SEND);
|
||||
intent.setType("text/plain");
|
||||
intent.putExtra(Intent.EXTRA_TEXT, account.url);
|
||||
startActivity(Intent.createChooser(intent, item.getTitle()));
|
||||
UiUtils.openSystemShareSheet(getActivity(), account);
|
||||
}else if(id==R.id.mute){
|
||||
confirmToggleMuted();
|
||||
}else if(id==R.id.block){
|
||||
@@ -639,7 +715,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
updateRelationship();
|
||||
}, this::updateRelationship);
|
||||
}else if(id==R.id.hide_boosts){
|
||||
new SetAccountFollowed(account.id, true, !relationship.showingReblogs)
|
||||
new SetAccountFollowed(account.id, true, !relationship.showingReblogs, relationship.notifying)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Relationship result){
|
||||
@@ -669,6 +745,24 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("targetAccount", Parcels.wrap(account));
|
||||
Nav.go(getActivity(), AddAccountToListsFragment.class, args);
|
||||
}else if(id==R.id.notifications){
|
||||
new SetAccountFollowed(account.id, true, relationship.showingReblogs, !relationship.notifying)
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(Relationship result){
|
||||
updateRelationship(result);
|
||||
new Snackbar.Builder(getActivity())
|
||||
.setText(result.notifying ? R.string.new_post_notifications_enabled : R.string.new_post_notifications_disabled)
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
error.showToast(getActivity());
|
||||
}
|
||||
})
|
||||
.wrapProgress(getActivity(), R.string.loading, false)
|
||||
.exec(accountID);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -836,17 +930,48 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
toolbar.setNavigationContentDescription(R.string.discard);
|
||||
|
||||
ViewGroup parent=contentView.findViewById(R.id.scrollable_content);
|
||||
Runnable updater=new Runnable(){
|
||||
@Override
|
||||
public void run(){
|
||||
// setPadding() calls nullLayouts() internally, forcing the text layout to update
|
||||
actionButton.setPadding(actionButton.getPaddingLeft(), 1, actionButton.getPaddingRight(), 0);
|
||||
actionButton.setPadding(actionButton.getPaddingLeft(), 0, actionButton.getPaddingRight(), 0);
|
||||
actionButton.measure(actionButton.getWidth()|View.MeasureSpec.EXACTLY, actionButton.getHeight()|View.MeasureSpec.EXACTLY);
|
||||
actionButton.postOnAnimation(this);
|
||||
}
|
||||
};
|
||||
actionButton.postOnAnimation(updater);
|
||||
TransitionManager.beginDelayedTransition(parent, new TransitionSet()
|
||||
.addTransition(new Fade(Fade.IN | Fade.OUT))
|
||||
.addTransition(new ChangeBounds())
|
||||
.setDuration(250)
|
||||
.setInterpolator(CubicBezierInterpolator.DEFAULT)
|
||||
.addListener(new Transition.TransitionListener(){
|
||||
@Override
|
||||
public void onTransitionStart(Transition transition){}
|
||||
|
||||
@Override
|
||||
public void onTransitionEnd(Transition transition){
|
||||
actionButton.removeCallbacks(updater);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTransitionCancel(Transition transition){}
|
||||
|
||||
@Override
|
||||
public void onTransitionPause(Transition transition){}
|
||||
|
||||
@Override
|
||||
public void onTransitionResume(Transition transition){}
|
||||
})
|
||||
);
|
||||
|
||||
name.setVisibility(View.GONE);
|
||||
username.setVisibility(View.GONE);
|
||||
name.setVisibility(View.INVISIBLE);
|
||||
username.setVisibility(View.INVISIBLE);
|
||||
bio.setVisibility(View.GONE);
|
||||
countersLayout.setVisibility(View.GONE);
|
||||
qrCodeButton.setVisibility(View.GONE);
|
||||
usernameDomain.setVisibility(View.INVISIBLE);
|
||||
|
||||
nameEditWrap.setVisibility(View.VISIBLE);
|
||||
nameEdit.setText(account.displayName);
|
||||
@@ -858,12 +983,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
refreshLayout.setEnabled(false);
|
||||
editDirty=false;
|
||||
V.setVisibilityAnimated(fab, View.GONE);
|
||||
addBackCallback(editModeBackCallback);
|
||||
}
|
||||
|
||||
private void exitEditMode(){
|
||||
if(!isInEditMode)
|
||||
throw new IllegalStateException();
|
||||
isInEditMode=false;
|
||||
removeBackCallback(editModeBackCallback);
|
||||
|
||||
invalidateOptionsMenu();
|
||||
actionButton.setText(R.string.edit_profile);
|
||||
@@ -875,7 +1002,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
|
||||
Toolbar toolbar=getToolbar();
|
||||
if(canGoBack()){
|
||||
Drawable back=getToolbarContext().getDrawable(R.drawable.ic_arrow_back).mutate();
|
||||
Drawable back=getToolbarContext().getDrawable(me.grishka.appkit.R.drawable.ic_arrow_back).mutate();
|
||||
back.setTint(UiUtils.getThemeColor(getToolbarContext(), R.attr.colorM3OnSurfaceVariant));
|
||||
toolbar.setNavigationIcon(back);
|
||||
toolbar.setNavigationContentDescription(0);
|
||||
@@ -885,11 +1012,40 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
editSaveMenuItem=null;
|
||||
|
||||
ViewGroup parent=contentView.findViewById(R.id.scrollable_content);
|
||||
Runnable updater=new Runnable(){
|
||||
@Override
|
||||
public void run(){
|
||||
// setPadding() calls nullLayouts() internally, forcing the text layout to update
|
||||
actionButton.setPadding(actionButton.getPaddingLeft(), 1, actionButton.getPaddingRight(), 0);
|
||||
actionButton.setPadding(actionButton.getPaddingLeft(), 0, actionButton.getPaddingRight(), 0);
|
||||
actionButton.measure(actionButton.getWidth()|View.MeasureSpec.EXACTLY, actionButton.getHeight()|View.MeasureSpec.EXACTLY);
|
||||
actionButton.postOnAnimation(this);
|
||||
}
|
||||
};
|
||||
actionButton.postOnAnimation(updater);
|
||||
TransitionManager.beginDelayedTransition(parent, new TransitionSet()
|
||||
.addTransition(new Fade(Fade.IN | Fade.OUT))
|
||||
.addTransition(new ChangeBounds())
|
||||
.setDuration(250)
|
||||
.setInterpolator(CubicBezierInterpolator.DEFAULT)
|
||||
.addListener(new Transition.TransitionListener(){
|
||||
@Override
|
||||
public void onTransitionStart(Transition transition){}
|
||||
|
||||
@Override
|
||||
public void onTransitionEnd(Transition transition){
|
||||
actionButton.removeCallbacks(updater);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTransitionCancel(Transition transition){}
|
||||
|
||||
@Override
|
||||
public void onTransitionPause(Transition transition){}
|
||||
|
||||
@Override
|
||||
public void onTransitionResume(Transition transition){}
|
||||
})
|
||||
);
|
||||
nameEditWrap.setVisibility(View.GONE);
|
||||
bioEditWrap.setVisibility(View.GONE);
|
||||
@@ -898,6 +1054,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
bio.setVisibility(View.VISIBLE);
|
||||
countersLayout.setVisibility(View.VISIBLE);
|
||||
refreshLayout.setEnabled(true);
|
||||
usernameDomain.setVisibility(View.VISIBLE);
|
||||
qrCodeButton.setVisibility(View.VISIBLE);
|
||||
|
||||
bindHeaderView();
|
||||
V.setVisibilityAnimated(fab, View.VISIBLE);
|
||||
@@ -942,23 +1100,18 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
updateRelationship();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBackPressed(){
|
||||
if(isInEditMode){
|
||||
if(savingEdits)
|
||||
return true;
|
||||
if(editDirty || aboutFragment.isEditDirty()){
|
||||
new M3AlertDialogBuilder(getActivity())
|
||||
.setTitle(R.string.discard_changes)
|
||||
.setPositiveButton(R.string.discard, (dlg, btn)->exitEditMode())
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show();
|
||||
}else{
|
||||
exitEditMode();
|
||||
}
|
||||
return true;
|
||||
private void onEditModeBackCallback(){
|
||||
if(savingEdits)
|
||||
return;
|
||||
if(editDirty || aboutFragment.isEditDirty()){
|
||||
new M3AlertDialogBuilder(getActivity())
|
||||
.setTitle(R.string.discard_changes)
|
||||
.setPositiveButton(R.string.discard, (dlg, btn)->exitEditMode())
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show();
|
||||
}else{
|
||||
exitEditMode();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Attachment> createFakeAttachments(String url, Drawable drawable){
|
||||
@@ -972,6 +1125,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
}
|
||||
|
||||
private void onAvatarClick(View v){
|
||||
if(account==null)
|
||||
return;
|
||||
if(isInEditMode){
|
||||
startImagePicker(AVATAR_RESULT);
|
||||
}else{
|
||||
@@ -985,11 +1140,13 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
}
|
||||
|
||||
private void onCoverClick(View v){
|
||||
if(account==null)
|
||||
return;
|
||||
if(isInEditMode){
|
||||
startImagePicker(COVER_RESULT);
|
||||
}else{
|
||||
Drawable drawable=cover.getDrawable();
|
||||
if(drawable==null || drawable instanceof ColorDrawable)
|
||||
if(drawable==null || drawable instanceof ColorDrawable || account.headerStatic.endsWith("/missing.png"))
|
||||
return;
|
||||
currentPhotoViewer=new PhotoViewer(getActivity(), createFakeAttachments(account.header, drawable), 0,
|
||||
null, accountID, new SingleImagePhotoViewerListener(cover, cover, null, this, ()->currentPhotoViewer=null, ()->drawable, ()->avatarBorder.setTranslationZ(2), ()->avatarBorder.setTranslationZ(0)));
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
package org.joinmastodon.android.fragments;
|
||||
|
||||
import android.Manifest;
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.AnimatorSet;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.app.Dialog;
|
||||
import android.app.DownloadManager;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.ColorFilter;
|
||||
import android.graphics.DashPathEffect;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.SystemClock;
|
||||
import android.provider.MediaStore;
|
||||
import android.provider.Settings;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ContextThemeWrapper;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.VelocityTracker;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import android.window.OnBackInvokedCallback;
|
||||
import android.window.OnBackInvokedDispatcher;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.WriterException;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
|
||||
|
||||
import org.joinmastodon.android.MainActivity;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.MastodonAPIController;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.googleservices.GmsClient;
|
||||
import org.joinmastodon.android.googleservices.barcodescanner.Barcode;
|
||||
import org.joinmastodon.android.googleservices.barcodescanner.BarcodeScanner;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.ui.Snackbar;
|
||||
import org.joinmastodon.android.ui.drawables.FancyQrCodeDrawable;
|
||||
import org.joinmastodon.android.ui.drawables.RadialParticleSystemDrawable;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.views.FixedAspectRatioFrameLayout;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.dynamicanimation.animation.DynamicAnimation;
|
||||
import androidx.dynamicanimation.animation.SpringAnimation;
|
||||
import androidx.dynamicanimation.animation.SpringForce;
|
||||
import me.grishka.appkit.fragments.AppKitFragment;
|
||||
import me.grishka.appkit.imageloader.ViewImageLoader;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.CubicBezierInterpolator;
|
||||
import me.grishka.appkit.utils.CustomViewHelper;
|
||||
import me.grishka.appkit.utils.V;
|
||||
|
||||
public class ProfileQrCodeFragment extends AppKitFragment{
|
||||
private static final String TAG="ProfileQrCodeFragment";
|
||||
private static final int PERMISSION_RESULT=388;
|
||||
private static final int SCAN_RESULT=439;
|
||||
|
||||
private Context themeWrapper;
|
||||
private GradientDrawable scrim=new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, new int[]{0xE6000000, 0xD9000000});
|
||||
private RadialParticleSystemDrawable particles;
|
||||
private View codeContainer;
|
||||
private View particleAnimContainer;
|
||||
private Animator currentTransition;
|
||||
private View saveBtn;
|
||||
private TextView saveBtnText;
|
||||
private View content;
|
||||
|
||||
private String accountID;
|
||||
private Account account;
|
||||
private String accountDomain;
|
||||
private Intent scannerIntent;
|
||||
private boolean dismissing;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
setStyle(STYLE_NO_FRAME, 0);
|
||||
setHasOptionsMenu(true);
|
||||
accountID=getArguments().getString("account");
|
||||
account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
|
||||
setCancelable(false);
|
||||
scannerIntent=BarcodeScanner.createIntent(Barcode.FORMAT_QR_CODE, false, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart(){
|
||||
super.onStart();
|
||||
Dialog dlg=getDialog();
|
||||
dlg.getWindow().setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT);
|
||||
dlg.getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR);
|
||||
dlg.getWindow().setNavigationBarColor(0);
|
||||
dlg.getWindow().setStatusBarColor(0);
|
||||
WindowManager.LayoutParams lp=dlg.getWindow().getAttributes();
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P){
|
||||
lp.layoutInDisplayCutoutMode=WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
|
||||
}
|
||||
dlg.getWindow().setAttributes(lp);
|
||||
if(!isTablet){
|
||||
getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
|
||||
}
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){
|
||||
dlg.getOnBackInvokedDispatcher().registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, this::dismiss);
|
||||
}else{
|
||||
dlg.setOnKeyListener((dialog, keyCode, event)->{
|
||||
if(keyCode==KeyEvent.KEYCODE_BACK && event.getAction()==KeyEvent.ACTION_DOWN){
|
||||
dismiss();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismiss(DialogInterface dialog){
|
||||
super.onDismiss(dialog);
|
||||
Activity activity=getActivity();
|
||||
if(activity!=null)
|
||||
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Activity activity){
|
||||
super.onAttach(activity);
|
||||
themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark);
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
|
||||
content=View.inflate(themeWrapper, R.layout.fragment_profile_qr, container);
|
||||
View decor=getDialog().getWindow().getDecorView();
|
||||
decor.setOnApplyWindowInsetsListener((v, insets)->{
|
||||
content.setPadding(insets.getStableInsetLeft(), insets.getStableInsetTop(), insets.getStableInsetRight(), insets.getStableInsetBottom());
|
||||
return insets.consumeStableInsets();
|
||||
});
|
||||
int flags=decor.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
|
||||
flags&=~(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
|
||||
decor.setSystemUiVisibility(flags);
|
||||
content.setBackground(scrim);
|
||||
|
||||
String url=account.url;
|
||||
QRCodeWriter writer=new QRCodeWriter();
|
||||
BitMatrix code;
|
||||
try{
|
||||
code=writer.encode(url, BarcodeFormat.QR_CODE, 0, 0, Map.of(EncodeHintType.MARGIN, 0, EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H));
|
||||
}catch(WriterException e){
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
View codeView=content.findViewById(R.id.code);
|
||||
ImageView avatar=content.findViewById(R.id.avatar);
|
||||
TextView username=content.findViewById(R.id.username);
|
||||
TextView domain=content.findViewById(R.id.domain);
|
||||
View share=content.findViewById(R.id.share_btn);
|
||||
saveBtn=content.findViewById(R.id.save_btn);
|
||||
saveBtnText=content.findViewById(R.id.save_text);
|
||||
View cornerAnimContainer=content.findViewById(R.id.corner_animation_container);
|
||||
particleAnimContainer=content.findViewById(R.id.particle_animation_container);
|
||||
codeContainer=content.findViewById(R.id.code_container);
|
||||
|
||||
if(!TextUtils.isEmpty(account.avatar)){
|
||||
ViewImageLoader.loadWithoutAnimation(avatar, getResources().getDrawable(R.drawable.image_placeholder, getActivity().getTheme()), new UrlImageLoaderRequest(Bitmap.Config.ARGB_8888, V.dp(24), V.dp(24), List.of(), Uri.parse(account.avatarStatic)));
|
||||
}
|
||||
username.setText(account.username);
|
||||
String accDomain=account.getDomain();
|
||||
domain.setText(accountDomain=TextUtils.isEmpty(accDomain) ? AccountSessionManager.get(accountID).domain : accDomain);
|
||||
Drawable logo=getResources().getDrawable(R.drawable.ic_ntf_logo, themeWrapper.getTheme()).mutate();
|
||||
logo.setTint(UiUtils.getThemeColor(themeWrapper, R.attr.colorM3OnPrimary));
|
||||
codeView.setBackground(new FancyQrCodeDrawable(code, UiUtils.getThemeColor(themeWrapper, R.attr.colorM3OnPrimary), logo));
|
||||
|
||||
share.setOnClickListener(v->{
|
||||
UiUtils.openSystemShareSheet(getActivity(), account);
|
||||
});
|
||||
saveBtn.setOnClickListener(v->saveCodeAsFile());
|
||||
|
||||
cornerAnimContainer.setBackground(new AnimatedCornersDrawable(themeWrapper));
|
||||
int particleColor=UiUtils.getThemeColor(themeWrapper, R.attr.colorM3Primary);
|
||||
particles=new RadialParticleSystemDrawable(5000, 200, (particleColor & 0xFFFFFF) | 0x80000000, particleColor & 0xFFFFFF, V.dp(65), V.dp(50), getResources().getDisplayMetrics().density);
|
||||
particleAnimContainer.setBackground(particles);
|
||||
content.setOnTouchListener(new TouchDismissListener());
|
||||
|
||||
int buttonExtraWidth=saveBtn.getPaddingLeft()+saveBtn.getPaddingRight()+saveBtnText.getCompoundDrawablesRelative()[0].getIntrinsicWidth()+saveBtnText.getCompoundDrawablePadding();
|
||||
saveBtn.getLayoutParams().width=(int)Math.max(saveBtnText.getPaint().measureText(getString(R.string.save)), saveBtnText.getPaint().measureText(getString(R.string.saved)))+buttonExtraWidth;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
if(savedInstanceState==null){
|
||||
AnimatorSet set=new AnimatorSet();
|
||||
set.playTogether(
|
||||
ObjectAnimator.ofInt(scrim, "alpha", 0, 255),
|
||||
ObjectAnimator.ofFloat(particleAnimContainer, View.TRANSLATION_Y, V.dp(50), 0),
|
||||
ObjectAnimator.ofFloat(particleAnimContainer, View.ALPHA, 0, 1),
|
||||
ObjectAnimator.ofFloat(getToolbar(), View.ALPHA, 0, 1)
|
||||
);
|
||||
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||
set.setDuration(350);
|
||||
set.addListener(new AnimatorListenerAdapter(){
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation){
|
||||
currentTransition=null;
|
||||
}
|
||||
});
|
||||
currentTransition=set;
|
||||
set.start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dismiss(){
|
||||
dismissWithAnimation(super::dismiss, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
|
||||
if(GmsClient.isGooglePlayServicesAvailable(getActivity())){
|
||||
MenuItem item=menu.add(0, 0, 0, R.string.scan_qr_code);
|
||||
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
item.setIcon(R.drawable.ic_qr_code_scanner_24px);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item){
|
||||
if(scannerIntent.resolveActivity(getActivity().getPackageManager())!=null){
|
||||
startActivityForResult(scannerIntent, SCAN_RESULT);
|
||||
}else{
|
||||
BarcodeScanner.installScannerModule(themeWrapper, ()->startActivityForResult(scannerIntent, SCAN_RESULT));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean canGoBack(){
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onToolbarNavigationClick(){
|
||||
dismiss();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wantsCustomNavigationIcon(){
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getNavigationIconDrawableResource(){
|
||||
return R.drawable.ic_baseline_close_24;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected LayoutInflater getToolbarLayoutInflater(){
|
||||
return LayoutInflater.from(themeWrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getToolbarResource(){
|
||||
return R.layout.profile_qr_toolbar;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults){
|
||||
if(requestCode==PERMISSION_RESULT){
|
||||
if(grantResults[0]==PackageManager.PERMISSION_GRANTED){
|
||||
doSaveCodeAsFile();
|
||||
}else if(!getActivity().shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)){
|
||||
new M3AlertDialogBuilder(getActivity())
|
||||
.setTitle(R.string.permission_required)
|
||||
.setMessage(R.string.storage_permission_to_download)
|
||||
.setPositiveButton(R.string.open_settings, (dialog, which)->getActivity().startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", getActivity().getPackageName(), null))))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data){
|
||||
if(requestCode==SCAN_RESULT && resultCode==Activity.RESULT_OK && BarcodeScanner.isValidResult(data)){
|
||||
Barcode code=BarcodeScanner.getResult(data);
|
||||
if(code!=null){
|
||||
if(code.rawValue.startsWith("https:") || code.rawValue.startsWith("http:")){
|
||||
((MainActivity)getActivity()).handleURL(Uri.parse(code.rawValue), accountID);
|
||||
dismiss();
|
||||
}else{
|
||||
Toast.makeText(themeWrapper, R.string.link_not_supported, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void dismissWithAnimation(Runnable onDone, boolean animateTranslationDown){
|
||||
if(dismissing)
|
||||
return;
|
||||
dismissing=true;
|
||||
content.setOnTouchListener(null);
|
||||
if(currentTransition!=null)
|
||||
currentTransition.cancel();
|
||||
AnimatorSet set=new AnimatorSet();
|
||||
set.playTogether(
|
||||
ObjectAnimator.ofInt(scrim, "alpha", 0),
|
||||
ObjectAnimator.ofFloat(particleAnimContainer, View.TRANSLATION_Y, particleAnimContainer.getTranslationY()+V.dp(animateTranslationDown ? 50 : -50)),
|
||||
ObjectAnimator.ofFloat(particleAnimContainer, View.ALPHA, 0),
|
||||
ObjectAnimator.ofFloat(getToolbar(), View.ALPHA, 0)
|
||||
);
|
||||
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||
set.setDuration(200);
|
||||
set.addListener(new AnimatorListenerAdapter(){
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation){
|
||||
onDone.run();
|
||||
}
|
||||
});
|
||||
currentTransition=set;
|
||||
set.start();
|
||||
}
|
||||
|
||||
private void saveCodeAsFile(){
|
||||
if(Build.VERSION.SDK_INT>=29){
|
||||
doSaveCodeAsFile();
|
||||
}else{
|
||||
if(getActivity().checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)!=PackageManager.PERMISSION_GRANTED){
|
||||
requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PERMISSION_RESULT);
|
||||
}else{
|
||||
doSaveCodeAsFile();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void doSaveCodeAsFile(){
|
||||
Bitmap bmp=Bitmap.createBitmap(1080, 1080, Bitmap.Config.ARGB_8888);
|
||||
Canvas c=new Canvas(bmp);
|
||||
float factor=1080f/codeContainer.getWidth();
|
||||
c.scale(factor, factor);
|
||||
codeContainer.draw(c);
|
||||
Activity activity=getActivity();
|
||||
MastodonAPIController.runInBackground(()->{
|
||||
String fileName=account.username+"_"+accountDomain+".png";
|
||||
try(OutputStream os=destinationStreamForFile(fileName)){
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, os);
|
||||
activity.runOnUiThread(()->{
|
||||
saveBtn.setEnabled(false);
|
||||
saveBtnText.setText(R.string.saved);
|
||||
saveBtnText.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_check_20px, 0, 0, 0);
|
||||
new Snackbar.Builder(activity)
|
||||
.setText(R.string.image_saved)
|
||||
.setAction(R.string.view_file, ()->startActivity(new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)))
|
||||
.show();
|
||||
});
|
||||
if(Build.VERSION.SDK_INT<29){
|
||||
File dstFile=new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName);
|
||||
MediaScannerConnection.scanFile(activity, new String[]{dstFile.getAbsolutePath()}, new String[]{"image/png"}, null);
|
||||
}
|
||||
}catch(IOException x){
|
||||
activity.runOnUiThread(()->{
|
||||
new Snackbar.Builder(activity)
|
||||
.setText(R.string.error_saving_file)
|
||||
.show();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private OutputStream destinationStreamForFile(String fileName) throws IOException{
|
||||
if(Build.VERSION.SDK_INT>=29){
|
||||
ContentValues values=new ContentValues();
|
||||
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
|
||||
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
|
||||
values.put(MediaStore.MediaColumns.MIME_TYPE, "image/png");
|
||||
ContentResolver cr=getActivity().getContentResolver();
|
||||
Uri itemUri=cr.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values);
|
||||
return cr.openOutputStream(itemUri);
|
||||
}else{
|
||||
return new FileOutputStream(new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig){
|
||||
super.onConfigurationChanged(newConfig);
|
||||
codeContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
|
||||
@Override
|
||||
public boolean onPreDraw(){
|
||||
codeContainer.getViewTreeObserver().removeOnPreDrawListener(this);
|
||||
updateParticleEmitter();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateParticleEmitter(){
|
||||
int[] loc={0, 0};
|
||||
particleAnimContainer.getLocationInWindow(loc);
|
||||
int x=loc[0], y=loc[1];
|
||||
codeContainer.getLocationInWindow(loc);
|
||||
int cx=loc[0]-x+codeContainer.getWidth()/2;
|
||||
int cy=loc[1]-y+codeContainer.getHeight()/2;
|
||||
int r=codeContainer.getWidth()/2-V.dp(10);
|
||||
particles.setEmitterPosition(cx, cy);
|
||||
particles.setClipOutBounds(cx-r, cy-r, cx+r, cy+r);
|
||||
}
|
||||
|
||||
public static class CustomizedLinearLayout extends LinearLayout implements CustomViewHelper{
|
||||
public CustomizedLinearLayout(Context context){
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public CustomizedLinearLayout(Context context, AttributeSet attrs){
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public CustomizedLinearLayout(Context context, AttributeSet attrs, int defStyle){
|
||||
super(context, attrs, defStyle);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
|
||||
int maxW=dp(400);
|
||||
FixedAspectRatioFrameLayout aspectLayout=(FixedAspectRatioFrameLayout) getChildAt(0);
|
||||
if(MeasureSpec.getSize(widthMeasureSpec)>maxW){
|
||||
widthMeasureSpec=MeasureSpec.getMode(widthMeasureSpec) | maxW;
|
||||
aspectLayout.setUseHeight(MeasureSpec.getSize(heightMeasureSpec)<dp(464));
|
||||
}else{
|
||||
aspectLayout.setUseHeight(false);
|
||||
}
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
}
|
||||
}
|
||||
|
||||
private class AnimatedCornersDrawable extends Drawable{
|
||||
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private RectF tmpRect=new RectF();
|
||||
|
||||
public AnimatedCornersDrawable(Context context){
|
||||
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorM3Primary));
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
paint.setStrokeCap(Paint.Cap.ROUND);
|
||||
paint.setStrokeWidth(V.dp(4));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas){
|
||||
float inset=V.dp(24);
|
||||
float radius=V.dp(40);
|
||||
float animProgress=((float)Math.sin(Math.toRadians(SystemClock.uptimeMillis()/16.6%360.0))+1f)/2f;
|
||||
tmpRect.set(getBounds());
|
||||
tmpRect.inset(inset, inset);
|
||||
canvas.save();
|
||||
float factor=1f+0.025f*animProgress;
|
||||
paint.setStrokeWidth(V.dp(4)/factor);
|
||||
canvas.scale(factor, factor, tmpRect.centerX(), tmpRect.centerY());
|
||||
canvas.drawRoundRect(tmpRect, radius, radius, paint);
|
||||
canvas.restore();
|
||||
invalidateSelf();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAlpha(int alpha){
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColorFilter(@Nullable ColorFilter colorFilter){
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOpacity(){
|
||||
return PixelFormat.TRANSLUCENT;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onBoundsChange(@NonNull Rect bounds){
|
||||
super.onBoundsChange(bounds);
|
||||
float inset=V.dp(24);
|
||||
float radius=V.dp(40);
|
||||
float additionalLength=V.dp(40);
|
||||
tmpRect.set(getBounds());
|
||||
tmpRect.inset(inset, inset);
|
||||
float[] intervals=new float[]{3.1415f*radius*0.5f+additionalLength*2f, tmpRect.width()-radius*2f-additionalLength*2f};
|
||||
paint.setPathEffect(new DashPathEffect(intervals, intervals[0]-additionalLength));
|
||||
updateParticleEmitter();
|
||||
}
|
||||
}
|
||||
|
||||
private class TouchDismissListener implements View.OnTouchListener{
|
||||
private Rect tmpRect=new Rect();
|
||||
private int[] xy={0, 0};
|
||||
private boolean dragging;
|
||||
private float dragDownY;
|
||||
private VelocityTracker velocityTracker;
|
||||
private SpringAnimation springBackAnim;
|
||||
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent ev){
|
||||
if(ev.getAction()==MotionEvent.ACTION_DOWN){
|
||||
codeContainer.getLocationInWindow(xy);
|
||||
tmpRect.set(xy[0], xy[1], xy[0]+codeContainer.getWidth(), xy[1]+codeContainer.getHeight());
|
||||
if(springBackAnim!=null){
|
||||
springBackAnim.skipToEnd();
|
||||
}
|
||||
if(tmpRect.contains((int)ev.getX(), (int)ev.getY())){
|
||||
dragging=true;
|
||||
dragDownY=ev.getY();
|
||||
velocityTracker=VelocityTracker.obtain();
|
||||
velocityTracker.addMovement(ev);
|
||||
}else{
|
||||
dismiss();
|
||||
}
|
||||
}else if(dragging){
|
||||
if(ev.getAction()==MotionEvent.ACTION_MOVE){
|
||||
float transY=ev.getY()-dragDownY;
|
||||
particleAnimContainer.setTranslationY(transY);
|
||||
float alpha=1f-Math.abs(transY)/particleAnimContainer.getHeight();
|
||||
scrim.setAlpha(Math.round(alpha*255));
|
||||
getToolbar().setAlpha(alpha);
|
||||
velocityTracker.addMovement(ev);
|
||||
}else if(ev.getAction()==MotionEvent.ACTION_UP){
|
||||
dragging=false;
|
||||
velocityTracker.addMovement(ev);
|
||||
velocityTracker.computeCurrentVelocity(1000);
|
||||
float velocity=velocityTracker.getYVelocity();
|
||||
if(Math.abs(velocity)>=V.dp(1000) || Math.abs(particleAnimContainer.getTranslationY())>particleAnimContainer.getHeight()/4f){
|
||||
dismissWithAnimation(ProfileQrCodeFragment.super::dismiss, velocity>0);
|
||||
}else{
|
||||
springBack(velocity);
|
||||
}
|
||||
velocityTracker.recycle();
|
||||
velocityTracker=null;
|
||||
}else if(ev.getAction()==MotionEvent.ACTION_CANCEL){
|
||||
dragging=false;
|
||||
springBack(velocityTracker.getYVelocity());
|
||||
velocityTracker.recycle();
|
||||
velocityTracker=null;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void springBack(float velocityY){
|
||||
SpringAnimation anim=new SpringAnimation(particleAnimContainer, DynamicAnimation.TRANSLATION_Y, 0);
|
||||
anim.getSpring().setStiffness(SpringForce.STIFFNESS_LOW).setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY);
|
||||
anim.setStartVelocity(velocityY);
|
||||
anim.addEndListener((animation, canceled, value, velocity)->springBackAnim=null);
|
||||
anim.addUpdateListener((animation, value, velocity)->{
|
||||
float alpha=1f-Math.abs(particleAnimContainer.getTranslationY())/particleAnimContainer.getHeight();
|
||||
scrim.setAlpha(Math.round(alpha*255));
|
||||
getToolbar().setAlpha(alpha);
|
||||
});
|
||||
springBackAnim=anim;
|
||||
anim.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import org.joinmastodon.android.ui.views.SizeListenerFrameLayout;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -260,10 +261,10 @@ public class SplashFragment extends AppKitFragment{
|
||||
private void loadAndChooseDefaultServer(){
|
||||
ClipData clipData=getActivity().getSystemService(ClipboardManager.class).getPrimaryClip();
|
||||
if(clipData!=null && clipData.getItemCount()>0){
|
||||
CharSequence clipText=clipData.getItemAt(0).coerceToText(getActivity());
|
||||
if(HtmlParser.INVITE_LINK_PATTERN.matcher(clipText).find()){
|
||||
currentInviteLink=Uri.parse(clipText.toString());
|
||||
defaultServerButton.setText(getString(R.string.join_server_x_with_invite, currentInviteLink.getHost()));
|
||||
String clipText=clipData.getItemAt(0).coerceToText(getActivity()).toString();
|
||||
if(HtmlParser.isValidInviteUrl(clipText)){
|
||||
currentInviteLink=Uri.parse(clipText);
|
||||
defaultServerButton.setText(getString(R.string.join_server_x_with_invite, HtmlParser.normalizeDomain(Objects.requireNonNull(currentInviteLink.getHost()))));
|
||||
}
|
||||
}else{
|
||||
loadingDefaultServer=true;
|
||||
@@ -315,7 +316,7 @@ public class SplashFragment extends AppKitFragment{
|
||||
if(defaultServerButton!=null && getActivity()!=null && currentInviteLink==null){
|
||||
defaultServerButton.setTextVisible(true);
|
||||
defaultServerProgress.setVisibility(View.GONE);
|
||||
defaultServerButton.setText(getString(R.string.join_default_server, chosenDefaultServer));
|
||||
defaultServerButton.setText(getString(R.string.join_default_server, HtmlParser.normalizeDomain(chosenDefaultServer)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ public class ThreadFragment extends StatusListFragment{
|
||||
replyButton.setOnClickListener(v->openReply());
|
||||
Account self=AccountSessionManager.get(accountID).self;
|
||||
if(!TextUtils.isEmpty(self.avatar)){
|
||||
ViewImageLoader.loadWithoutAnimation(replyButtonAva, getResources().getDrawable(R.drawable.image_placeholder), new UrlImageLoaderRequest(self.avatar, V.dp(24), V.dp(24)));
|
||||
ViewImageLoader.loadWithoutAnimation(replyButtonAva, getResources().getDrawable(R.drawable.image_placeholder, getActivity().getTheme()), new UrlImageLoaderRequest(self.avatar, V.dp(24), V.dp(24)));
|
||||
}
|
||||
UiUtils.loadCustomEmojiInTextView(toolbarTitleView);
|
||||
showContent();
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package org.joinmastodon.android.fragments.account_list;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.SearchAccounts;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
|
||||
public class AddListMembersFragment extends AccountSearchFragment{
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
dataLoaded();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
refreshing=true;
|
||||
currentRequest=new SearchAccounts(currentQuery, 0, 0, false, true)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Account> result){
|
||||
AddListMembersFragment.this.onSuccess(result);
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getSearchViewPlaceholder(){
|
||||
return getString(R.string.search_among_people_you_follow);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.joinmastodon.android.fragments.discover;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Fragment;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
@@ -11,9 +14,14 @@ import android.widget.FrameLayout;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.joinmastodon.android.MainActivity;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.fragments.ScrollableToTop;
|
||||
import org.joinmastodon.android.googleservices.GmsClient;
|
||||
import org.joinmastodon.android.googleservices.barcodescanner.Barcode;
|
||||
import org.joinmastodon.android.googleservices.barcodescanner.BarcodeScanner;
|
||||
import org.joinmastodon.android.model.SearchResult;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.SimpleViewHolder;
|
||||
@@ -28,11 +36,11 @@ import androidx.viewpager2.widget.ViewPager2;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.fragments.AppKitFragment;
|
||||
import me.grishka.appkit.fragments.BaseRecyclerFragment;
|
||||
import me.grishka.appkit.fragments.OnBackPressedListener;
|
||||
import me.grishka.appkit.utils.V;
|
||||
|
||||
public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener{
|
||||
public class DiscoverFragment extends AppKitFragment implements ScrollableToTop{
|
||||
private static final int QUERY_RESULT=937;
|
||||
private static final int SCAN_RESULT=456;
|
||||
|
||||
private TabLayout tabLayout;
|
||||
private ViewPager2 pager;
|
||||
@@ -40,7 +48,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
private TabLayoutMediator tabLayoutMediator;
|
||||
private boolean searchActive;
|
||||
private FrameLayout searchView;
|
||||
private ImageButton searchBack;
|
||||
private ImageButton searchBack, searchScanQR;
|
||||
private TextView searchText;
|
||||
private View tabsDivider;
|
||||
|
||||
@@ -52,6 +60,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
|
||||
private String accountID;
|
||||
private String currentQuery;
|
||||
private Intent scannerIntent;
|
||||
private Runnable searchExitCallback=this::exitSearch;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
@@ -60,6 +70,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
setRetainInstance(true);
|
||||
|
||||
accountID=getArguments().getString("account");
|
||||
scannerIntent=BarcodeScanner.createIntent(Barcode.FORMAT_QR_CODE, false, true);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -169,11 +180,17 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
searchBack.setImportantForAccessibility(searchActive ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
searchBack.setOnClickListener(v->exitSearch());
|
||||
if(searchActive){
|
||||
searchBack.setImageResource(R.drawable.ic_arrow_back);
|
||||
searchBack.setImageResource(me.grishka.appkit.R.drawable.ic_arrow_back);
|
||||
pager.setVisibility(View.GONE);
|
||||
tabLayout.setVisibility(View.GONE);
|
||||
searchView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
searchScanQR=view.findViewById(R.id.search_scan_qr);
|
||||
if(!GmsClient.isGooglePlayServicesAvailable(getActivity())){
|
||||
searchScanQR.setVisibility(View.GONE);
|
||||
}else{
|
||||
searchScanQR.setOnClickListener(v->openQrScanner());
|
||||
}
|
||||
|
||||
View searchWrap=view.findViewById(R.id.search_wrap);
|
||||
searchWrap.setOutlineProvider(OutlineProviders.roundedRect(28));
|
||||
@@ -211,10 +228,11 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
pager.setVisibility(View.GONE);
|
||||
tabLayout.setVisibility(View.GONE);
|
||||
searchView.setVisibility(View.VISIBLE);
|
||||
searchBack.setImageResource(R.drawable.ic_arrow_back);
|
||||
searchBack.setImageResource(me.grishka.appkit.R.drawable.ic_arrow_back);
|
||||
searchBack.setEnabled(true);
|
||||
searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
|
||||
tabsDivider.setVisibility(View.GONE);
|
||||
addBackCallback(searchExitCallback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +249,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
tabsDivider.setVisibility(View.VISIBLE);
|
||||
currentQuery=null;
|
||||
removeBackCallback(searchExitCallback);
|
||||
}
|
||||
|
||||
private Fragment getFragmentForPage(int page){
|
||||
@@ -243,15 +262,6 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBackPressed(){
|
||||
if(searchActive){
|
||||
exitSearch();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFragmentResult(int reqCode, boolean success, Bundle result){
|
||||
if(reqCode==QUERY_RESULT && success){
|
||||
@@ -268,6 +278,28 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data){
|
||||
if(requestCode==SCAN_RESULT && resultCode==Activity.RESULT_OK && BarcodeScanner.isValidResult(data)){
|
||||
Barcode code=BarcodeScanner.getResult(data);
|
||||
if(code!=null){
|
||||
if(code.rawValue.startsWith("https:") || code.rawValue.startsWith("http:")){
|
||||
((MainActivity)getActivity()).handleURL(Uri.parse(code.rawValue), accountID);
|
||||
}else{
|
||||
Toast.makeText(getActivity(), R.string.link_not_supported, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void openQrScanner(){
|
||||
if(scannerIntent.resolveActivity(getActivity().getPackageManager())!=null){
|
||||
startActivityForResult(scannerIntent, SCAN_RESULT);
|
||||
}else{
|
||||
BarcodeScanner.installScannerModule(getActivity(), ()->startActivityForResult(scannerIntent, SCAN_RESULT));
|
||||
}
|
||||
}
|
||||
|
||||
private class DiscoverPagerAdapter extends RecyclerView.Adapter<SimpleViewHolder>{
|
||||
@NonNull
|
||||
@Override
|
||||
|
||||
@@ -1,55 +1,36 @@
|
||||
package org.joinmastodon.android.fragments.discover;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.trends.GetTrendingLinks;
|
||||
import org.joinmastodon.android.fragments.ScrollableToTop;
|
||||
import org.joinmastodon.android.model.Card;
|
||||
import org.joinmastodon.android.model.viewmodel.CardViewModel;
|
||||
import org.joinmastodon.android.ui.DividerItemDecoration;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable;
|
||||
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.viewholders.LinkCardHolder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.fragments.BaseRecyclerFragment;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
|
||||
import me.grishka.appkit.imageloader.ListImageLoaderAdapter;
|
||||
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
|
||||
import me.grishka.appkit.imageloader.RecyclerViewDelegate;
|
||||
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.BindableViewHolder;
|
||||
import me.grishka.appkit.utils.MergeRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
|
||||
public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> implements ScrollableToTop{
|
||||
public class DiscoverNewsFragment extends BaseRecyclerFragment<DiscoverNewsFragment.CardItem> implements ScrollableToTop{
|
||||
private String accountID;
|
||||
private DiscoverInfoBannerHelper bannerHelper;
|
||||
private MergeRecyclerAdapter mergeAdapter;
|
||||
private UsableRecyclerView cardsList;
|
||||
private ArrayList<CardViewModel> top3=new ArrayList<>();
|
||||
private CardLinksAdapter cardsAdapter;
|
||||
|
||||
public DiscoverNewsFragment(){
|
||||
super(10);
|
||||
@@ -68,12 +49,14 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> im
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Card> result){
|
||||
top3.clear();
|
||||
top3.addAll(result.subList(0, Math.min(3, result.size())).stream().map(card->new CardViewModel(card, 280, 140)).collect(Collectors.toList()));
|
||||
cardsAdapter.notifyDataSetChanged();
|
||||
|
||||
onDataLoaded(result.subList(top3.size(), result.size()).stream()
|
||||
.map(card->new CardViewModel(card, 56, 56))
|
||||
int[] index={0};
|
||||
onDataLoaded(result.stream()
|
||||
.map(card->{
|
||||
int actualIndex=index[0]+(refreshing ? 0 : (data.size()+preloadedData.size()));
|
||||
index[0]++;
|
||||
int size=actualIndex==0 ? 1000 : 192;
|
||||
return new CardItem(new CardViewModel(card, size, size, card, accountID));
|
||||
})
|
||||
.collect(Collectors.toList()), false);
|
||||
bannerHelper.onBannerBecameVisible();
|
||||
}
|
||||
@@ -81,28 +64,12 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> im
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@Override
|
||||
protected RecyclerView.Adapter getAdapter(){
|
||||
cardsList=new UsableRecyclerView(getActivity());
|
||||
cardsList.setLayoutManager(new LinearLayoutManager(getActivity(), LinearLayoutManager.HORIZONTAL, false));
|
||||
ListImageLoaderWrapper cardsImageLoader=new ListImageLoaderWrapper(getActivity(), cardsList, new RecyclerViewDelegate(cardsList), this);
|
||||
cardsList.setAdapter(cardsAdapter=new CardLinksAdapter(cardsImageLoader, top3));
|
||||
cardsList.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(256)));
|
||||
cardsList.setPadding(V.dp(16), V.dp(8), 0, 0);
|
||||
cardsList.setClipToPadding(false);
|
||||
cardsList.addItemDecoration(new RecyclerView.ItemDecoration(){
|
||||
@Override
|
||||
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
|
||||
outRect.right=V.dp(16);
|
||||
}
|
||||
});
|
||||
cardsList.setSelector(R.drawable.bg_rect_12dp_ripple);
|
||||
cardsList.setDrawSelectorOnTop(true);
|
||||
|
||||
mergeAdapter=new MergeRecyclerAdapter();
|
||||
bannerHelper.maybeAddBanner(list, mergeAdapter);
|
||||
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(cardsList));
|
||||
mergeAdapter.addAdapter(new LinksAdapter(imgLoader, data));
|
||||
mergeAdapter.addAdapter(new LinksAdapter(imgLoader));
|
||||
return mergeAdapter;
|
||||
}
|
||||
|
||||
@@ -111,18 +78,46 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> im
|
||||
smoothScrollRecyclerViewToTop(list);
|
||||
}
|
||||
|
||||
private class LinksAdapter extends UsableRecyclerView.Adapter<BaseLinkViewHolder> implements ImageLoaderRecyclerAdapter{
|
||||
private final List<CardViewModel> data;
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
list.addItemDecoration(new RecyclerView.ItemDecoration(){
|
||||
@Override
|
||||
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
|
||||
if(parent.getChildAdapterPosition(view)==0 && !bannerHelper.isBannerShown()){
|
||||
outRect.top=V.dp(16);
|
||||
}
|
||||
if(parent.getChildViewHolder(view) instanceof LinkCardHolder<?>){
|
||||
outRect.bottom=V.dp(8);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public LinksAdapter(ListImageLoaderWrapper imgLoader, List<CardViewModel> data){
|
||||
public static class CardItem implements LinkCardHolder.LinkCardProvider{
|
||||
public final CardViewModel card;
|
||||
|
||||
private CardItem(CardViewModel card){
|
||||
this.card=card;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CardViewModel getCard(){
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
private class LinksAdapter extends UsableRecyclerView.Adapter<LinkCardHolder<CardItem>> implements ImageLoaderRecyclerAdapter{
|
||||
public LinksAdapter(ListImageLoaderWrapper imgLoader){
|
||||
super(imgLoader);
|
||||
this.data=data;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public BaseLinkViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||
return new LinkViewHolder();
|
||||
public LinkCardHolder<CardItem> onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||
LinkCardHolder<CardItem> vh=new LinkCardHolder<>(getActivity(), list, viewType==1, accountID);
|
||||
vh.setTryResolving(false);
|
||||
return vh;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -131,91 +126,24 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> im
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(BaseLinkViewHolder holder, int position){
|
||||
holder.bind(data.get(position).card);
|
||||
public void onBindViewHolder(LinkCardHolder<CardItem> holder, int position){
|
||||
holder.bind(data.get(position));
|
||||
super.onBindViewHolder(holder, position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getImageCountForItem(int position){
|
||||
return data.get(position).imageRequest==null ? 0 : 1;
|
||||
return data.get(position).card.getImageCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImageLoaderRequest getImageRequest(int position, int image){
|
||||
return data.get(position).imageRequest;
|
||||
}
|
||||
}
|
||||
|
||||
private class CardLinksAdapter extends LinksAdapter{
|
||||
public CardLinksAdapter(ListImageLoaderWrapper imgLoader, List<CardViewModel> data){
|
||||
super(imgLoader, data);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public BaseLinkViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||
return new LinkCardViewHolder();
|
||||
}
|
||||
}
|
||||
|
||||
private class BaseLinkViewHolder extends BindableViewHolder<Card> implements UsableRecyclerView.Clickable, ImageLoaderViewHolder{
|
||||
protected final TextView name, title;
|
||||
protected final ImageView photo;
|
||||
private BlurhashCrossfadeDrawable crossfadeDrawable=new BlurhashCrossfadeDrawable();
|
||||
private boolean didClear;
|
||||
|
||||
public BaseLinkViewHolder(int layout){
|
||||
super(getActivity(), layout, list);
|
||||
name=findViewById(R.id.name);
|
||||
title=findViewById(R.id.title);
|
||||
photo=findViewById(R.id.photo);
|
||||
return data.get(position).card.getImageRequest(image);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(Card item){
|
||||
name.setText(item.providerName);
|
||||
title.setText(item.title);
|
||||
crossfadeDrawable.setSize(item.width, item.height);
|
||||
crossfadeDrawable.setBlurhashDrawable(item.blurhashPlaceholder);
|
||||
crossfadeDrawable.setCrossfadeAlpha(0f);
|
||||
photo.setImageDrawable(null);
|
||||
photo.setImageDrawable(crossfadeDrawable);
|
||||
didClear=false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setImage(int index, Drawable drawable){
|
||||
crossfadeDrawable.setImageDrawable(drawable);
|
||||
if(didClear)
|
||||
crossfadeDrawable.animateAlpha(0f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearImage(int index){
|
||||
crossfadeDrawable.setCrossfadeAlpha(1f);
|
||||
didClear=true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
UiUtils.launchWebBrowser(getActivity(), item.url);
|
||||
}
|
||||
}
|
||||
|
||||
private class LinkViewHolder extends BaseLinkViewHolder{
|
||||
public LinkViewHolder(){
|
||||
super(R.layout.item_trending_link);
|
||||
photo.setOutlineProvider(OutlineProviders.roundedRect(12));
|
||||
photo.setClipToOutline(true);
|
||||
}
|
||||
}
|
||||
|
||||
private class LinkCardViewHolder extends BaseLinkViewHolder{
|
||||
public LinkCardViewHolder(){
|
||||
super(R.layout.item_trending_link_card);
|
||||
itemView.setOutlineProvider(OutlineProviders.roundedRect(12));
|
||||
itemView.setClipToOutline(true);
|
||||
public int getItemViewType(int position){
|
||||
return position==0 ? 1 : 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,14 +57,13 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.fragments.CustomTransitionsFragment;
|
||||
import me.grishka.appkit.fragments.OnBackPressedListener;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
|
||||
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.MergeRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.V;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
|
||||
public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultViewModel> implements CustomTransitionsFragment, OnBackPressedListener{
|
||||
public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultViewModel> implements CustomTransitionsFragment{
|
||||
private static final Pattern HASHTAG_REGEX=Pattern.compile("^(\\w*[a-zA-Z·]\\w*)$", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern USERNAME_REGEX=Pattern.compile("^@?([a-z0-9_-]+)(@[^\\s]+)?$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
@@ -166,7 +165,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
|
||||
|
||||
navigationIcon=new LayerDrawable(new Drawable[]{
|
||||
searchIcon=getToolbarContext().getResources().getDrawable(R.drawable.ic_search_24px, getToolbarContext().getTheme()).mutate(),
|
||||
backIcon=getToolbarContext().getResources().getDrawable(R.drawable.ic_arrow_back, getToolbarContext().getTheme()).mutate()
|
||||
backIcon=getToolbarContext().getResources().getDrawable(me.grishka.appkit.R.drawable.ic_arrow_back, getToolbarContext().getTheme()).mutate()
|
||||
}){
|
||||
@Override
|
||||
public Drawable mutate(){
|
||||
@@ -371,6 +370,11 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
|
||||
container.invalidateOutline();
|
||||
navigationIcon.invalidateSelf();
|
||||
});
|
||||
if(!enter){
|
||||
String initialQuery=getArguments().getString("query");
|
||||
searchViewHelper.setQuery(TextUtils.isEmpty(initialQuery) ? "" : initialQuery);
|
||||
currentQuery=initialQuery;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
@@ -437,14 +441,6 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
|
||||
Nav.finish(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onBackPressed(){
|
||||
String initialQuery=getArguments().getString("query");
|
||||
searchViewHelper.setQuery(TextUtils.isEmpty(initialQuery) ? "" : initialQuery);
|
||||
currentQuery=initialQuery;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static class AnimatableOutlineProvider extends ViewOutlineProvider{
|
||||
private float boundsFraction, radius;
|
||||
private final Rect boundsFrom, boundsTo;
|
||||
|
||||
@@ -73,8 +73,6 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInsta
|
||||
protected boolean isSignup;
|
||||
protected CatalogInstance fakeInstance=new CatalogInstance();
|
||||
|
||||
private static final double DUNBAR=Math.log(800);
|
||||
|
||||
public InstanceCatalogFragment(int layout, int perPage){
|
||||
super(layout, perPage);
|
||||
}
|
||||
@@ -155,7 +153,7 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInsta
|
||||
}
|
||||
|
||||
protected void loadInstanceInfo(String _domain, boolean isFromRedirect, Consumer<Object> onError){
|
||||
if(TextUtils.isEmpty(_domain))
|
||||
if(TextUtils.isEmpty(_domain) || _domain.indexOf('.')==-1)
|
||||
return;
|
||||
String domain=normalizeInstanceDomain(_domain);
|
||||
Instance cachedInstance=instancesCache.get(domain);
|
||||
@@ -316,8 +314,10 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInsta
|
||||
loadingInstanceRedirectRequest=null;
|
||||
loadingInstanceDomain=null;
|
||||
Activity a=getActivity();
|
||||
if(a==null)
|
||||
if(a==null) {
|
||||
response.close();
|
||||
return;
|
||||
}
|
||||
try(response){
|
||||
if(!response.isSuccessful()){
|
||||
a.runOnUiThread(()->{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user