Compare commits
390 Commits
2.3.0+fork
...
2.3.0+fork
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dae5989d64 | ||
|
|
1d1c4f2666 | ||
|
|
0fecdf345a | ||
|
|
82bcfe3fa8 | ||
|
|
203c43343a | ||
|
|
4c105acc30 | ||
|
|
d81eb6ad0a | ||
|
|
08542cd16f | ||
|
|
f30e12f5c6 | ||
|
|
3a14fb5912 | ||
|
|
cc64a1b6a2 | ||
|
|
7fa079e362 | ||
|
|
c2e6280a18 | ||
|
|
01225b05f2 | ||
|
|
89f27984b7 | ||
|
|
61b933655c | ||
|
|
d47e1939d0 | ||
|
|
00b934dc69 | ||
|
|
c86ff1cce4 | ||
|
|
5427b21365 | ||
|
|
d875edbc23 | ||
|
|
4aecb17497 | ||
|
|
806db1d09f | ||
|
|
49cf100d37 | ||
|
|
259a0ae140 | ||
|
|
420233da14 | ||
|
|
78ec24ff0c | ||
|
|
a6f1d981db | ||
|
|
b07789b346 | ||
|
|
42c55d5eee | ||
|
|
13545fd5ef | ||
|
|
134513babd | ||
|
|
91cb616164 | ||
|
|
f3d600282e | ||
|
|
c26df5762f | ||
|
|
2021c335ac | ||
|
|
d121f14d30 | ||
|
|
d1a2a70cdc | ||
|
|
89ef482e2e | ||
|
|
9918649d7c | ||
|
|
09185faf9a | ||
|
|
bed201a2f7 | ||
|
|
5e7a4c0136 | ||
|
|
bcb8717d5f | ||
|
|
ed1c1bd097 | ||
|
|
f480532fd6 | ||
|
|
cc056cef08 | ||
|
|
9e7445b8d8 | ||
|
|
e2d96d3bc7 | ||
|
|
4f5c99be21 | ||
|
|
0388f9d9be | ||
|
|
c45128ced0 | ||
|
|
f404d2f9cd | ||
|
|
2dada69eb8 | ||
|
|
b7e0596014 | ||
|
|
dbef984908 | ||
|
|
55259f103d | ||
|
|
81519fe906 | ||
|
|
07ab3c394a | ||
|
|
620cc94351 | ||
|
|
2494918171 | ||
|
|
a0bed5e739 | ||
|
|
a42bf86a1e | ||
|
|
9c7ae9653b | ||
|
|
44473705b9 | ||
|
|
f1d40f8963 | ||
|
|
fbae5d8816 | ||
|
|
43afbb7523 | ||
|
|
080815846f | ||
|
|
4b6c6cbcfe | ||
|
|
117037e7e8 | ||
|
|
05972fc702 | ||
|
|
28084b9f9e | ||
|
|
02010df408 | ||
|
|
38f77c69d1 | ||
|
|
d0a8c26b65 | ||
|
|
401602e5bc | ||
|
|
ccd9dbed13 | ||
|
|
736d5d9f3e | ||
|
|
32451c0eea | ||
|
|
e7ed8d5590 | ||
|
|
79d04a949b | ||
|
|
5cd99b9763 | ||
|
|
3f30c2f3be | ||
|
|
db8187bbc9 | ||
|
|
4e1632aa19 | ||
|
|
a813f961af | ||
|
|
f6417662b9 | ||
|
|
2d1bc09616 | ||
|
|
d9e5ea5b80 | ||
|
|
1ab6bc3663 | ||
|
|
effe3a079f | ||
|
|
7d65563096 | ||
|
|
857c5b9a55 | ||
|
|
e49760c5a0 | ||
|
|
93b97e99a8 | ||
|
|
6d148b1f7a | ||
|
|
4d24e4e846 | ||
|
|
9f5c420e66 | ||
|
|
ca07240a70 | ||
|
|
1b6978bb93 | ||
|
|
d4b20fc5f7 | ||
|
|
d3d95c7963 | ||
|
|
98c5baecad | ||
|
|
766b7b8c45 | ||
|
|
896ded9ff3 | ||
|
|
7b31543d7a | ||
|
|
ff61c3c02e | ||
|
|
aa8562dc88 | ||
|
|
ec495750fe | ||
|
|
af33c593b5 | ||
|
|
4586e42459 | ||
|
|
2a45b7d13d | ||
|
|
60d573de58 | ||
|
|
2d7499e8cc | ||
|
|
9ec82ae090 | ||
|
|
da783c3771 | ||
|
|
9869581515 | ||
|
|
f45fb87ea5 | ||
|
|
d80ac7557e | ||
|
|
58403fef59 | ||
|
|
87ca8b1ad7 | ||
|
|
04e1f9e148 | ||
|
|
1e1fe47638 | ||
|
|
c567e264de | ||
|
|
c142f82fd1 | ||
|
|
c0cf5b40fa | ||
|
|
b45e87b271 | ||
|
|
958243e65d | ||
|
|
8cc91b0f02 | ||
|
|
0ac7d3530e | ||
|
|
10d42264c8 | ||
|
|
72fee62472 | ||
|
|
9b4528b69a | ||
|
|
4b0cf4311d | ||
|
|
4ceea9100d | ||
|
|
2522cd26d1 | ||
|
|
294bcef5f6 | ||
|
|
e61618bf2c | ||
|
|
70e5030fe1 | ||
|
|
7c270aadda | ||
|
|
30eaeb006d | ||
|
|
5e11b3fb7a | ||
|
|
d6089d0c1e | ||
|
|
1bb288e565 | ||
|
|
d42eb934d5 | ||
|
|
2fecd6f0a3 | ||
|
|
c3a2b5a6e1 | ||
|
|
ccff874bcf | ||
|
|
9e7f351174 | ||
|
|
a9e7fab029 | ||
|
|
aad8abd3bf | ||
|
|
d938c8c470 | ||
|
|
124ad8cb0e | ||
|
|
a17c3293b5 | ||
|
|
5868da3337 | ||
|
|
731ee17d6d | ||
|
|
edddc297dd | ||
|
|
85152102fd | ||
|
|
fba4c1c6d6 | ||
|
|
593e8d0eb7 | ||
|
|
bafb1ba8f8 | ||
|
|
36124db2aa | ||
|
|
155a093eb7 | ||
|
|
ddee29bf03 | ||
|
|
99e2958649 | ||
|
|
519afb6259 | ||
|
|
6ab8991c45 | ||
|
|
44200a4d56 | ||
|
|
e929478b6a | ||
|
|
cf98aa4939 | ||
|
|
22585a2ec5 | ||
|
|
fa6abd44c3 | ||
|
|
1d7cbcc4e1 | ||
|
|
5edbe9b826 | ||
|
|
b5027ee66f | ||
|
|
499baeb496 | ||
|
|
72d486e992 | ||
|
|
3020c826ed | ||
|
|
34f3e33efc | ||
|
|
5b25168eb7 | ||
|
|
c785bbb2d7 | ||
|
|
45324a5598 | ||
|
|
55ad624209 | ||
|
|
ed0fe1e803 | ||
|
|
18079454a9 | ||
|
|
87cb80867a | ||
|
|
1829dc1d9d | ||
|
|
519cb672d2 | ||
|
|
e0a5e259f7 | ||
|
|
86512e237e | ||
|
|
b9efdbbb40 | ||
|
|
d369129ac7 | ||
|
|
c01135d822 | ||
|
|
653a66bd87 | ||
|
|
ffc2990b32 | ||
|
|
8b26fb3184 | ||
|
|
3fec39835c | ||
|
|
5402e78342 | ||
|
|
8995cfcc9d | ||
|
|
8d3b1f40a3 | ||
|
|
f775bae93e | ||
|
|
ca84bc36e3 | ||
|
|
2a775aba70 | ||
|
|
7cd65dcb32 | ||
|
|
4d694b2725 | ||
|
|
2e39f81c36 | ||
|
|
803e66f999 | ||
|
|
ed22d3b4ed | ||
|
|
ec72653dba | ||
|
|
9b1e79eba8 | ||
|
|
ca4a1d461a | ||
|
|
b90607582a | ||
|
|
0c95f6db1b | ||
|
|
4caa6cf650 | ||
|
|
bc08c149b7 | ||
|
|
4a783957ed | ||
|
|
113b47d9e2 | ||
|
|
96ccb14a59 | ||
|
|
bc8b0e192c | ||
|
|
72400703ab | ||
|
|
91345268e8 | ||
|
|
bff6ac4a14 | ||
|
|
75183f5625 | ||
|
|
7654b869ba | ||
|
|
f176384bcc | ||
|
|
a4f2a733b5 | ||
|
|
9ea48fa0ab | ||
|
|
cc2076ec10 | ||
|
|
b5a0c293c5 | ||
|
|
3265cfe772 | ||
|
|
857d0ce539 | ||
|
|
31a52c2790 | ||
|
|
94ce329f49 | ||
|
|
a67c8b36b1 | ||
|
|
ff90e21e86 | ||
|
|
5fd2e322f6 | ||
|
|
cdd9b0553f | ||
|
|
6157d4942a | ||
|
|
e68e870a7c | ||
|
|
0788b03828 | ||
|
|
b670da04ed | ||
|
|
f70abbbb73 | ||
|
|
a0dd75890c | ||
|
|
38df70cd9e | ||
|
|
e18fa57d73 | ||
|
|
51f6264534 | ||
|
|
feff45721f | ||
|
|
20558f0a19 | ||
|
|
e97a479e65 | ||
|
|
f590fde7a4 | ||
|
|
77c5173014 | ||
|
|
dd4bed0027 | ||
|
|
229c0b359f | ||
|
|
0d4158a612 | ||
|
|
cfde4425b7 | ||
|
|
15f84af757 | ||
|
|
39895ff79a | ||
|
|
3d2b67efc5 | ||
|
|
ebd637546f | ||
|
|
618946a8c6 | ||
|
|
e8ce2a7e35 | ||
|
|
f8dbecc3e1 | ||
|
|
76030c041c | ||
|
|
998e186f8b | ||
|
|
75bc0aa052 | ||
|
|
edb4b7152b | ||
|
|
66c9e0d908 | ||
|
|
0bdb23e462 | ||
|
|
d9ce0e6d31 | ||
|
|
aa3c8b5812 | ||
|
|
4392ce20b6 | ||
|
|
d5085c5899 | ||
|
|
9a1668a29a | ||
|
|
4d598bd2fe | ||
|
|
57911ce070 | ||
|
|
f9f8c4a9ef | ||
|
|
6ad8a85044 | ||
|
|
14e6187efc | ||
|
|
bd88606c48 | ||
|
|
b38c78c50a | ||
|
|
4c9f7fc8be | ||
|
|
4f11a79d2a | ||
|
|
7ab920d943 | ||
|
|
c8f2e7a752 | ||
|
|
cdcc428e86 | ||
|
|
7bb5584dd9 | ||
|
|
0c5c51dc17 | ||
|
|
b17b7afd03 | ||
|
|
e2e8173db6 | ||
|
|
5e7f4bda82 | ||
|
|
38996d8921 | ||
|
|
6cb8961639 | ||
|
|
18ac0423c0 | ||
|
|
d2704c1f0d | ||
|
|
ed23b7cc13 | ||
|
|
47ab6b5a08 | ||
|
|
70686bbbd0 | ||
|
|
b53997261e | ||
|
|
efd9b1e916 | ||
|
|
b51033a421 | ||
|
|
e0a793e176 | ||
|
|
542c24ff75 | ||
|
|
965f7c6d1d | ||
|
|
2df6d9ce60 | ||
|
|
5d3afc1b0e | ||
|
|
0c8f903eb6 | ||
|
|
ef23734b22 | ||
|
|
c0ab3a47ae | ||
|
|
f4a94bc42e | ||
|
|
69b95c27ec | ||
|
|
c64d6db859 | ||
|
|
730adc34dd | ||
|
|
a082a3d325 | ||
|
|
c7820ddac8 | ||
|
|
169fbc2d52 | ||
|
|
44e3e5faaf | ||
|
|
711c70af2f | ||
|
|
1d405d9e48 | ||
|
|
892ce130ca | ||
|
|
fea9d6e761 | ||
|
|
88e11f25a7 | ||
|
|
6faa497569 | ||
|
|
1d45899f8c | ||
|
|
938643f9e2 | ||
|
|
1ccf9bf4b7 | ||
|
|
ad9b5f028d | ||
|
|
e52154fd17 | ||
|
|
54202f3e8d | ||
|
|
d4b8c350dc | ||
|
|
daaf467168 | ||
|
|
eda52d5a55 | ||
|
|
0700274d6b | ||
|
|
faee3e3dd6 | ||
|
|
129ce09c9f | ||
|
|
368e226257 | ||
|
|
93321720e1 | ||
|
|
96c1c036a8 | ||
|
|
edffe0fd42 | ||
|
|
d1d8f2ef45 | ||
|
|
95ba52b761 | ||
|
|
02c8a56c17 | ||
|
|
b34a855150 | ||
|
|
b736cf2925 | ||
|
|
eea78302ab | ||
|
|
09a7da2952 | ||
|
|
ebf3b075b8 | ||
|
|
28c851a630 | ||
|
|
44194e5d43 | ||
|
|
58bb492461 | ||
|
|
00726abec1 | ||
|
|
c9e93bb6a6 | ||
|
|
f980bba7cd | ||
|
|
eea350f84e | ||
|
|
44bec713ae | ||
|
|
2139dbd76b | ||
|
|
ad92a08271 | ||
|
|
b0dc521b90 | ||
|
|
732de52ebb | ||
|
|
34b2a4e2a0 | ||
|
|
2291c2bb28 | ||
|
|
7581a6cf7e | ||
|
|
2c86356389 | ||
|
|
6815cd77e4 | ||
|
|
4f9a1db26b | ||
|
|
d3bcf9d8ee | ||
|
|
35d39b63e2 | ||
|
|
15c77e4220 | ||
|
|
962c094f7e | ||
|
|
c6081fb4d4 | ||
|
|
1832de3aab | ||
|
|
5c15914bab | ||
|
|
7e244d65bf | ||
|
|
9c8e6647bc | ||
|
|
4d128b4408 | ||
|
|
e0098efe32 | ||
|
|
42f5975f6b | ||
|
|
1045593cc9 | ||
|
|
3443b80ff7 | ||
|
|
9fe6b3457a | ||
|
|
0a26380f23 | ||
|
|
ef3605c8e3 | ||
|
|
3df20c4749 | ||
|
|
c63e87de45 | ||
|
|
1151e41846 | ||
|
|
09668d2500 | ||
|
|
773a24af2c | ||
|
|
b1f6409c8d | ||
|
|
ee8e535e58 | ||
|
|
d128f29bbc |
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,12 +1,11 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: LucasGGamerM
|
||||
custom: ["https://liberapay.com/LucasGGamerM/donate", liberapay.com]
|
||||
patreon: # mastodon
|
||||
open_collective: # Replace with a single Open Collective username e.g., user1
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username e.g., user1
|
||||
liberapay: LucasGGamerM # Replace with a single Liberapay username e.g., user1
|
||||
issuehunt: # Replace with a single IssueHunt username e.g., user1
|
||||
otechie: # Replace with a single Otechie username e.g., user1
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -25,7 +25,7 @@ Does this issue also occur with the respective upstream release?
|
||||
> No / Yes
|
||||
|
||||
> In case it does, please consider filing an [upstream bug report](https://github.com/mastodon/mastodon-android/issues) instead.
|
||||
> If this bug is seriously impacting your usage or you think I might want to try to fix it for Megalodon, feel free to still create this issue!
|
||||
> If this bug is seriously impacting your usage or you think I might want to try to fix it for Moshidon, feel free to still create this issue!
|
||||
|
||||
**Screenshots and screen recordings**
|
||||
|
||||
|
||||
54
FAQ.md
54
FAQ.md
@@ -7,3 +7,57 @@ A: There are many, but the most outstanding differences are: the ability to have
|
||||
Q: Will there ever be a version of Moshidon for iOS?
|
||||
|
||||
A: No. As android and iOS apps do not share code, it is incredibly hard to port.
|
||||
|
||||
## Detailed changes
|
||||
|
||||
### Features
|
||||
|
||||
* [Adding the ability to view other server's local timelines](https://github.com/LucasGGamerM/moshidon/tree/feature/local-timelines)
|
||||
* [Adding the ability to load followers and following from remote instance](https://github.com/LucasGGamerM/moshidon/tree/feature/remote-followers)
|
||||
* [Adding the ability to have filtered posts show with a warning](https://github.com/LucasGGamerM/moshidon/tree/feature/filters_again)
|
||||
* [Add “Unlisted” as a post visibility option](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/enable-unlisted)
|
||||
([Pull request](https://github.com/mastodon/mastodon-android/pull/103))
|
||||
* Adding a useful private profile note box
|
||||
* Auto hiding the compose button on scroll
|
||||
* Adding the ability to remind yourself to add alt text to images
|
||||
* An indicator for if an image has alt text or not
|
||||
* Adding the ability to have drafts
|
||||
* Also adding the ability to view announcements from your instance
|
||||
* Adding the ability to post for local timeline only (Only on instances that support it!)
|
||||
* [Add image description button and viewer](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/display-alt-text) ([Pull request](https://github.com/mastodon/mastodon-android/pull/129))
|
||||
* [Implement pinning posts and displaying pinned posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/pin-posts) ([Pull request](https://github.com/mastodon/mastodon-android/pull/140))
|
||||
* [Implement deleting and re-drafting](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/delete-redraft) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/21))
|
||||
* [Implement a bookmark button and list](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/bookmarks) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/22))
|
||||
* [Add “Check for update” button in addition to integrated update checker](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/check-for-update-button)
|
||||
* [Add “Mark media as sensitive” option](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/mark-media-as-sensitive)
|
||||
* [Add settings to hide replies and reposts from the timeline](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/filter-home-timeline) ([Pull request](https://github.com/mastodon/mastodon-android/pull/317))
|
||||
* [Follow and unfollow hashtags](https://github.com/sk22/megalodon/commit/7d38f031f197aa6cefaf53e39d929538689c1e4e) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/233))
|
||||
* [Notification bell for posts](https://github.com/sk22/megalodon/commit/b166ca705eb9169025ef32bbe6315b42491b57ea) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/81))
|
||||
* [Viewing lists and adding/removing users from lists](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:list-timeline-views) based on [@obstsalatschuessel](https://github.com/obstsalatschuessel)'s [Pull request](https://github.com/mastodon/mastodon-android/pull/286)
|
||||
* [List favorited posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/favs-list)
|
||||
* [Accept/reject follow requests](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/follow-requests)
|
||||
* [Display content warning title above text](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/cw-above-text)
|
||||
* [Add notifications tab for posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/posts-notifications-tab)
|
||||
* [Show visibility of original post when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/display-reply-visibility)
|
||||
* [Clickable reply/boost line above posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:clickable-boost-reply-line)
|
||||
* [Clickable reply line while replying to open original post](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/clickable-reply-line-compose)
|
||||
|
||||
|
||||
### Behavior
|
||||
|
||||
* Ask for confirmation before reblogging
|
||||
* Adding a bottom option for the publish button, allowing for easier use on larger screens!
|
||||
* [Make back button return to the home tab before exiting the app](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/back-returns-home) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/118))
|
||||
* [Always preserve content warnings when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/always-preserve-cw) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/113))
|
||||
* [Display full image when adding image description](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/compose-image-description-full-image) ([Pull request](https://github.com/mastodon/mastodon-android/pull/182))
|
||||
* [Set spoiler height independently to content height](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:spoiler-height-independent) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/166))
|
||||
* [Option to hide interaction numbers](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:settings/hide-interaction-numbers)
|
||||
* [Option to always reveal content warnings](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/cw-above-text)
|
||||
* [Option to disable scrolling title bars](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:settings/disable-marquee)
|
||||
|
||||
|
||||
### Visual
|
||||
|
||||
* [Custom extended footer redesign](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:compact-extended-footer)
|
||||
* [Improvements to the true black mode](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:true-black-improvements)
|
||||
* [Profile header tweaks](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:ui/profile-header-tweaks)
|
||||
|
||||
215
README.md
215
README.md
@@ -1,185 +1,91 @@
|
||||

|
||||
#  Moshidon, the material you mastodon client!
|
||||
|
||||
# Moshidon, the material you mastodon client!
|
||||
|
||||
> A fork of [megalodon](https://github.com/sk22/megalodon) which is a fork of [official Mastodon Android app](https://github.com/mastodon/mastodon-android) adding important features that are missing in the official app and possibly won’t ever be implemented, such as the federated timeline, unlisted posting, bookmarks and an image description viewer.
|
||||
> A fast, highly customizable, up-to-date fork of [megalodon](https://github.com/sk22/megalodon) adding important features such as a fully federated timeline, unlisted posting, drafts, scheduled posts, bookmarks, and alt text warnings.
|
||||
|
||||
|
||||
[](https://github.com/LucasGGamerM/moshidon/releases/latest/download/moshidon.apk)
|
||||
## Download Now
|
||||
|
||||
[](https://github.com/LucasGGamerM/moshidon-nightly/releases/latest/download/moshidon-nightly.apk)
|
||||
<a href="https://play.google.com/store/apps/details?id=org.joinmastodon.android.moshinda"><img height="35" alt="Get it on Google Play" src="img/google-play-badge.png"></a> <a href="https://f-droid.org/pt_BR/packages/org.joinmastodon.android.moshinda"><img height="35" alt="Get it on F-Droid" src="img/f-droid-badge.png"></a> <a href="https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.moshinda"><img height="35" alt="Get it on IzzyOnDroid" src="img/izzy-badge.png"></a>
|
||||
|
||||
[](https://github.com/LucasGGamerM/moshidon/releases/latest/download/moshidon.apk) [](https://translate.codeberg.org/engage/moshidon/) [](https://github.com/LucasGGamerM/moshidon-nightly/releases/latest/download/moshidon-nightly.apk) [](https://github.com/LucasGGamerM/moshidon/actions/workflows/nightly-builds.yml)
|
||||
|
||||
[](https://translate.codeberg.org/engage/moshidon/)
|
||||
|
||||
[](https://github.com/LucasGGamerM/moshidon/actions/workflows/nightly-builds.yml)
|
||||
## Donate
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=org.joinmastodon.android.moshinda"><img height="50" alt="Get it on Google Play" src="img/google-play-badge.png"></a>
|
||||
|
||||
<a href="https://f-droid.org/pt_BR/packages/org.joinmastodon.android.moshinda"><img height="50" alt="Get it on F-Droid" src="img/f-droid-badge.png"></a>
|
||||
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.moshinda"><img height="50" alt="Get it on IzzyOnDroid" src="img/izzy-badge.png"></a>
|
||||
<a href="https://github.com/sponsors/LucasGGamerM">Github Sponsors</a> | <a href="https://liberapay.com/LucasGGamerM/donate">Liberapay</a> | Monero Wallet Key: `4886mdarcyB6Yf8Qc6vDJBK1fz6ibHFLZUmHb4GZZz9yLGNhcG3XC64e5UZ8dVQYTLZb82W6P9WhteowW4STJEec97Gf22j`
|
||||
|
||||
## Help out the project by donating at: https://github.com/sponsors/LucasGGamerM!
|
||||
### We also support LiberaPay at: https://liberapay.com/LucasGGamerM/donate (Currently broken)
|
||||
## Key Features
|
||||
|
||||
### You can also donate some Monero through this wallet address as well:
|
||||
4886mdarcyB6Yf8Qc6vDJBK1fz6ibHFLZUmHb4GZZz9yLGNhcG3XC64e5UZ8dVQYTLZb82W6P9WhteowW4STJEec97Gf22j
|
||||
[ screenshot of full timeline in default colour scheme ]
|
||||
[ screenshot of full timeline in an alt colour scheme ]
|
||||
[ screenshot of profile page ]
|
||||
[ screenshot of compose post window ]
|
||||
|
||||
---
|
||||
### Flexible Timelines
|
||||
|
||||
## Key features
|
||||
[ Home dropdown menu ]
|
||||
|
||||
### **The ability to add other server's local timeline to your timelines**
|
||||
Under the Home menu by default you can see your active account's timeline, your server's local timeline, and your server's federated timeline. You can also pin hashtags, lists, other servers, or make a custom view of just your posts, your bookmarks, or your favourites for quick access. Then sort these timelines to prioritize the ones you visit most often.
|
||||
|
||||
It can be accessed in the "Edit timelines" menu, where you can add a new "Community" to see other server's local posts!
|
||||
### Multiple Accounts & Crossposting
|
||||
|
||||
### **View remote profiles**
|
||||
Sign in to multiple accounts in the same app and easily switch between them. Press and hold on the boost or fave button to boost or fave a post to a different account than the one you are currently browsing with.
|
||||
|
||||
You can now see all of a profile follows and followers, by directly loading them from the profile's home instance. In case of a failed lookup, the app will automatically fall back to the older method.
|
||||
[ boost icon pop up select profile ]
|
||||
|
||||
### **Translate posts easily**
|
||||
### Drafts & Scheduled Posts
|
||||
|
||||
Allows you to easily translate posts in another language with a translate button! Your instance must support translation, otherwise it will not work.
|
||||
Write posts and save them, or schedule them to post later. Edit and delete your drafts.
|
||||
|
||||
### **Show posts filtered with a warning**
|
||||
### Alt Text Tag & Reminder
|
||||
|
||||
Allows you to have filtered posts collapsed with a warning! As shown in the screenshots:
|
||||
An unobtrusive ALT tag appears on images with alt text. Clicking on the icon makes the alt text appear. By default, Moshidon will show a warning to add alt text if your post has any attachments lacking alt text. This is for better accessibility, and it can be disabled in settings. You can also hide from your feed all posts that are lacking in alt text.
|
||||
|
||||
Before | After
|
||||
:-------------------------:|:-------------------------:
|
||||
 | 
|
||||
[ image with alt text icon higlighted ]
|
||||
[ alt text expanded ]
|
||||
|
||||
### Themes & Customization
|
||||
|
||||
### **Color themes**
|
||||
Moshidon is designed according to Material Design principles. Follow your device's light or dark mode settings or change colour palette - your system's default, purple, black & white, "pitch black" (battery saving) and more. Customize your experience by moving or renaming the publish button, show or hide sensitive media by default, reduce motion, collapse long posts, add haptic feedback, or making the fave button a heart ♥ or a star ★.
|
||||
|
||||
Allows you to change theme within the app. Supports Material You, purple, pink, green, blue, red, orange, yellow and Nord!
|
||||
### Not Just For Mastodon
|
||||
|
||||
### **Unlisted posting**
|
||||
Supports features available on other types of fediverse servers such as admin announcements, showing pronouns in user names, post translation, emoji reactions, local-only posting, and markdown or html in posts.
|
||||
|
||||
**Allows you to post publicly without having your post show up in trends, hashtags or public timelines (i.e., in the tabs “Local”, “Community” and “Posts”).**
|
||||
### Fully Federated Feed & Profiles
|
||||
|
||||
When posting with Unlisted visibility, your posts will still be publicly accessible in your profile. They will also be shown in people’s Home timelines, but only if they follow you or someone they follow reposted/replied to your post.
|
||||
See all public posts from servers your server federates with and fetch profiles from a user's local server for accurate up to date information.
|
||||
|
||||
The Mastodon documentation has some more information about [Unlisted posting](https://docs.joinmastodon.org/user/posting/#unlisted) and [Public timelines](https://docs.joinmastodon.org/user/network/#timelines).
|
||||
## And more...
|
||||
- quote-posts - links to fediverse posts in other posts will be loaded inline like quote-tweets
|
||||
- manage pinned posts and bookmarks
|
||||
- manage lists, filters, and most privacy settings
|
||||
- display pronouns in timelines, threads, and user listings
|
||||
- get only specific types of notifications (no more finished polls!), limit who you get notifications from, or group all notifications into one.
|
||||
- automatically add "re:" to beginning of replies with content warnings
|
||||
- ask before boosting or deleting posts
|
||||
- when replying to a boosted post automatically mention the person who boosted it
|
||||
- overlay audio from posts, allowing your existing media to keep playing
|
||||
- auto-reveal CWs that are the same as ones you've already opened, or always reveal content warnings and sensitive media
|
||||
- hide media previews in timelines (save data)
|
||||
- show post interaction counts in timeline
|
||||
- allow custom emoji in display names
|
||||
- enable scrolling text for long display names
|
||||
- hide interaction buttons
|
||||
- show post dividers
|
||||
|
||||
### **Federated timeline**
|
||||
|
||||
**This allows you to chronologically see all Public posts from people on all other Fediverse neighborhoods your home instance is connected to.**
|
||||
## Installation & Releases
|
||||
|
||||
Despite being one of the main features of federated social media, the Federated timeline wasn’t included in the official Mastodon app – supposedly, because this conflicts with Google’s safety requirements for apps on the Play Store.
|
||||
Moshidon is available on GitHub, Google Play, F-Droid, and the IzzyOnDroid repo. All sources provide the same ` moshidon.apk ` stable release. Older releases are available on the [Releases](https://github.com/LucasGGamerM/moshidon/releases) page.
|
||||
|
||||
That’s one of the reasons why choosing a small, **well-moderated instance is important**. Instance admins and moderators should always make sure to ban abusive users and stop federating with instances who platform them. On well-moderated instances, the Federated timeline can be a welcoming place to meet new people!
|
||||
### How to Install from GitHub
|
||||
[Download the latest stable release from Github](https://github.com/LucasGGamerM/moshidon/releases/latest/download/moshidon.apk) and open it. You might have to accept installing APK files from your browser. Moshidon will automatically check for new updates available on GitHub and offer to download and install them within the app. You can also manually press “Check for updates” at the bottom of the settings page.
|
||||
|
||||
### **Image description viewer**
|
||||
### Nightly Version
|
||||
All ` moshidon-night.apk ` nightly builds can be downloaded on the [Nightly Releases](https://github.com/LucasGGamerM/moshidon-nightly/releases) page. This is an unstable version with an integrated updater for development and testing purposes. If you find any bugs with it, please file a bug report on our [Issues](https://github.com/LucasGGamerM/moshidon/issues) page.
|
||||
|
||||
**Allows you to quickly check whether an image or video has an alternative text attached to it.**
|
||||
|
||||
This is important to **ensure the content you’re sharing is as accessible as possible** to people who can’t see the images and rely on software to read back the provided content descriptions. Thankfully, it’s quite common for people on the Fediverse to provide such alt texts, and hopefully things stay this way!
|
||||
|
||||
### **Reminder to add alt text to attached media**
|
||||
|
||||
By default, Moshidon will show a warning to add alt text if your post has any attachments without any alt text. This is for better accessibility, and it can easily be bypassed and disabled in settings.
|
||||
|
||||
### **Pinning posts**
|
||||
|
||||
**This lets you can highlight important posts on your profile. A dedicated “Pinned” tab in people’s profiles shows all the posts they pinned.**
|
||||
|
||||
On the Fediverse, it’s quite common for people to pin posts they want others to read before following them. You can pin/unpin posts yourself by clicking the `⋯` button in the top right corner of your posts.
|
||||
|
||||
### **Bookmarks**
|
||||
|
||||
**They allow for quickly saving posts and viewing them through the Bookmarks button on the top right of your profile.**
|
||||
|
||||
To bookmark a post, press the button between the Favorite and Share buttons on the bottom of the post. Bookmarks are saved privately, so the post authors won’t know you saved their post – the list of bookmarked posts is only visible to you.
|
||||
|
||||
## Installation
|
||||
|
||||
**Press the download button above to download the APK. Open the downloaded file on your Android device to install it. Moshidon will automatically notify you about new updates inside the app.**
|
||||
|
||||
To install this app on your Android device, download the [latest release from GitHub](https://github.com/LucasGGamerM/moshidon/releases/latest/download/moshidon.apk) and open it. You might have to accept installing APK files from your browser when trying to install it. You can also take a look at all releases on the [Releases](https://github.com/LucasGGamerM/moshidon/releases) page.
|
||||
|
||||
Moshidon makes use of [Mastodon for Android](https://github.com/mastodon/mastodon-android)’s automatic update checker. Moshidon will check for new updates available on GitHub and offer to download and install them. You can also manually press “Check for updates” at the bottom of the settings page!
|
||||
|
||||
Moshidon is also available in [IzzyOnDroid repo](https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.moshinda), compatible with all F-Droid clients. The APK provided here is the same as the one included in the Releases.
|
||||
|
||||
## Release variants
|
||||
|
||||
### Stable variant
|
||||
|
||||
All stable version downloads can be found on the [Releases](https://github.com/LucasGGamerM/moshidon/releases) page.
|
||||
|
||||
**`moshidon.apk`**
|
||||
|
||||
Variant with an integrated updater. If you download Moshidon from here (and not from an app store), just download the regular `moshidon.apk`.
|
||||
|
||||
### Nightly variant
|
||||
|
||||
All nightly builds can be downloaded at [Nightly Releases](https://github.com/LucasGGamerM/moshidon-nightly/releases) page.
|
||||
|
||||
**`moshidon-nightly.apk`**
|
||||
|
||||
Unstable variant with an integrated updater. It's for development and testing purposes. If you find any bugs with it, please file a bug report at our [issues](https://github.com/LucasGGamerM/moshidon/issues) page.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Detailed changes
|
||||
|
||||
### Features
|
||||
|
||||
* [Adding the ability to view other server's local timelines](https://github.com/LucasGGamerM/moshidon/tree/feature/local-timelines)
|
||||
* [Adding the ability to load followers and following from remote instance](https://github.com/LucasGGamerM/moshidon/tree/feature/remote-followers)
|
||||
* [Adding the ability to have filtered posts show with a warning](https://github.com/LucasGGamerM/moshidon/tree/feature/filters_again)
|
||||
* [Add “Unlisted” as a post visibility option](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/enable-unlisted)
|
||||
([Pull request](https://github.com/mastodon/mastodon-android/pull/103))
|
||||
* Adding a useful private profile note box
|
||||
* Auto hiding the compose button on scroll
|
||||
* Adding the ability to remind yourself to add alt text to images
|
||||
* An indicator for if an image has alt text or not
|
||||
* Adding the ability to have drafts
|
||||
* Also adding the ability to view announcements from your instance
|
||||
* Adding the ability to post for local timeline only (Only on instances that support it!)
|
||||
* [Add image description button and viewer](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/display-alt-text) ([Pull request](https://github.com/mastodon/mastodon-android/pull/129))
|
||||
* [Implement pinning posts and displaying pinned posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/pin-posts) ([Pull request](https://github.com/mastodon/mastodon-android/pull/140))
|
||||
* [Implement deleting and re-drafting](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/delete-redraft) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/21))
|
||||
* [Implement a bookmark button and list](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/bookmarks) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/22))
|
||||
* [Add “Check for update” button in addition to integrated update checker](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/check-for-update-button)
|
||||
* [Add “Mark media as sensitive” option](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/mark-media-as-sensitive)
|
||||
* [Add settings to hide replies and reposts from the timeline](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/filter-home-timeline) ([Pull request](https://github.com/mastodon/mastodon-android/pull/317))
|
||||
* [Follow and unfollow hashtags](https://github.com/sk22/megalodon/commit/7d38f031f197aa6cefaf53e39d929538689c1e4e) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/233))
|
||||
* [Notification bell for posts](https://github.com/sk22/megalodon/commit/b166ca705eb9169025ef32bbe6315b42491b57ea) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/81))
|
||||
* [Viewing lists and adding/removing users from lists](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:list-timeline-views) based on [@obstsalatschuessel](https://github.com/obstsalatschuessel)'s [Pull request](https://github.com/mastodon/mastodon-android/pull/286)
|
||||
* [List favorited posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/favs-list)
|
||||
* [Accept/reject follow requests](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/follow-requests)
|
||||
* [Display content warning title above text](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/cw-above-text)
|
||||
* [Add notifications tab for posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/posts-notifications-tab)
|
||||
* [Show visibility of original post when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/display-reply-visibility)
|
||||
* [Clickable reply/boost line above posts](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:clickable-boost-reply-line)
|
||||
* [Clickable reply line while replying to open original post](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/clickable-reply-line-compose)
|
||||
|
||||
|
||||
### Behavior
|
||||
|
||||
* Allow for confirmation before reblogging
|
||||
* Adding a bottom option for the publish button, allowing for easier use on larger screens!
|
||||
* [Make back button return to the home tab before exiting the app](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/back-returns-home) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/118))
|
||||
* [Always preserve content warnings when replying](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/always-preserve-cw) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/113))
|
||||
* [Display full image when adding image description](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/compose-image-description-full-image) ([Pull request](https://github.com/mastodon/mastodon-android/pull/182))
|
||||
* [Set spoiler height independently to content height](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:spoiler-height-independent) ([Closes issue](https://github.com/mastodon/mastodon-android/issues/166))
|
||||
* [Option to hide interaction numbers](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:settings/hide-interaction-numbers)
|
||||
* [Option to always reveal content warnings](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:feature/cw-above-text)
|
||||
* [Option to disable scrolling title bars](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:settings/disable-marquee)
|
||||
|
||||
|
||||
### Visual
|
||||
|
||||
* [Custom extended footer redesign](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:compact-extended-footer)
|
||||
* [Improvements to the true black mode](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:true-black-improvements)
|
||||
* [Profile header tweaks](https://github.com/mastodon/mastodon-android/compare/master...sk22:megalodon:ui/profile-header-tweaks)
|
||||
|
||||
|
||||
## Building
|
||||
## Building & Contributing
|
||||
|
||||
As this app is using Java 17 features, you need JDK 17 or newer to build it. Other than that, everything is pretty standard. You can either import the project into Android Studio and build it from there, or run the following command in the project directory:
|
||||
|
||||
@@ -191,14 +97,13 @@ As this app is using Java 17 features, you need JDK 17 or newer to build it. Oth
|
||||
|
||||
This project is released under the [GPL-3 License](./LICENSE).
|
||||
|
||||
## Links
|
||||
## Contact & Support
|
||||
|
||||
**<a rel="me" href="https://floss.social/@moshidon">@moshidon@floss.social</a>**
|
||||
|
||||
[Official Matrix Chatroom](https://matrix.to/#/#moshidon:floss.social)
|
||||
|
||||
[F.A.Q](FAQ.md)
|
||||
|
||||
[Official matrix chatroom:](https://matrix.to/#/#moshidon:floss.social) https://matrix.to/#/#moshidon:floss.social
|
||||
[Moshidon Roadmap](https://github.com/users/LucasGGamerM/projects/1)
|
||||
|
||||
[Moshidon roadmap](https://github.com/users/LucasGGamerM/projects/1)
|
||||
|
||||
<a rel="me" href="https://floss.social/@moshidon">@moshidon<wbr>@floss.social</a>
|
||||
|
||||
---
|
||||
|
||||
24
build.gradle
24
build.gradle
@@ -1,23 +1,3 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven {
|
||||
url "https://www.jitpack.io"
|
||||
content {
|
||||
includeModule 'com.github.UnifiedPush', 'android-connector'
|
||||
}
|
||||
}
|
||||
mavenLocal()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.0.0'
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.2" apply false
|
||||
}
|
||||
@@ -17,7 +17,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
# Automatically convert third-party libraries to use AndroidX
|
||||
android.enableJetifier=false
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=false
|
||||
org.gradle.configuration-cache=true
|
||||
4
gradle/wrapper/gradle-wrapper.properties
vendored
4
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,7 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=e111cb9948407e26351227dabce49822fb88c37ee72f1d1582a69c68af2e702f
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
|
||||
distributionSha256Sum=57dafb5c2622c6cc08b993c85b7c06956a2f53536432a30ead46166dbca0f1e9
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -15,9 +15,9 @@ android {
|
||||
archivesBaseName = "moshidon"
|
||||
applicationId "org.joinmastodon.android.moshinda"
|
||||
minSdk 23
|
||||
targetSdk 33
|
||||
versionCode 105
|
||||
versionName "2.3.0+fork.105.moshinda"
|
||||
targetSdk 34
|
||||
versionCode 108
|
||||
versionName "2.3.0+fork.108.moshinda"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
resourceConfigurations += ['ar-rSA', 'ar-rDZ', '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', 'kab', 'ko-rKR', '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']
|
||||
}
|
||||
@@ -102,9 +102,14 @@ android {
|
||||
shrinkResources true
|
||||
versionNameSuffix '-play'
|
||||
}
|
||||
githubRelease { initWith release }
|
||||
githubRelease {
|
||||
initWith release
|
||||
versionNameSuffix '-github'
|
||||
}
|
||||
fdroidRelease {
|
||||
initWith release
|
||||
// The F-droid build system doesn't like this at all for some reason.
|
||||
// versionNameSuffix '-fdroid'
|
||||
// signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
package org.joinmastodon.android.utils;
|
||||
|
||||
import static org.joinmastodon.android.model.FilterAction.*;
|
||||
import static org.joinmastodon.android.model.FilterContext.*;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
|
||||
import org.joinmastodon.android.model.Attachment;
|
||||
import org.joinmastodon.android.model.LegacyFilter;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
public class StatusFilterPredicateTest {
|
||||
|
||||
private static final LegacyFilter hideMeFilter = new LegacyFilter(), warnMeFilter = new LegacyFilter();
|
||||
private static final List<LegacyFilter> allFilters = List.of(hideMeFilter, warnMeFilter);
|
||||
|
||||
private static final Status
|
||||
hideInHomePublic = Status.ofFake(null, "hide me, please", Instant.now()),
|
||||
warnInHomePublic = Status.ofFake(null, "display me with a warning", Instant.now()),
|
||||
noAltText = Status.ofFake(null, "display me with a warning", Instant.now()),
|
||||
withAltText = Status.ofFake(null, "display me with a warning", Instant.now());
|
||||
|
||||
static {
|
||||
hideMeFilter.phrase = "hide me";
|
||||
hideMeFilter.filterAction = HIDE;
|
||||
hideMeFilter.context = EnumSet.of(PUBLIC, HOME);
|
||||
|
||||
warnMeFilter.phrase = "warning";
|
||||
warnMeFilter.filterAction = WARN;
|
||||
warnMeFilter.context = EnumSet.of(PUBLIC, HOME);
|
||||
|
||||
// noAltText.mediaAttachments = Attachment.createFakeAttachments("fakeurl", new ColorDrawable());
|
||||
// withAltText.mediaAttachments = Attachment.createFakeAttachments("fakeurl", new ColorDrawable());
|
||||
// for (Attachment mediaAttachment : withAltText.mediaAttachments) {
|
||||
// mediaAttachment.description = "Alt Text";
|
||||
// }
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHide() {
|
||||
assertFalse("should not pass because matching filter applies to given context",
|
||||
new StatusFilterPredicate(allFilters, HOME).test(hideInHomePublic));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHideRegardlessOfContext() {
|
||||
assertTrue("filters without context should always pass",
|
||||
new StatusFilterPredicate(allFilters, null).test(hideInHomePublic));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHideInDifferentContext() {
|
||||
assertTrue("should pass because matching filter does not apply to given context",
|
||||
new StatusFilterPredicate(allFilters, THREAD).test(hideInHomePublic));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHideWithWarningText() {
|
||||
assertTrue("should pass because matching filter is for warnings",
|
||||
new StatusFilterPredicate(allFilters, HOME).test(warnInHomePublic));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWarn() {
|
||||
assertFalse("should not pass because filter applies to given context",
|
||||
new StatusFilterPredicate(allFilters, HOME, WARN).test(warnInHomePublic));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWarnRegardlessOfContext() {
|
||||
assertTrue("filters without context should always pass",
|
||||
new StatusFilterPredicate(allFilters, null, WARN).test(warnInHomePublic));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWarnInDifferentContext() {
|
||||
assertTrue("should pass because filter does not apply to given context",
|
||||
new StatusFilterPredicate(allFilters, THREAD, WARN).test(warnInHomePublic));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testWarnWithHideText() {
|
||||
assertTrue("should pass because matching filter is for hiding",
|
||||
new StatusFilterPredicate(allFilters, HOME, WARN).test(hideInHomePublic));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAltTextFilterNoPass() {
|
||||
assertFalse("should not pass because of no alt text",
|
||||
new StatusFilterPredicate(allFilters, HOME).test(noAltText));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAltTextFilterPass() {
|
||||
assertTrue("should pass because of alt text",
|
||||
new StatusFilterPredicate(allFilters, HOME).test(withAltText));
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,13 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{
|
||||
if(state==UpdateState.DOWNLOADING)
|
||||
throw new IllegalStateException();
|
||||
DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
|
||||
MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
|
||||
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){
|
||||
MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), Context.RECEIVER_EXPORTED);
|
||||
}else{
|
||||
MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
|
||||
}
|
||||
|
||||
downloadID=dm.enqueue(
|
||||
new DownloadManager.Request(Uri.parse(getPrefs().getString("apkURL", null)))
|
||||
.setDestinationUri(Uri.fromFile(getUpdateApkFile()))
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
<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.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
|
||||
@@ -25,10 +26,6 @@
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TRANSLATE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="http"/>
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@@ -85,6 +82,15 @@
|
||||
<data android:mimeType="*/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".ChooseAccountForComposeActivity" android:exported="true" android:configChanges="orientation|screenSize" android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/TransparentDialog">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.CHOOSER"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
<data android:mimeType="*/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
<service android:name=".AudioPlayerService" android:foregroundServiceType="mediaPlayback"/>
|
||||
|
||||
@@ -110,13 +116,11 @@
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="org.joinmastodon.android.utils.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
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>
|
||||
|
||||
@@ -20,13 +20,16 @@ cachapa.xyz
|
||||
canary.fedinuke.example.com
|
||||
catgirl.life
|
||||
cawfee.club
|
||||
childlove.space
|
||||
childlove.su
|
||||
clew.lol
|
||||
clubcyberia.co
|
||||
contrapointsfan.club
|
||||
cottoncandy.cafe
|
||||
crlf.ninja
|
||||
crucible.world
|
||||
cum.camp
|
||||
cum.salon
|
||||
cunnyborea.space
|
||||
decayable.ink
|
||||
dembased.xyz
|
||||
detroitriotcity.com
|
||||
@@ -34,10 +37,12 @@ djsumdog.com
|
||||
eientei.org
|
||||
eveningzoo.club
|
||||
fluf.club
|
||||
foxgirl.lol
|
||||
freak.university
|
||||
freeatlantis.com
|
||||
freespeechextremist.com
|
||||
froth.zone
|
||||
fsebugoutzone.org
|
||||
gameliberty.club
|
||||
gearlandia.haus
|
||||
genderheretics.xyz
|
||||
@@ -49,6 +54,7 @@ goyim.app
|
||||
h5q.net
|
||||
haeder.net
|
||||
handholding.io
|
||||
harpy.faith
|
||||
hitchhiker.social
|
||||
iddqd.social
|
||||
kitsunemimi.club
|
||||
@@ -56,15 +62,14 @@ kiwifarms.cc
|
||||
kurosawa.moe
|
||||
kyaruc.moe
|
||||
leafposter.club
|
||||
lewdieheaven.com
|
||||
liberdon.com
|
||||
ligma.pro
|
||||
loli.church
|
||||
lolicon.rocks
|
||||
lolison.network
|
||||
lolison.top
|
||||
lovingexpressions.net
|
||||
makemysarcophagus.com
|
||||
marsey.moe
|
||||
mastinator.com
|
||||
merovingian.club
|
||||
midwaytrades.com
|
||||
@@ -74,17 +79,21 @@ mouse.services
|
||||
mugicha.club
|
||||
narrativerry.xyz
|
||||
natehiggers.online
|
||||
nationalist.social
|
||||
needs.vodka
|
||||
neenster.org
|
||||
nicecrew.digital
|
||||
nightshift.social
|
||||
nnia.space
|
||||
noagendasocial.com
|
||||
noagendasocial.nl
|
||||
noagendatube.com
|
||||
noauthority.social
|
||||
nobodyhasthe.biz
|
||||
norwoodzero.net
|
||||
nyanide.com
|
||||
onionfarms.org
|
||||
parcero.bond
|
||||
pawlicker.com
|
||||
pawoo.net
|
||||
pedo.school
|
||||
@@ -129,9 +138,11 @@ sonichu.com
|
||||
spinster.xyz
|
||||
springbo.cc
|
||||
strelizia.net
|
||||
taihou.website
|
||||
tastingtraffic.net
|
||||
teci.world
|
||||
theapex.social
|
||||
theblab.org
|
||||
thechimp.zone
|
||||
thenobody.club
|
||||
thepostearthdestination.com
|
||||
@@ -139,9 +150,11 @@ tkammer.de
|
||||
trumpislovetrumpis.life
|
||||
truthsocial.co.in
|
||||
usualsuspects.lol
|
||||
vampiremaid.cafe
|
||||
varishangout.net
|
||||
vtuberfan.social
|
||||
wolfgirl.bar
|
||||
xn--p1abe3d.xn--80asehdb
|
||||
yggdrasil.social
|
||||
youjo.love
|
||||
zhub.link
|
||||
@@ -88,8 +88,13 @@ public class AudioPlayerService extends Service{
|
||||
nm=getSystemService(NotificationManager.class);
|
||||
// registerReceiver(receiver, new IntentFilter(Intent.ACTION_MEDIA_BUTTON));
|
||||
registerReceiver(receiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
|
||||
registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE));
|
||||
registerReceiver(receiver, new IntentFilter(ACTION_STOP));
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){
|
||||
registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE), RECEIVER_EXPORTED);
|
||||
registerReceiver(receiver, new IntentFilter(ACTION_STOP), RECEIVER_EXPORTED);
|
||||
}else{
|
||||
registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE));
|
||||
registerReceiver(receiver, new IntentFilter(ACTION_STOP));
|
||||
}
|
||||
instance=this;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.joinmastodon.android;
|
||||
|
||||
import android.app.Fragment;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.fragments.ComposeFragment;
|
||||
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import me.grishka.appkit.FragmentStackActivity;
|
||||
|
||||
public class ChooseAccountForComposeActivity extends FragmentStackActivity{
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState){
|
||||
UiUtils.setUserPreferredTheme(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
if (savedInstanceState == null && Objects.equals(getIntent().getAction(), Intent.ACTION_CHOOSER)) {
|
||||
AccountSessionManager.getInstance().maybeUpdateLocalInfo();
|
||||
List<AccountSession> sessions=AccountSessionManager.getInstance().getLoggedInAccounts();
|
||||
if (sessions.isEmpty()){
|
||||
Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
} else if (sessions.size() > 1) {
|
||||
AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, R.drawable.ic_fluent_compose_28_regular,
|
||||
R.string.choose_account, null, false);
|
||||
sheet.setOnClick((accountId, open) -> {
|
||||
openComposeFragment(accountId);
|
||||
});
|
||||
sheet.show();
|
||||
} else if (sessions.size() == 1) {
|
||||
openComposeFragment(sessions.get(0).getID());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void openComposeFragment(String accountID){
|
||||
getWindow().setBackgroundDrawable(null);
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
Fragment fragment=new ComposeFragment();
|
||||
fragment.setArguments(args);
|
||||
showFragmentClearingBackStack(fragment);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,11 @@ public class ExternalShareActivity extends FragmentStackActivity{
|
||||
Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
} else if (isOpenable || sessions.size() > 1) {
|
||||
AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, true, isOpenable);
|
||||
AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, R.drawable.ic_fluent_share_28_regular,
|
||||
isOpenable
|
||||
? R.string.sk_external_share_or_open_title
|
||||
: R.string.sk_external_share_title,
|
||||
null, isOpenable);
|
||||
sheet.setOnClick((accountId, open) -> {
|
||||
if (open && text.isPresent()) {
|
||||
BiConsumer<Class<? extends Fragment>, Bundle> callback = (clazz, args) -> {
|
||||
@@ -82,6 +86,8 @@ public class ExternalShareActivity extends FragmentStackActivity{
|
||||
}
|
||||
|
||||
private void openComposeFragment(String accountID){
|
||||
AccountSession session=AccountSessionManager.get(accountID);
|
||||
UiUtils.setUserPreferredTheme(this, session);
|
||||
getWindow().setBackgroundDrawable(null);
|
||||
|
||||
Intent intent=getIntent();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,6 @@ public class GlobalUserPreferences{
|
||||
public static boolean spectatorMode;
|
||||
public static boolean autoHideFab;
|
||||
public static boolean allowRemoteLoading;
|
||||
public static boolean forwardReportDefault;
|
||||
public static AutoRevealMode autoRevealEqualSpoilers;
|
||||
public static boolean disableM3PillActiveIndicator;
|
||||
public static boolean showNavigationLabels;
|
||||
@@ -78,10 +77,10 @@ public class GlobalUserPreferences{
|
||||
public static boolean hapticFeedback;
|
||||
public static boolean replyLineAboveHeader;
|
||||
public static boolean swapBookmarkWithBoostAction;
|
||||
public static boolean loadRemoteAccountFollowers;
|
||||
public static boolean mentionRebloggerAutomatically;
|
||||
public static boolean showPostsWithoutAlt;
|
||||
public static boolean showMediaPreview;
|
||||
public static boolean removeTrackingParams;
|
||||
|
||||
public static SharedPreferences getPrefs(){
|
||||
return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE);
|
||||
@@ -137,7 +136,6 @@ public class GlobalUserPreferences{
|
||||
autoHideFab=prefs.getBoolean("autoHideFab", true);
|
||||
allowRemoteLoading=prefs.getBoolean("allowRemoteLoading", true);
|
||||
autoRevealEqualSpoilers=AutoRevealMode.valueOf(prefs.getString("autoRevealEqualSpoilers", AutoRevealMode.THREADS.name()));
|
||||
forwardReportDefault=prefs.getBoolean("forwardReportDefault", true);
|
||||
disableM3PillActiveIndicator=prefs.getBoolean("disableM3PillActiveIndicator", false);
|
||||
showNavigationLabels=prefs.getBoolean("showNavigationLabels", true);
|
||||
displayPronounsInTimelines=prefs.getBoolean("displayPronounsInTimelines", true);
|
||||
@@ -160,10 +158,10 @@ public class GlobalUserPreferences{
|
||||
confirmBeforeReblog=prefs.getBoolean("confirmBeforeReblog", false);
|
||||
hapticFeedback=prefs.getBoolean("hapticFeedback", true);
|
||||
swapBookmarkWithBoostAction=prefs.getBoolean("swapBookmarkWithBoostAction", false);
|
||||
loadRemoteAccountFollowers=prefs.getBoolean("loadRemoteAccountFollowers", true);
|
||||
mentionRebloggerAutomatically=prefs.getBoolean("mentionRebloggerAutomatically", false);
|
||||
showPostsWithoutAlt=prefs.getBoolean("showPostsWithoutAlt", true);
|
||||
showMediaPreview=prefs.getBoolean("showMediaPreview", true);
|
||||
removeTrackingParams=prefs.getBoolean("removeTrackingParams", true);
|
||||
|
||||
theme=ThemePreference.values()[prefs.getInt("theme", 0)];
|
||||
|
||||
@@ -213,7 +211,6 @@ public class GlobalUserPreferences{
|
||||
.putBoolean("autoHideFab", autoHideFab)
|
||||
.putBoolean("allowRemoteLoading", allowRemoteLoading)
|
||||
.putString("autoRevealEqualSpoilers", autoRevealEqualSpoilers.name())
|
||||
.putBoolean("forwardReportDefault", forwardReportDefault)
|
||||
.putBoolean("disableM3PillActiveIndicator", disableM3PillActiveIndicator)
|
||||
.putBoolean("showNavigationLabels", showNavigationLabels)
|
||||
.putBoolean("displayPronounsInTimelines", displayPronounsInTimelines)
|
||||
@@ -232,7 +229,6 @@ public class GlobalUserPreferences{
|
||||
.putBoolean("replyLineAboveHeader", replyLineAboveHeader)
|
||||
.putBoolean("confirmBeforeReblog", confirmBeforeReblog)
|
||||
.putBoolean("swapBookmarkWithBoostAction", swapBookmarkWithBoostAction)
|
||||
.putBoolean("loadRemoteAccountFollowers", loadRemoteAccountFollowers)
|
||||
.putBoolean("hapticFeedback", hapticFeedback)
|
||||
.putBoolean("mentionRebloggerAutomatically", mentionRebloggerAutomatically)
|
||||
.putBoolean("showDividers", showDividers)
|
||||
@@ -240,6 +236,7 @@ public class GlobalUserPreferences{
|
||||
.putBoolean("enableDeleteNotifications", enableDeleteNotifications)
|
||||
.putBoolean("showPostsWithoutAlt", showPostsWithoutAlt)
|
||||
.putBoolean("showMediaPreview", showMediaPreview)
|
||||
.putBoolean("removeTrackingParams", removeTrackingParams)
|
||||
|
||||
.apply();
|
||||
}
|
||||
|
||||
@@ -111,8 +111,6 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis
|
||||
fragment.setArguments(args);
|
||||
showFragmentClearingBackStack(fragment);
|
||||
}
|
||||
}else if(intent.getBooleanExtra("compose", false)){
|
||||
showCompose();
|
||||
}else if(Intent.ACTION_VIEW.equals(intent.getAction())){
|
||||
handleURL(intent.getData(), null);
|
||||
}/*else if(intent.hasExtra(PackageInstaller.EXTRA_STATUS) && GithubSelfUpdater.needSelfUpdating()){
|
||||
@@ -187,17 +185,6 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis
|
||||
showFragment(fragment);
|
||||
}
|
||||
|
||||
private void showCompose(){
|
||||
AccountSession session=AccountSessionManager.getInstance().getLastActiveAccount();
|
||||
if(session==null || !session.activated)
|
||||
return;
|
||||
ComposeFragment compose=new ComposeFragment();
|
||||
Bundle composeArgs=new Bundle();
|
||||
composeArgs.putString("account", session.getID());
|
||||
compose.setArguments(composeArgs);
|
||||
showFragment(compose);
|
||||
}
|
||||
|
||||
private void maybeRequestNotificationsPermission(){
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED){
|
||||
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 100);
|
||||
@@ -343,8 +330,6 @@ public class MainActivity extends FragmentStackActivity implements ProvidesAssis
|
||||
}catch(BadParcelableException x){
|
||||
Log.w(TAG, x);
|
||||
}
|
||||
} else if (intent.getBooleanExtra("compose", false)){
|
||||
showCompose();
|
||||
} else if (Intent.ACTION_VIEW.equals(intent.getAction())){
|
||||
handleURL(intent.getData(), null);
|
||||
} else {
|
||||
|
||||
@@ -25,7 +25,7 @@ public class MastodonApp extends Application{
|
||||
params.diskCacheSize=100*1024*1024;
|
||||
params.maxMemoryCacheSize=Integer.MAX_VALUE;
|
||||
ImageCache.setParams(params);
|
||||
NetworkUtils.setUserAgent("MastodonAndroid/"+BuildConfig.VERSION_NAME);
|
||||
NetworkUtils.setUserAgent("MoshidonAndroid/"+BuildConfig.VERSION_NAME);
|
||||
|
||||
PushSubscriptionManager.tryRegisterFCM();
|
||||
GlobalUserPreferences.load();
|
||||
|
||||
@@ -101,7 +101,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){
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,11 @@ public class MastodonAPIController{
|
||||
final boolean isBad = host == null || badDomains.stream().anyMatch(h -> h.equalsIgnoreCase(host) || host.toLowerCase().endsWith("." + h));
|
||||
thread.postRunnable(()->{
|
||||
try{
|
||||
// if (isBad) throw new IllegalArgumentException();
|
||||
if(isBad){
|
||||
Log.i(TAG, "submitRequest: refusing to connect to bad domain: " + host);
|
||||
throw new IllegalArgumentException("Failed to connect to domain");
|
||||
}
|
||||
|
||||
if(req.canceled)
|
||||
return;
|
||||
Request.Builder builder=new Request.Builder()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.joinmastodon.android.api.requests.timelines;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class GetTrendingLinksTimeline extends MastodonAPIRequest<List<Status>>{
|
||||
public GetTrendingLinksTimeline(@NonNull String url, String maxID, String minID, int limit){
|
||||
super(HttpMethod.GET, "/timelines/link/", new TypeToken<>(){});
|
||||
addQueryParameter("url", url);
|
||||
if(maxID!=null)
|
||||
addQueryParameter("max_id", maxID);
|
||||
if(minID!=null)
|
||||
addQueryParameter("min_id", minID);
|
||||
if(limit>0)
|
||||
addQueryParameter("limit", ""+limit);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import org.joinmastodon.android.api.requests.markers.SaveMarkers;
|
||||
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
|
||||
import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.AltTextFilter;
|
||||
import org.joinmastodon.android.model.Application;
|
||||
import org.joinmastodon.android.model.FilterAction;
|
||||
import org.joinmastodon.android.model.FilterContext;
|
||||
@@ -37,6 +38,7 @@ import org.joinmastodon.android.utils.ObjectIdComparator;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -313,8 +315,11 @@ public class AccountSession{
|
||||
return true;
|
||||
// Even with server-side filters, clients are expected to remove statuses that match a filter that hides them
|
||||
if(getLocalPreferences().serverSideFiltersSupported){
|
||||
// Moshidon: this code path in CustomLocalTimelines makes the app crash, so this check is here
|
||||
if (s.filtered == null)
|
||||
return false;
|
||||
for(FilterResult filter : s.filtered){
|
||||
if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE)
|
||||
if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE && filter.filter.context.contains(context))
|
||||
return true;
|
||||
}
|
||||
}else if(wordFilters!=null){
|
||||
@@ -326,6 +331,21 @@ public class AccountSession{
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<FilterResult> getClientSideFilters(Status status) {
|
||||
List<FilterResult> filters = new ArrayList<>();
|
||||
|
||||
// filter post that have no alt text
|
||||
// it only applies when activated in the settings
|
||||
AltTextFilter altTextFilter=new AltTextFilter(FilterAction.WARN, EnumSet.allOf(FilterContext.class));
|
||||
if(altTextFilter.matches(status)){
|
||||
FilterResult filterResult=new FilterResult();
|
||||
filterResult.filter=altTextFilter;
|
||||
filterResult.keywordMatches=List.of();
|
||||
filters.add(filterResult);
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
public void updateAccountInfo(){
|
||||
AccountSessionManager.getInstance().updateSessionLocalInfo(this);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.joinmastodon.android.api.session;
|
||||
|
||||
import static org.unifiedpush.android.connector.UnifiedPush.getDistributor;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.ComponentName;
|
||||
@@ -17,7 +15,7 @@ import android.util.Log;
|
||||
|
||||
import org.joinmastodon.android.BuildConfig;
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.ChooseAccountForComposeActivity;
|
||||
import org.joinmastodon.android.MainActivity;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.R;
|
||||
@@ -491,15 +489,19 @@ public class AccountSessionManager{
|
||||
if(Build.VERSION.SDK_INT<26)
|
||||
return;
|
||||
ShortcutManager sm=MastodonApp.context.getSystemService(ShortcutManager.class);
|
||||
if((sm.getDynamicShortcuts().isEmpty() || BuildConfig.DEBUG) && !sessions.isEmpty()){
|
||||
|
||||
Intent intent = new Intent(MastodonApp.context, ChooseAccountForComposeActivity.class)
|
||||
.setAction(Intent.ACTION_CHOOSER)
|
||||
.putExtra("compose", true);
|
||||
|
||||
// This was done so that the old shortcuts get updated to the new implementation.
|
||||
if((sm.getDynamicShortcuts().isEmpty() || sm.getDynamicShortcuts().get(0).getIntent() != intent || BuildConfig.DEBUG ) && !sessions.isEmpty()){
|
||||
// There are no shortcuts, but there are accounts. Add a compose shortcut.
|
||||
ShortcutInfo info=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))
|
||||
.setIntent(new Intent(MastodonApp.context, MainActivity.class)
|
||||
.setAction(Intent.ACTION_MAIN)
|
||||
.putExtra("compose", true))
|
||||
.setIntent(intent)
|
||||
.build();
|
||||
sm.setDynamicShortcuts(Collections.singletonList(info));
|
||||
}else if(sessions.isEmpty()){
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Pair;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowInsets;
|
||||
@@ -23,19 +24,18 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.CacheController;
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
|
||||
import org.joinmastodon.android.api.requests.polls.SubmitPollVote;
|
||||
import org.joinmastodon.android.api.requests.statuses.AkkomaTranslateStatus;
|
||||
import org.joinmastodon.android.api.requests.statuses.TranslateStatus;
|
||||
import org.joinmastodon.android.api.session.AccountLocalPreferences;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.events.PollUpdatedEvent;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.AkkomaTranslation;
|
||||
import org.joinmastodon.android.model.DisplayItemsParent;
|
||||
import org.joinmastodon.android.model.Notification;
|
||||
import org.joinmastodon.android.model.Poll;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
@@ -70,7 +70,6 @@ import org.joinmastodon.android.utils.TypedObjectPool;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -79,14 +78,9 @@ import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
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.fragments.BaseRecyclerFragment;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
|
||||
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
|
||||
@@ -664,11 +658,30 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
}
|
||||
|
||||
public void onPollViewResultsButtonClick(PollFooterStatusDisplayItem.Holder holder, boolean shown){
|
||||
for(int i=0;i<list.getChildCount();i++){
|
||||
if(list.getChildViewHolder(list.getChildAt(i)) instanceof PollOptionStatusDisplayItem.Holder item && item.getItemID().equals(holder.getItemID())){
|
||||
item.showResults(shown);
|
||||
int firstOptionIndex=-1, footerIndex=-1;
|
||||
int i=0;
|
||||
for(StatusDisplayItem item:displayItems){
|
||||
if(item.parentID.equals(holder.getItemID())){
|
||||
if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
|
||||
firstOptionIndex=i;
|
||||
}else if(item instanceof PollFooterStatusDisplayItem){
|
||||
footerIndex=i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if(firstOptionIndex==-1 || footerIndex==-1)
|
||||
throw new IllegalStateException("Can't find all poll items in displayItems");
|
||||
List<StatusDisplayItem> pollItems=displayItems.subList(firstOptionIndex, footerIndex+1);
|
||||
|
||||
for(StatusDisplayItem item:pollItems){
|
||||
if (item instanceof PollOptionStatusDisplayItem) {
|
||||
((PollOptionStatusDisplayItem) item).isAnimating=true;
|
||||
((PollOptionStatusDisplayItem) item).showResults=shown;
|
||||
adapter.notifyItemRangeChanged(firstOptionIndex, pollItems.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void submitPollVote(String parentID, String pollID, List<Integer> choices){
|
||||
@@ -696,6 +709,42 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
toggleSpoiler(status, isForQuote, holder.getItemID());
|
||||
}
|
||||
|
||||
public void updateStatusWithQuote(DisplayItemsParent parent) {
|
||||
Pair<Integer, Integer> items=findAllItemsOfParent(parent);
|
||||
if (items==null)
|
||||
return;
|
||||
|
||||
// Only StatusListFragments/NotificationsListFragments can display status with quotes
|
||||
assert (this instanceof StatusListFragment) || (this instanceof NotificationsListFragment);
|
||||
List<StatusDisplayItem> oldItems = displayItems.subList(items.first, items.second+1);
|
||||
List<StatusDisplayItem> newItems=this.buildDisplayItems((T) parent);
|
||||
int prevSize=oldItems.size();
|
||||
oldItems.clear();
|
||||
displayItems.addAll(items.first, newItems);
|
||||
|
||||
// Update the cache
|
||||
final CacheController cache=AccountSessionManager.get(accountID).getCacheController();
|
||||
if (parent instanceof Status) {
|
||||
cache.updateStatus((Status) parent);
|
||||
} else if (parent instanceof Notification) {
|
||||
cache.updateNotification((Notification) parent);
|
||||
}
|
||||
|
||||
adapter.notifyItemRangeRemoved(items.first, prevSize);
|
||||
adapter.notifyItemRangeInserted(items.first, newItems.size());
|
||||
}
|
||||
|
||||
public void removeStatus(DisplayItemsParent parent) {
|
||||
Pair<Integer, Integer> items=findAllItemsOfParent(parent);
|
||||
if (items==null)
|
||||
return;
|
||||
|
||||
List<StatusDisplayItem> statusDisplayItems = displayItems.subList(items.first, items.second+1);
|
||||
int prevSize=statusDisplayItems.size();
|
||||
statusDisplayItems.clear();
|
||||
adapter.notifyItemRangeRemoved(items.first, prevSize);
|
||||
}
|
||||
|
||||
public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder) {
|
||||
Status status = holder.getItem().status;
|
||||
if(holder.getItem().hasVisibilityToggle) holder.animateVisibilityToggle(false);
|
||||
@@ -731,6 +780,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
displayItems.addAll(index+1, spoilerItem.contentItems);
|
||||
adapter.notifyItemRangeInserted(index+1, spoilerItem.contentItems.size());
|
||||
}else{
|
||||
if(spoilers.size()>1 && !isForQuote && status.quote.spoilerRevealed)
|
||||
toggleSpoiler(status.quote, true, itemID);
|
||||
displayItems.subList(index+1, index+1+spoilerItem.contentItems.size()).clear();
|
||||
adapter.notifyItemRangeRemoved(index+1, spoilerItem.contentItems.size());
|
||||
}
|
||||
@@ -743,19 +794,33 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
list.invalidateItemDecorations();
|
||||
}
|
||||
|
||||
public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable) {
|
||||
public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable, boolean isForQuote) {
|
||||
Status s=holder.getItem().status;
|
||||
if(s.textExpandable!=expandable && list!=null) {
|
||||
s.textExpandable=expandable;
|
||||
HeaderStatusDisplayItem.Holder header=findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class);
|
||||
if(header!=null) header.bindCollapseButton();
|
||||
List<HeaderStatusDisplayItem.Holder> headers=findAllHoldersOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class);
|
||||
if(headers!=null && !headers.isEmpty()){
|
||||
HeaderStatusDisplayItem.Holder header=headers.size() > 1 && isForQuote ? headers.get(1) : headers.get(0);
|
||||
if(header!=null) header.bindCollapseButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void onToggleExpanded(Status status, String itemID) {
|
||||
public void onToggleExpanded(Status status, boolean isForQuote, String itemID) {
|
||||
status.textExpanded = !status.textExpanded;
|
||||
notifyItemChanged(itemID, TextStatusDisplayItem.class);
|
||||
HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
|
||||
// TODO: simplify this to a single case
|
||||
if(!isForQuote)
|
||||
// using the adapter directly to update the item does not work for non-quoted texts
|
||||
notifyItemChanged(itemID, TextStatusDisplayItem.class);
|
||||
else{
|
||||
List<TextStatusDisplayItem.Holder> textItems=findAllHoldersOfType(itemID, TextStatusDisplayItem.Holder.class);
|
||||
TextStatusDisplayItem.Holder text=textItems.size()>1 ? textItems.get(1) : textItems.get(0);
|
||||
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition());
|
||||
}
|
||||
List<HeaderStatusDisplayItem.Holder> headers=findAllHoldersOfType(itemID, HeaderStatusDisplayItem.Holder.class);
|
||||
if (headers.isEmpty())
|
||||
return;
|
||||
HeaderStatusDisplayItem.Holder header=headers.size() > 1 && isForQuote ? headers.get(1) : headers.get(0);
|
||||
if(header!=null) header.animateExpandToggle();
|
||||
else notifyItemChanged(itemID, HeaderStatusDisplayItem.class);
|
||||
}
|
||||
@@ -883,6 +948,23 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
protected Pair<Integer, Integer> findAllItemsOfParent(DisplayItemsParent parent){
|
||||
int startIndex=-1;
|
||||
int endIndex=-1;
|
||||
for(int i=0; i<displayItems.size(); i++){
|
||||
StatusDisplayItem item = displayItems.get(i);
|
||||
if(item.parentID.equals(parent.getID())) {
|
||||
startIndex= startIndex==-1 ? i : startIndex;
|
||||
endIndex=i;
|
||||
}
|
||||
}
|
||||
|
||||
if(startIndex==-1 || endIndex==-1)
|
||||
return null;
|
||||
return Pair.create(startIndex, endIndex);
|
||||
}
|
||||
|
||||
protected <I extends StatusDisplayItem, H extends StatusDisplayItem.Holder<I>> List<H> findAllHoldersOfType(String id, Class<H> type){
|
||||
ArrayList<H> holders=new ArrayList<>();
|
||||
for(int i=0;i<list.getChildCount();i++){
|
||||
|
||||
@@ -65,6 +65,7 @@ import com.twitter.twittertext.TwitterTextEmojiRegex;
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.TweakedFileProvider;
|
||||
import org.joinmastodon.android.api.MastodonErrorResponse;
|
||||
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
|
||||
import org.joinmastodon.android.api.requests.statuses.DeleteStatus;
|
||||
@@ -98,7 +99,7 @@ import org.joinmastodon.android.ui.text.ComposeAutocompleteSpan;
|
||||
import org.joinmastodon.android.ui.text.ComposeHashtagOrMentionSpan;
|
||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
|
||||
import org.joinmastodon.android.utils.FileProvider;
|
||||
import org.joinmastodon.android.utils.Tracking;
|
||||
import org.joinmastodon.android.utils.TransferSpeedTracker;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.viewcontrollers.ComposeAutocompleteViewController;
|
||||
@@ -512,7 +513,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
}
|
||||
|
||||
int typeIndex=contentType.ordinal();
|
||||
contentTypePopup.getMenu().findItem(typeIndex).setChecked(true);
|
||||
if (contentTypePopup.getMenu().findItem(typeIndex) != null)
|
||||
contentTypePopup.getMenu().findItem(typeIndex).setChecked(true);
|
||||
contentTypeBtn.setSelected(typeIndex != ContentType.UNSPECIFIED.ordinal() && typeIndex != ContentType.PLAIN.ordinal());
|
||||
|
||||
autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID);
|
||||
@@ -1174,6 +1176,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
|
||||
private void actuallyPublish(boolean preview){
|
||||
String text=mainEditText.getText().toString();
|
||||
if(GlobalUserPreferences.removeTrackingParams)
|
||||
text=Tracking.cleanUrlsInText(text);
|
||||
CreateStatus.Request req=new CreateStatus.Request();
|
||||
if("bottom".equals(postLang.encoding)){
|
||||
text=new StatusTextEncoder(Bottom::encode).encode(text);
|
||||
@@ -1504,7 +1508,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
private void openCamera() throws IOException {
|
||||
if (getContext().checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
File photoFile = File.createTempFile("img", ".jpg");
|
||||
photoUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", photoFile);
|
||||
photoUri = UiUtils.getFileProviderUri(getContext(), photoFile);
|
||||
|
||||
Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
|
||||
cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);
|
||||
@@ -1662,7 +1666,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
|
||||
}
|
||||
}
|
||||
UiUtils.enablePopupMenuIcons(getActivity(), visibilityPopup);
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI()) m.setGroupDividerEnabled(true);
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI() && !UiUtils.isMagic()) m.setGroupDividerEnabled(true);
|
||||
visibilityPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){
|
||||
@Override
|
||||
public boolean onMenuItemClick(MenuItem item){
|
||||
|
||||
@@ -7,16 +7,14 @@ import android.view.MenuInflater;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
|
||||
import org.joinmastodon.android.model.Filter;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.model.FilterContext;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.model.TimelineDefinition;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.utils.ProvidesAssistContent;
|
||||
import org.joinmastodon.android.utils.StatusFilterPredicate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
|
||||
@@ -53,7 +51,7 @@ public class CustomLocalTimelineFragment extends PinnableStatusListFragment impl
|
||||
if(!result.isEmpty())
|
||||
maxID=result.get(result.size()-1).id;
|
||||
if (getActivity() == null) return;
|
||||
result=result.stream().filter(new StatusFilterPredicate(accountID, FilterContext.PUBLIC)).collect(Collectors.toList());
|
||||
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
|
||||
result.stream().forEach(status -> {
|
||||
status.account.acct += "@"+domain;
|
||||
status.mentions.forEach(mention -> mention.id = null);
|
||||
@@ -82,12 +80,15 @@ public class CustomLocalTimelineFragment extends PinnableStatusListFragment impl
|
||||
|
||||
@Override
|
||||
protected FilterContext getFilterContext() {
|
||||
return null;
|
||||
return FilterContext.PUBLIC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getWebUri(Uri.Builder base) {
|
||||
return Uri.parse(domain);
|
||||
return new Uri.Builder()
|
||||
.scheme("https")
|
||||
.authority(domain)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -396,7 +396,7 @@ public class EditTimelinesFragment extends MastodonRecyclerFragment<TimelineDefi
|
||||
tl.setTitle(name);
|
||||
if(item == null || item.getType()==TimelineDefinition.TimelineType.HASHTAG){
|
||||
tl.setTagOptions(
|
||||
mainHashtag,
|
||||
TextUtils.isEmpty(mainHashtag) ? name : mainHashtag,
|
||||
tagsAny.getChipValues(),
|
||||
tagsAll.getChipValues(),
|
||||
tagsNone.getChipValues(),
|
||||
|
||||
@@ -297,8 +297,8 @@ public class FollowRequestsListFragment extends MastodonRecyclerFragment<FollowR
|
||||
cover.setImageDrawable(image);
|
||||
}else{
|
||||
item.emojiHelper.setImageDrawable(index-2, image);
|
||||
name.invalidate();
|
||||
bio.invalidate();
|
||||
name.setText(name.getText());
|
||||
bio.setText(bio.getText());
|
||||
}
|
||||
if(image instanceof Animatable a && !a.isRunning())
|
||||
a.start();
|
||||
@@ -319,7 +319,18 @@ public class FollowRequestsListFragment extends MastodonRecyclerFragment<FollowR
|
||||
|
||||
private void onFollowRequestButtonClick(View v) {
|
||||
itemView.setHasTransientState(true);
|
||||
UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, accountID, null, v == acceptButton, relationship, rel -> {
|
||||
UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, accountID, null, v == acceptButton, relationship, (Boolean visible) -> {
|
||||
if(v==acceptButton){
|
||||
acceptButton.setTextVisible(!visible);
|
||||
acceptProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
|
||||
acceptButton.setClickable(!visible);
|
||||
}else{
|
||||
rejectButton.setTextVisible(!visible);
|
||||
rejectProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
|
||||
rejectButton.setClickable(!visible);
|
||||
}
|
||||
itemView.setHasTransientState(false);
|
||||
}, rel -> {
|
||||
if(getContext()==null) return;
|
||||
itemView.setHasTransientState(false);
|
||||
relationships.put(item.account.id, rel);
|
||||
|
||||
@@ -101,10 +101,15 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{
|
||||
}
|
||||
|
||||
private void updateMuteState(boolean newMute) {
|
||||
muteMenuItem.setTitle(getString(newMute ? R.string.unmute_user : R.string.mute_user, "#" + hashtag));
|
||||
muteMenuItem.setTitle(getString(newMute ? R.string.unmute_user : R.string.mute_user, "#" + hashtagName));
|
||||
muteMenuItem.setIcon(newMute ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular);
|
||||
}
|
||||
|
||||
private void updateFollowState(boolean following) {
|
||||
followMenuItem.setTitle(getString(following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName));
|
||||
followMenuItem.setIcon(following ? R.drawable.ic_fluent_person_delete_24_filled : R.drawable.ic_fluent_person_add_24_regular);
|
||||
}
|
||||
|
||||
private void showMuteDialog(boolean mute) {
|
||||
UiUtils.showConfirmationAlert(getContext(),
|
||||
mute ? R.string.mo_unmute_hashtag : R.string.mo_mute_hashtag,
|
||||
@@ -148,8 +153,6 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{
|
||||
}).exec(accountID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
protected TimelineDefinition makeTimelineDefinition() {
|
||||
return TimelineDefinition.ofHashtag(hashtagName);
|
||||
@@ -232,7 +235,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{
|
||||
|
||||
@Override
|
||||
public Uri getWebUri(Uri.Builder base) {
|
||||
return base.path((isInstanceAkkoma() ? "/tag/" : "/tags/") + hashtag).build();
|
||||
return base.path((isInstanceAkkoma() ? "/tag/" : "/tags/") + hashtagName).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -292,6 +295,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{
|
||||
followMenuItem=optionsMenu.findItem(R.id.follow_hashtag);
|
||||
pinMenuItem=optionsMenu.findItem(R.id.pin);
|
||||
followMenuItem.setVisible(toolbarContentVisible);
|
||||
updateFollowState(hashtag!=null && hashtag.following);
|
||||
// pinMenuItem.setShowAsAction(toolbarContentVisible ? MenuItem.SHOW_AS_ACTION_NEVER : MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
super.updatePinButton(pinMenuItem);
|
||||
|
||||
@@ -388,8 +392,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{
|
||||
followButton.setTextVisible(true);
|
||||
followProgress.setVisibility(View.GONE);
|
||||
if(followMenuItem!=null){
|
||||
followMenuItem.setTitle(getString(hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName));
|
||||
followMenuItem.setIcon(hashtag.following ? R.drawable.ic_fluent_person_delete_24_filled : R.drawable.ic_fluent_person_add_24_regular);
|
||||
updateFollowState(hashtag.following);
|
||||
}
|
||||
if(muteMenuItem!=null){
|
||||
muteMenuItem.setTitle(getString(filter.isPresent() ? R.string.unmute_user : R.string.mute_user, "#" + hashtag));
|
||||
@@ -429,6 +432,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment{
|
||||
return;
|
||||
hashtag=result;
|
||||
updateHeader();
|
||||
updateFollowState(result.following);
|
||||
followRequestRunning=false;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,8 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
|
||||
notificationsBadge=tabBar.findViewById(R.id.notifications_badge);
|
||||
notificationsBadge.setVisibility(View.GONE);
|
||||
|
||||
tabBar.selectTab(currentTab);
|
||||
|
||||
if(savedInstanceState==null){
|
||||
getChildFragmentManager().beginTransaction()
|
||||
.add(me.grishka.appkit.R.id.fragment_wrap, homeTabFragment)
|
||||
@@ -182,7 +184,6 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
|
||||
});
|
||||
}
|
||||
}
|
||||
tabBar.selectTab(currentTab);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
|
||||
addListsToOverflowMenu();
|
||||
addHashtagsToOverflowMenu();
|
||||
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI())
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI() && !UiUtils.isMagic())
|
||||
m.setGroupDividerEnabled(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
import androidx.viewpager2.widget.ViewPager2;
|
||||
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountByID;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
|
||||
@@ -287,11 +288,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
noteEdit.setOnFocusChangeListener((v, hasFocus)->{
|
||||
if(hasFocus){
|
||||
hideFab();
|
||||
noteEdit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES);
|
||||
}else{
|
||||
showFab();
|
||||
savePrivateNote(noteEdit.getText().toString());
|
||||
return;
|
||||
}
|
||||
showFab();
|
||||
savePrivateNote(noteEdit.getText().toString());
|
||||
});
|
||||
|
||||
FrameLayout sizeWrapper=new FrameLayout(getActivity()){
|
||||
@@ -454,8 +454,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
}
|
||||
|
||||
private void hidePrivateNote(){
|
||||
noteWrap.setVisibility(View.GONE);
|
||||
noteEdit.setText(null);
|
||||
noteWrap.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void savePrivateNote(String note){
|
||||
@@ -469,6 +469,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
public void onSuccess(Relationship result) {
|
||||
updateRelationship(result);
|
||||
invalidateOptionsMenu();
|
||||
if(!TextUtils.isEmpty(result.note))
|
||||
Toast.makeText(MastodonApp.context, R.string.mo_personal_note_saved, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -817,18 +819,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.edit_note);
|
||||
}
|
||||
boolean hasMultipleAccounts = AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1;
|
||||
MenuItem openWithAccounts = menu.findItem(R.id.open_with_account);
|
||||
openWithAccounts.setVisible(hasMultipleAccounts);
|
||||
SubMenu accountsMenu=openWithAccounts.getSubMenu();
|
||||
if(hasMultipleAccounts){
|
||||
accountsMenu.clear();
|
||||
UiUtils.populateAccountsMenu(accountID, accountsMenu, s-> UiUtils.openURL(
|
||||
getActivity(), s.getID(), account.url, false
|
||||
));
|
||||
}
|
||||
menu.findItem(R.id.open_with_account).setVisible(hasMultipleAccounts);
|
||||
|
||||
if(isOwnProfile) {
|
||||
if (isInstancePixelfed()) menu.findItem(R.id.scheduled).setVisible(false);
|
||||
menu.findItem(R.id.favorites).setIcon(GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_20_regular : R.drawable.ic_fluent_star_20_regular);
|
||||
UiUtils.insetPopupMenuIcon(getContext(), menu.findItem(R.id.favorites));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -865,10 +861,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
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){
|
||||
UiUtils.confirmToggleMuteUser(getActivity(), accountID, account, relationship.muting, this::updateRelationship);
|
||||
}else if(id==R.id.block){
|
||||
@@ -965,11 +958,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
.show();
|
||||
}
|
||||
invalidateOptionsMenu();
|
||||
}else if(id==R.id.manage_user_lists){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("targetAccount", Parcels.wrap(account));
|
||||
Nav.go(getActivity(), AddAccountToListsFragment.class, args);
|
||||
}else if(id==R.id.open_with_account){
|
||||
UiUtils.pickAccount(getActivity(), accountID, R.string.sk_open_with_account, R.drawable.ic_fluent_person_swap_24_regular, session ->UiUtils.openURL(
|
||||
getActivity(), session.getID(), account.url, false
|
||||
), null);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1597,8 +1589,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
|
||||
public void setImage(int index, Drawable image){
|
||||
CustomEmojiSpan span=index>=item.nameEmojis.length ? item.valueEmojis[index-item.nameEmojis.length] : item.nameEmojis[index];
|
||||
span.setDrawable(image);
|
||||
title.invalidate();
|
||||
value.invalidate();
|
||||
title.setText(title.getText());
|
||||
value.setText(value.getText());
|
||||
toolbarTitleView.setText(toolbarTitleView.getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -85,7 +85,7 @@ public class ScheduledStatusListFragment extends BaseStatusListFragment<Schedule
|
||||
|
||||
@Override
|
||||
protected List<StatusDisplayItem> buildDisplayItems(ScheduledStatus s) {
|
||||
return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, null,
|
||||
return StatusDisplayItem.buildItems(this, s.toFormattedStatus(accountID), accountID, s, knownAccounts, null,
|
||||
StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS |
|
||||
StatusDisplayItem.FLAG_NO_FOOTER |
|
||||
StatusDisplayItem.FLAG_NO_TRANSLATE);
|
||||
|
||||
@@ -61,7 +61,9 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>
|
||||
flags |= StatusDisplayItem.FLAG_NO_TRANSLATE;
|
||||
if(!GlobalUserPreferences.showMediaPreview)
|
||||
flags |= StatusDisplayItem.FLAG_NO_MEDIA_PREVIEW;
|
||||
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, getFilterContext(), isMainThreadStatus ? 0 : flags);
|
||||
/* MOSHIDON: we make the filterContext null in the main status in the thread fragment, so that the main status is never filtered (because you just clicked on it).
|
||||
This also restores old behavior that got lost to time and changes in the filter system */
|
||||
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, isMainThreadStatus ? null : getFilterContext(), isMainThreadStatus ? 0 : flags);
|
||||
}
|
||||
|
||||
protected abstract FilterContext getFilterContext();
|
||||
|
||||
@@ -228,12 +228,16 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
|
||||
s.spoilerRevealed = oldStatus.spoilerRevealed;
|
||||
s.sensitiveRevealed = oldStatus.sensitiveRevealed;
|
||||
s.filterRevealed = oldStatus.filterRevealed;
|
||||
s.textExpanded = oldStatus.textExpanded;
|
||||
}
|
||||
if (GlobalUserPreferences.autoRevealEqualSpoilers != AutoRevealMode.NEVER &&
|
||||
s.spoilerText != null &&
|
||||
s.spoilerText.equals(mainStatus.spoilerText)) {
|
||||
if (GlobalUserPreferences.autoRevealEqualSpoilers == AutoRevealMode.DISCUSSIONS || Objects.equals(mainStatus.account.id, s.account.id)) {
|
||||
s.spoilerRevealed = mainStatus.spoilerRevealed;
|
||||
s.spoilerText != null){
|
||||
if (s.spoilerText.equals(mainStatus.spoilerText) ||
|
||||
(s.spoilerText.toLowerCase().startsWith("re: ") &&
|
||||
s.spoilerText.substring(4).equals(mainStatus.spoilerText))){
|
||||
if (GlobalUserPreferences.autoRevealEqualSpoilers == AutoRevealMode.DISCUSSIONS || Objects.equals(mainStatus.account.id, s.account.id)) {
|
||||
s.spoilerRevealed = mainStatus.spoilerRevealed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,6 +308,13 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
|
||||
updatedStatus.filterRevealed = mainStatus.filterRevealed;
|
||||
updatedStatus.spoilerRevealed = mainStatus.spoilerRevealed;
|
||||
updatedStatus.sensitiveRevealed = mainStatus.sensitiveRevealed;
|
||||
updatedStatus.textExpanded = mainStatus.textExpanded;
|
||||
if(updatedStatus.quote!=null && mainStatus.quote!=null){
|
||||
updatedStatus.quote.filterRevealed = mainStatus.quote.filterRevealed;
|
||||
updatedStatus.quote.spoilerRevealed = mainStatus.quote.spoilerRevealed;
|
||||
updatedStatus.quote.sensitiveRevealed = mainStatus.quote.sensitiveRevealed;
|
||||
updatedStatus.quote.textExpanded = mainStatus.quote.textExpanded;
|
||||
}
|
||||
|
||||
// returning fired event object to facilitate testing
|
||||
Object event;
|
||||
@@ -418,7 +429,7 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
|
||||
UiUtils.loadCustomEmojiInTextView(replyButtonText);
|
||||
replyButtonAva.setOutlineProvider(OutlineProviders.OVAL);
|
||||
replyButtonAva.setClipToOutline(true);
|
||||
replyButton.setOnClickListener(v->openReply());
|
||||
replyButton.setOnClickListener(v->openReply(mainStatus, accountID));
|
||||
replyButton.setOnLongClickListener(this::onReplyLongClick);
|
||||
Account self=AccountSessionManager.get(accountID).self;
|
||||
if(!TextUtils.isEmpty(self.avatar)){
|
||||
@@ -570,11 +581,11 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
|
||||
super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(replyContainer, insets));
|
||||
}
|
||||
|
||||
private void openReply(){
|
||||
maybeShowPreReplySheet(mainStatus, ()->{
|
||||
private void openReply(Status status, String accountID){
|
||||
maybeShowPreReplySheet(status, ()->{
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("replyTo", Parcels.wrap(mainStatus));
|
||||
args.putParcelable("replyTo", Parcels.wrap(status));
|
||||
args.putBoolean("fromThreadFragment", true);
|
||||
Nav.go(getActivity(), ComposeFragment.class, args);
|
||||
});
|
||||
@@ -583,9 +594,10 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist
|
||||
if(mainStatus.preview) return false;
|
||||
if (AccountSessionManager.getInstance().getLoggedInAccounts().size() < 2) return false;
|
||||
UiUtils.pickAccount(v.getContext(), accountID, R.string.sk_reply_as, R.drawable.ic_fluent_arrow_reply_28_regular, session -> {
|
||||
UiUtils.lookupStatus(v.getContext(), mainStatus, accountID, session.getID(), status -> {
|
||||
String pickedAccountID = session.getID();
|
||||
UiUtils.lookupStatus(v.getContext(), mainStatus, pickedAccountID, accountID, status -> {
|
||||
if (status == null) return;
|
||||
openReply();
|
||||
openReply(status, pickedAccountID);
|
||||
});
|
||||
}, null);
|
||||
return true;
|
||||
|
||||
@@ -279,8 +279,8 @@ public class DiscoverAccountsFragment extends MastodonRecyclerFragment<DiscoverA
|
||||
cover.setImageDrawable(image);
|
||||
}else{
|
||||
item.emojiHelper.setImageDrawable(index-2, image);
|
||||
name.invalidate();
|
||||
bio.invalidate();
|
||||
name.setText(name.getText());
|
||||
bio.setText(bio.getText());
|
||||
}
|
||||
if(image instanceof Animatable a && !a.isRunning())
|
||||
a.start();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.joinmastodon.android.fragments.discover;
|
||||
|
||||
import android.app.Fragment;
|
||||
import android.app.assist.AssistContent;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
@@ -24,6 +25,7 @@ import org.joinmastodon.android.ui.SimpleViewHolder;
|
||||
import org.joinmastodon.android.ui.tabs.TabLayout;
|
||||
import org.joinmastodon.android.ui.tabs.TabLayoutMediator;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.utils.ProvidesAssistContent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -35,7 +37,7 @@ 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, IsOnTop {
|
||||
public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener, IsOnTop, ProvidesAssistContent{
|
||||
private static final int QUERY_RESULT=937;
|
||||
|
||||
private TabLayout tabLayout;
|
||||
@@ -80,8 +82,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
for(int i=0;i<tabViews.length;i++){
|
||||
FrameLayout tabView=new FrameLayout(getActivity());
|
||||
tabView.setId(switch(i){
|
||||
case 0 -> R.id.discover_hashtags;
|
||||
case 1 -> R.id.discover_posts;
|
||||
case 0 -> R.id.discover_posts;
|
||||
case 1 -> R.id.discover_hashtags;
|
||||
case 2 -> R.id.discover_news;
|
||||
case 3 -> R.id.discover_users;
|
||||
default -> throw new IllegalStateException("Unexpected value: "+i);
|
||||
@@ -125,8 +127,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
accountsFragment.setArguments(args);
|
||||
|
||||
getChildFragmentManager().beginTransaction()
|
||||
.add(R.id.discover_hashtags, hashtagsFragment)
|
||||
.add(R.id.discover_posts, postsFragment)
|
||||
.add(R.id.discover_hashtags, hashtagsFragment)
|
||||
.add(R.id.discover_news, newsFragment)
|
||||
.add(R.id.discover_users, accountsFragment)
|
||||
.commit();
|
||||
@@ -136,8 +138,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
@Override
|
||||
public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){
|
||||
tab.setText(switch(position){
|
||||
case 0 -> R.string.hashtags;
|
||||
case 1 -> R.string.posts;
|
||||
case 0 -> R.string.posts;
|
||||
case 1 -> R.string.hashtags;
|
||||
case 2 -> R.string.news;
|
||||
case 3 -> R.string.for_you;
|
||||
default -> throw new IllegalStateException("Unexpected value: "+position);
|
||||
@@ -258,8 +260,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
|
||||
private Fragment getFragmentForPage(int page){
|
||||
return switch(page){
|
||||
case 0 -> hashtagsFragment;
|
||||
case 1 -> postsFragment;
|
||||
case 0 -> postsFragment;
|
||||
case 1 -> hashtagsFragment;
|
||||
case 2 -> newsFragment;
|
||||
case 3 -> accountsFragment;
|
||||
default -> throw new IllegalStateException("Unexpected value: "+page);
|
||||
@@ -291,6 +293,13 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProvideAssistContent(AssistContent assistContent) {
|
||||
callFragmentToProvideAssistContent(searchActive
|
||||
? searchFragment
|
||||
: getFragmentForPage(pager.getCurrentItem()), assistContent);
|
||||
}
|
||||
|
||||
private class DiscoverPagerAdapter extends RecyclerView.Adapter<SimpleViewHolder>{
|
||||
@NonNull
|
||||
@Override
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments.discover;
|
||||
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
@@ -19,6 +20,8 @@ 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.utils.ProvidesAssistContent;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -27,6 +30,7 @@ import java.util.stream.Collectors;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.fragments.BaseRecyclerFragment;
|
||||
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
|
||||
@@ -40,7 +44,7 @@ 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, IsOnTop{
|
||||
public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri{
|
||||
private String accountID;
|
||||
private DiscoverInfoBannerHelper bannerHelper;
|
||||
private MergeRecyclerAdapter mergeAdapter;
|
||||
@@ -115,6 +119,16 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> im
|
||||
return isRecyclerViewOnTop(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAccountID() {
|
||||
return accountID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getWebUri(Uri.Builder base) {
|
||||
return isInstanceAkkoma() ? null : base.path("/explore/links").build();
|
||||
}
|
||||
|
||||
private class LinksAdapter extends UsableRecyclerView.Adapter<BaseLinkViewHolder> implements ImageLoaderRecyclerAdapter{
|
||||
private final List<CardViewModel> data;
|
||||
|
||||
@@ -203,7 +217,16 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> im
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
UiUtils.launchWebBrowser(getActivity(), item.url);
|
||||
//TODO: enable timeline for all servers once 4.3.0 is released
|
||||
if(getInstance().isEmpty() ||
|
||||
!getInstance().get().checkVersion(4,3,0)){
|
||||
UiUtils.launchWebBrowser(getActivity(), item.url);
|
||||
return;
|
||||
}
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putParcelable("trendingLink", Parcels.wrap(item));
|
||||
Nav.go(getActivity(), DiscoverTrendingLinkTimelineFragment.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package org.joinmastodon.android.fragments.discover;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.timelines.GetTrendingLinksTimeline;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.fragments.ComposeFragment;
|
||||
import org.joinmastodon.android.fragments.HomeTabFragment;
|
||||
import org.joinmastodon.android.fragments.StatusListFragment;
|
||||
import org.joinmastodon.android.model.Card;
|
||||
import org.joinmastodon.android.model.FilterContext;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.SimpleCallback;
|
||||
import me.grishka.appkit.utils.MergeRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
|
||||
import me.grishka.appkit.utils.V;
|
||||
|
||||
//TODO: replace this implementation when upstream implements their own design
|
||||
public class DiscoverTrendingLinkTimelineFragment extends StatusListFragment{
|
||||
private Card trendingLink;
|
||||
private TextView headerTitle, headerSubtitle;
|
||||
private Button openLinkButton;
|
||||
private boolean toolbarContentVisible;
|
||||
|
||||
private Menu optionsMenu;
|
||||
private MenuInflater optionsMenuInflater;
|
||||
|
||||
@Override
|
||||
protected boolean wantsComposeButton() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Activity activity){
|
||||
super.onAttach(activity);
|
||||
trendingLink=Parcels.unwrap(getArguments().getParcelable("trendingLink"));
|
||||
setTitle(trendingLink.title);
|
||||
setHasOptionsMenu(true);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void doLoadData(int offset, int count){
|
||||
currentRequest=new GetTrendingLinksTimeline(trendingLink.url, getMaxID(), null, count)
|
||||
.setCallback(new SimpleCallback<>(this){
|
||||
@Override
|
||||
public void onSuccess(List<Status> result){
|
||||
if(getActivity()==null) return;
|
||||
boolean more=applyMaxID(result);
|
||||
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
|
||||
onDataLoaded(result, more);
|
||||
}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onShown(){
|
||||
super.onShown();
|
||||
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
|
||||
loadData();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(View view, Bundle savedInstanceState){
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
fab=view.findViewById(R.id.fab);
|
||||
fab.setOnClickListener(this::onFabClick);
|
||||
|
||||
if(getParentFragment() instanceof HomeTabFragment) return;
|
||||
|
||||
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
|
||||
@Override
|
||||
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
|
||||
View topChild=recyclerView.getChildAt(0);
|
||||
int firstChildPos=recyclerView.getChildAdapterPosition(topChild);
|
||||
float newAlpha=firstChildPos>0 ? 1f : Math.min(1f, -topChild.getTop()/(float)headerTitle.getHeight());
|
||||
toolbarTitleView.setAlpha(newAlpha);
|
||||
boolean newToolbarVisibility=newAlpha>0.5f;
|
||||
if(newToolbarVisibility!=toolbarContentVisible){
|
||||
toolbarContentVisible=newToolbarVisibility;
|
||||
createOptionsMenu();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean onFabLongClick(View v) {
|
||||
return UiUtils.pickAccountForCompose(getActivity(), accountID, trendingLink.url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFabClick(View v){
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
args.putString("prefilledText", trendingLink.url);
|
||||
Nav.go(getActivity(), ComposeFragment.class, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSetFabBottomInset(int inset){
|
||||
((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+inset;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected FilterContext getFilterContext() {
|
||||
return FilterContext.PUBLIC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getWebUri(Uri.Builder base) {
|
||||
return base.path("/links").appendPath(trendingLink.url).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RecyclerView.Adapter getAdapter(){
|
||||
View header=getActivity().getLayoutInflater().inflate(R.layout.header_trending_link_timeline, list, false);
|
||||
headerTitle=header.findViewById(R.id.title);
|
||||
headerSubtitle=header.findViewById(R.id.subtitle);
|
||||
openLinkButton=header.findViewById(R.id.profile_action_btn);
|
||||
|
||||
headerTitle.setText(trendingLink.title);
|
||||
openLinkButton.setVisibility(View.GONE);
|
||||
openLinkButton.setOnClickListener(v->{
|
||||
if(trendingLink==null)
|
||||
return;
|
||||
openLink();
|
||||
});
|
||||
updateHeader();
|
||||
|
||||
MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
|
||||
if(!(getParentFragment() instanceof HomeTabFragment)){
|
||||
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(header));
|
||||
}
|
||||
mergeAdapter.addAdapter(super.getAdapter());
|
||||
return mergeAdapter;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getMainAdapterOffset(){
|
||||
return 1;
|
||||
}
|
||||
|
||||
private void createOptionsMenu(){
|
||||
optionsMenu.clear();
|
||||
optionsMenuInflater.inflate(R.menu.trending_links_timeline, optionsMenu);
|
||||
MenuItem openLinkMenuItem=optionsMenu.findItem(R.id.open_link);
|
||||
openLinkMenuItem.setVisible(toolbarContentVisible);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
|
||||
inflater.inflate(R.menu.trending_links_timeline, menu);
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
optionsMenu=menu;
|
||||
optionsMenuInflater=inflater;
|
||||
createOptionsMenu();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item){
|
||||
if (super.onOptionsItemSelected(item)) return true;
|
||||
if (item.getItemId() == R.id.open_link && trendingLink!=null) {
|
||||
openLink();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onUpdateToolbar(){
|
||||
super.onUpdateToolbar();
|
||||
toolbarTitleView.setAlpha(toolbarContentVisible ? 1f : 0f);
|
||||
createOptionsMenu();
|
||||
}
|
||||
|
||||
private void updateHeader(){
|
||||
if(trendingLink==null || getActivity()==null)
|
||||
return;
|
||||
//TODO: update to show mastodon account once fully implemented upstream
|
||||
headerSubtitle.setText(getContext().getString(R.string.article_by_author, TextUtils.isEmpty(trendingLink.authorName)? trendingLink.providerName : trendingLink.authorName));
|
||||
openLinkButton.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private void openLink() {
|
||||
UiUtils.launchWebBrowser(getActivity(), trendingLink.url);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.joinmastodon.android.fragments.discover;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
@@ -13,6 +14,7 @@ import org.joinmastodon.android.fragments.ScrollableToTop;
|
||||
import org.joinmastodon.android.model.Hashtag;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.views.HashtagChartView;
|
||||
import org.joinmastodon.android.utils.ProvidesAssistContent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -23,7 +25,7 @@ import me.grishka.appkit.fragments.BaseRecyclerFragment;
|
||||
import me.grishka.appkit.utils.BindableViewHolder;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
|
||||
public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> implements ScrollableToTop, IsOnTop{
|
||||
public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri{
|
||||
private String accountID;
|
||||
|
||||
public TrendingHashtagsFragment(){
|
||||
@@ -65,6 +67,16 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> impl
|
||||
return isRecyclerViewOnTop(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAccountID() {
|
||||
return accountID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getWebUri(Uri.Builder base) {
|
||||
return isInstanceAkkoma() ? null : base.path("/explore/tags").build();
|
||||
}
|
||||
|
||||
private class HashtagsAdapter extends RecyclerView.Adapter<HashtagViewHolder>{
|
||||
@NonNull
|
||||
@Override
|
||||
|
||||
@@ -100,7 +100,6 @@ public class ReportCommentFragment extends MastodonToolbarFragment{
|
||||
|
||||
ProgressBar topProgress=view.findViewById(R.id.top_progress);
|
||||
topProgress.setProgress(getArguments().containsKey("ruleIDs") ? 75 : 66);
|
||||
forwardSwitch.setChecked(GlobalUserPreferences.forwardReportDefault);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.joinmastodon.android.fragments.settings;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.WindowInsets;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.fragments.HasAccountID;
|
||||
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
|
||||
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
|
||||
import org.joinmastodon.android.model.viewmodel.ListItem;
|
||||
@@ -14,10 +16,11 @@ import org.joinmastodon.android.ui.DividerItemDecoration;
|
||||
import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter;
|
||||
import org.joinmastodon.android.ui.viewholders.ListItemViewHolder;
|
||||
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
|
||||
import org.joinmastodon.android.utils.ProvidesAssistContent;
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<ListItem<T>>{
|
||||
public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<ListItem<T>> implements HasAccountID, ProvidesAssistContent.ProvidesWebUri{
|
||||
protected GenericListItemsAdapter<T> itemsAdapter;
|
||||
protected String accountID;
|
||||
|
||||
@@ -83,4 +86,14 @@ public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<L
|
||||
}
|
||||
super.onApplyWindowInsets(insets);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAccountID() {
|
||||
return accountID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getWebUri(Uri.Builder base) {
|
||||
return base.path("/settings").build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.joinmastodon.android.fragments.settings;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.view.Menu;
|
||||
@@ -329,4 +330,8 @@ public class EditFilterFragment extends BaseSettingsFragment<Void> implements On
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@Override
|
||||
public Uri getWebUri(Uri.Builder base) {
|
||||
return base.path(filter == null ? "/filters/new" : "/filters/"+ filter.id + "/edit").build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ package org.joinmastodon.android.fragments.settings;
|
||||
import android.app.Activity;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
@@ -11,6 +16,13 @@ import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.ToNumberPolicy;
|
||||
|
||||
import org.joinmastodon.android.BuildConfig;
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
@@ -21,6 +33,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.fragments.HasAccountID;
|
||||
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.Snackbar;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.updater.GithubSelfUpdater;
|
||||
@@ -28,20 +41,18 @@ import org.joinmastodon.android.updater.GithubSelfUpdater;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.StringReader;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Files;
|
||||
import java.io.OutputStream;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.FormatStyle;
|
||||
import java.util.ArrayList;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import me.grishka.appkit.imageloader.ImageCache;
|
||||
@@ -51,6 +62,8 @@ import me.grishka.appkit.utils.V;
|
||||
|
||||
public class SettingsAboutAppFragment extends BaseSettingsFragment<Void> implements HasAccountID{
|
||||
private static final String TAG="SettingsAboutAppFragment";
|
||||
private static final int IMPORT_RESULT=314;
|
||||
private static final int EXPORT_RESULT=271;
|
||||
private ListItem<Void> mediaCacheItem, copyCrashLogItem;
|
||||
private CheckableListItem<Void> enablePreReleasesItem;
|
||||
private AccountSession session;
|
||||
@@ -58,7 +71,7 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment<Void> impleme
|
||||
private File crashLogFile=new File(MastodonApp.context.getFilesDir(), "crash.log");
|
||||
|
||||
// MOSHIDON
|
||||
private ListItem<Void> clearRecentEmojisItem;
|
||||
private ListItem<Void> clearRecentEmojisItem, exportItem, importItem;
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState){
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -73,13 +86,15 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment<Void> impleme
|
||||
new ListItem<>(R.string.mo_settings_contribute, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.mo_repo_url))),
|
||||
new ListItem<>(R.string.settings_tos, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms")),
|
||||
new ListItem<>(R.string.settings_privacy_policy, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true),
|
||||
exportItem=new ListItem<>(R.string.export_settings_title, R.string.export_settings_summary, R.drawable.ic_fluent_arrow_export_24_filled, this::onExportClick),
|
||||
importItem=new ListItem<>(R.string.import_settings_title, R.string.import_settings_summary, R.drawable.ic_fluent_arrow_import_24_filled, this::onImportClick, 0, true),
|
||||
clearRecentEmojisItem=new ListItem<>(R.string.mo_clear_recent_emoji, 0, this::onClearRecentEmojisClick),
|
||||
mediaCacheItem=new ListItem<>(R.string.settings_clear_cache, 0, this::onClearMediaCacheClick),
|
||||
new ListItem<>(getString(R.string.sk_settings_clear_timeline_cache), session.domain, this::onClearTimelineCacheClick),
|
||||
copyCrashLogItem=new ListItem<>(getString(R.string.sk_settings_copy_crash_log), lastModified, 0, this::onCopyCrashLog)
|
||||
));
|
||||
|
||||
if(GithubSelfUpdater.needSelfUpdating()){
|
||||
if(GithubSelfUpdater.needSelfUpdating() && !BuildConfig.BUILD_TYPE.equals("nightly") ){
|
||||
items.add(enablePreReleasesItem=new CheckableListItem<>(R.string.sk_updater_enable_pre_releases, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.enablePreReleases, i->toggleCheckableItem(enablePreReleasesItem)));
|
||||
}
|
||||
|
||||
@@ -146,6 +161,166 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment<Void> impleme
|
||||
Toast.makeText(getContext(), R.string.mo_recent_emoji_cleared, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private void onExportClick(ListItem<?> item){
|
||||
// The magic will happen on the onActivityResult Method
|
||||
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||
intent.setType("application/json");
|
||||
intent.putExtra(Intent.EXTRA_TITLE,"moshidon-exported-settings.json");
|
||||
startActivityForResult(intent, EXPORT_RESULT);
|
||||
}
|
||||
|
||||
private void onImportClick(ListItem<?> item){
|
||||
new M3AlertDialogBuilder(getContext())
|
||||
.setTitle(R.string.import_settings_confirm)
|
||||
.setIcon(R.drawable.ic_fluent_warning_24_regular)
|
||||
.setMessage(R.string.import_settings_confirm_body)
|
||||
.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("application/json");
|
||||
startActivityForResult(intent, IMPORT_RESULT);
|
||||
})
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data){
|
||||
if(requestCode==IMPORT_RESULT && resultCode==Activity.RESULT_OK){
|
||||
Uri uri=data.getData();
|
||||
if(uri==null){
|
||||
return;
|
||||
}
|
||||
try{
|
||||
InputStream inputStream=getContext().getContentResolver().openInputStream(uri);
|
||||
if(inputStream==null)
|
||||
return;
|
||||
BufferedReader reader=new BufferedReader(new InputStreamReader(inputStream));
|
||||
StringBuilder stringBuilder=new StringBuilder();
|
||||
String line;
|
||||
while((line=reader.readLine())!=null){
|
||||
stringBuilder.append(line);
|
||||
}
|
||||
inputStream.close();
|
||||
String jsonString=stringBuilder.toString();
|
||||
|
||||
Gson gson=new GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create();
|
||||
|
||||
//check if json is not null
|
||||
if(jsonString.isEmpty()) {
|
||||
throw new IOException();
|
||||
}
|
||||
|
||||
JsonObject jsonObject=JsonParser.parseString(jsonString).getAsJsonObject();
|
||||
|
||||
//check if json has required attributes
|
||||
if(!(jsonObject.has("versionName") && jsonObject.has("versionCode") && jsonObject.has("GlobalUserPreferences"))){
|
||||
Toast.makeText(getContext(), getContext().getString(R.string.import_settings_failed), Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
String versionName=jsonObject.get("versionName").getAsString();
|
||||
int versionCode=jsonObject.get("versionCode").getAsInt();
|
||||
Log.i(TAG, "onActivityResult: Reading exported settings ("+versionName+" "+versionCode+")");
|
||||
|
||||
// retrieve GlobalUserPreferences
|
||||
Map<String, ?> jsonGlobalPrefs=gson.fromJson(jsonObject.getAsJsonObject("GlobalUserPreferences"), Map.class);
|
||||
SharedPreferences.Editor globalPrefsEditor=GlobalUserPreferences.getPrefs().edit();
|
||||
for(String key : jsonGlobalPrefs.keySet()){
|
||||
Object value=jsonGlobalPrefs.get(key);
|
||||
if(value==null)
|
||||
continue;
|
||||
savePrefValue(globalPrefsEditor, key, value);
|
||||
}
|
||||
|
||||
// retrieve LocalPreferences for all logged in accounts
|
||||
//TODO: maybe show a dialog for which accounts to import?
|
||||
for(AccountSession accountSession : AccountSessionManager.getInstance().getLoggedInAccounts()){
|
||||
if(!jsonObject.has(accountSession.self.id))
|
||||
continue;
|
||||
Map<String, ?> prefs=gson.fromJson(jsonObject.getAsJsonObject(accountSession.self.id), Map.class);
|
||||
|
||||
SharedPreferences.Editor prefEditor=accountSession.getRawLocalPreferences().edit();
|
||||
for(String key : prefs.keySet()){
|
||||
Object value=prefs.get(key);
|
||||
if(value==null)
|
||||
continue;
|
||||
savePrefValue(prefEditor, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// restart app to apply new preferences
|
||||
// https://stackoverflow.com/a/46848226
|
||||
PackageManager packageManager=getContext().getPackageManager();
|
||||
Intent intent=packageManager.getLaunchIntentForPackage(getContext().getPackageName());
|
||||
ComponentName componentName=intent.getComponent();
|
||||
Intent mainIntent=Intent.makeRestartActivityTask(componentName);
|
||||
// Required for API 34 and later
|
||||
// Ref: https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents
|
||||
mainIntent.setPackage(getContext().getPackageName());
|
||||
getContext().startActivity(mainIntent);
|
||||
Runtime.getRuntime().exit(0);
|
||||
}catch(IOException e){
|
||||
Log.w(TAG, e);
|
||||
Toast.makeText(getContext(), getContext().getString(R.string.import_settings_failed), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
if(requestCode == EXPORT_RESULT && resultCode==Activity.RESULT_OK) {
|
||||
try{
|
||||
Gson gson = new Gson();
|
||||
JsonObject jsonObject = new JsonObject();
|
||||
jsonObject.addProperty("versionName", BuildConfig.VERSION_NAME);
|
||||
jsonObject.addProperty("versionCode", BuildConfig.VERSION_CODE);
|
||||
|
||||
// GlobalUserPreferences
|
||||
//TODO: remove prefs that should not be exported
|
||||
JsonElement je = gson.toJsonTree(GlobalUserPreferences.getPrefs().getAll());
|
||||
jsonObject.add("GlobalUserPreferences", je);
|
||||
|
||||
// add account local prefs
|
||||
for(AccountSession accountSession: AccountSessionManager.getInstance().getLoggedInAccounts()) {
|
||||
Map<String, ?> prefs = accountSession.getRawLocalPreferences().getAll();
|
||||
//TODO: remove prefs that should not be exported
|
||||
JsonElement accountPrefs = gson.toJsonTree(prefs);
|
||||
jsonObject.add(accountSession.self.id, accountPrefs);
|
||||
}
|
||||
|
||||
File file = new File(getContext().getCacheDir(), "moshidon-exported-settings.json");
|
||||
FileWriter writer = new FileWriter(file);
|
||||
writer.write(jsonObject.toString());
|
||||
writer.flush();
|
||||
writer.close();
|
||||
|
||||
// Got this from stackoverflow at https://stackoverflow.com/a/67046741
|
||||
InputStream is = new FileInputStream(file);
|
||||
OutputStream os = getContext().getContentResolver().openOutputStream(data.getData());
|
||||
|
||||
byte[] buffer = new byte[1024];
|
||||
int length;
|
||||
while ((length = is.read(buffer)) > 0) {
|
||||
os.write(buffer, 0, length);
|
||||
}
|
||||
}catch(IOException e){
|
||||
Toast.makeText(getContext(), e.getMessage(), Toast.LENGTH_SHORT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void savePrefValue(SharedPreferences.Editor editor, String key, Object value) {
|
||||
if(value.getClass().equals(Boolean.class))
|
||||
editor.putBoolean(key, (Boolean) value);
|
||||
// gson parses all numbers either long (for int) or double (the rest)
|
||||
else if(value.getClass().equals(Long.class))
|
||||
editor.putInt(key, ((Long) value).intValue());
|
||||
else if(value.getClass().equals(Double.class))
|
||||
editor.putFloat(key, ((Double) value).floatValue());
|
||||
else
|
||||
editor.putString(key, String.valueOf(value));
|
||||
//explicitly immediately since the app will restarted soon after
|
||||
// and it may not have the time to write the values in the background
|
||||
editor.commit();
|
||||
}
|
||||
|
||||
private void updateMediaCacheItem(){
|
||||
long size=ImageCache.getInstance(getActivity()).getDiskCache().size();
|
||||
mediaCacheItem.subtitle=UiUtils.formatFileSize(getActivity(), size, false);
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
package org.joinmastodon.android.fragments.settings;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.session.AccountLocalPreferences;
|
||||
@@ -30,7 +23,10 @@ import org.joinmastodon.android.utils.MastodonLanguage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
public class SettingsBehaviorFragment extends BaseSettingsFragment<Void> implements HasAccountID{
|
||||
private ListItem<Void> languageItem;
|
||||
@@ -41,7 +37,7 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment<Void> impleme
|
||||
// MEGALODON
|
||||
private MastodonLanguage.LanguageResolver languageResolver;
|
||||
private ListItem<Void> prefixRepliesItem, replyVisibilityItem, customTabsItem;
|
||||
private CheckableListItem<Void> forwardReportsItem, remoteLoadingItem, showBoostsItem, showRepliesItem, loadNewPostsItem, seeNewPostsBtnItem, overlayMediaItem;
|
||||
private CheckableListItem<Void> remoteLoadingItem, showBoostsItem, showRepliesItem, loadNewPostsItem, seeNewPostsBtnItem, overlayMediaItem;
|
||||
|
||||
// MOSHIDON
|
||||
private CheckableListItem<Void> mentionRebloggerAutomaticallyItem, hapticFeedbackItem, showPostsWithoutAltItem;
|
||||
@@ -68,12 +64,11 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment<Void> impleme
|
||||
confirmBoostItem=new CheckableListItem<>(R.string.settings_confirm_boost, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmBoost, R.drawable.ic_fluent_arrow_repeat_all_24_regular, i->toggleCheckableItem(confirmBoostItem)),
|
||||
confirmDeleteItem=new CheckableListItem<>(R.string.settings_confirm_delete_post, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmDeletePost, R.drawable.ic_fluent_delete_24_regular, i->toggleCheckableItem(confirmDeleteItem)),
|
||||
prefixRepliesItem=new ListItem<>(R.string.sk_settings_prefix_reply_cw_with_re, getPrefixWithRepliesString(), R.drawable.ic_fluent_arrow_reply_24_regular, this::onPrefixRepliesClick),
|
||||
forwardReportsItem=new CheckableListItem<>(R.string.sk_settings_forward_report_default, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.forwardReportDefault, R.drawable.ic_fluent_arrow_forward_24_regular, i->toggleCheckableItem(forwardReportsItem)),
|
||||
loadNewPostsItem=new CheckableListItem<>(R.string.sk_settings_load_new_posts, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.loadNewPosts, R.drawable.ic_fluent_arrow_sync_24_regular, i->onLoadNewPostsClick()),
|
||||
seeNewPostsBtnItem=new CheckableListItem<>(R.string.sk_settings_see_new_posts_button, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showNewPostsButton, R.drawable.ic_fluent_arrow_up_24_regular, i->toggleCheckableItem(seeNewPostsBtnItem)),
|
||||
hapticFeedbackItem=new CheckableListItem<>(R.string.mo_haptic_feedback, R.string.mo_setting_haptic_feedback_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.hapticFeedback, R.drawable.ic_fluent_phone_vibrate_24_regular, i->toggleCheckableItem(hapticFeedbackItem)),
|
||||
remoteLoadingItem=new CheckableListItem<>(R.string.sk_settings_allow_remote_loading, R.string.sk_settings_allow_remote_loading_explanation, CheckableListItem.Style.SWITCH, GlobalUserPreferences.allowRemoteLoading, R.drawable.ic_fluent_communication_24_regular, i->toggleCheckableItem(remoteLoadingItem)),
|
||||
mentionRebloggerAutomaticallyItem=new CheckableListItem<>(R.string.mo_mention_reblogger_automatically, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.mentionRebloggerAutomatically, R.drawable.ic_fluent_comment_mention_24_regular, i->toggleCheckableItem(mentionRebloggerAutomaticallyItem)),
|
||||
hapticFeedbackItem=new CheckableListItem<>(R.string.mo_haptic_feedback, R.string.mo_setting_haptic_feedback_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.hapticFeedback, R.drawable.ic_fluent_phone_vibrate_24_regular, i->toggleCheckableItem(hapticFeedbackItem), true),
|
||||
mentionRebloggerAutomaticallyItem=new CheckableListItem<>(R.string.mo_mention_reblogger_automatically, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.mentionRebloggerAutomatically, R.drawable.ic_fluent_comment_mention_24_regular, i->toggleCheckableItem(mentionRebloggerAutomaticallyItem), true),
|
||||
showBoostsItem=new CheckableListItem<>(R.string.sk_settings_show_boosts, 0, CheckableListItem.Style.SWITCH, lp.showBoosts, R.drawable.ic_fluent_arrow_repeat_all_24_regular, i->toggleCheckableItem(showBoostsItem)),
|
||||
showRepliesItem=new CheckableListItem<>(R.string.sk_settings_show_replies, 0, CheckableListItem.Style.SWITCH, lp.showReplies, R.drawable.ic_fluent_arrow_reply_24_regular, i->toggleCheckableItem(showRepliesItem))
|
||||
));
|
||||
@@ -178,14 +173,6 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment<Void> impleme
|
||||
|
||||
private void onCustomTabsClick(ListItem<?> item){
|
||||
// GlobalUserPreferences.useCustomTabs=customTabsItem.checked;
|
||||
Intent intent=new Intent(Intent.ACTION_VIEW, Uri.parse("http://example.com"));
|
||||
ResolveInfo info=getActivity().getPackageManager().resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
|
||||
final String browserName;
|
||||
if(info==null){
|
||||
browserName="??";
|
||||
}else{
|
||||
browserName=info.loadLabel(getActivity().getPackageManager()).toString();
|
||||
}
|
||||
ArrayAdapter<CharSequence> adapter=new ArrayAdapter<>(getActivity(), R.layout.item_alert_single_choice_2lines_but_different, R.id.text,
|
||||
new String[]{getString(R.string.in_app_browser), getString(R.string.system_browser)}){
|
||||
@Override
|
||||
@@ -198,12 +185,7 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment<Void> impleme
|
||||
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent){
|
||||
View view=super.getView(position, convertView, parent);
|
||||
TextView subtitle=view.findViewById(R.id.subtitle);
|
||||
if(position==0){
|
||||
subtitle.setVisibility(View.GONE);
|
||||
}else{
|
||||
subtitle.setVisibility(View.VISIBLE);
|
||||
subtitle.setText(browserName);
|
||||
}
|
||||
subtitle.setVisibility(View.GONE);
|
||||
return view;
|
||||
}
|
||||
};
|
||||
@@ -227,7 +209,6 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment<Void> impleme
|
||||
GlobalUserPreferences.confirmUnfollow=confirmUnfollowItem.checked;
|
||||
GlobalUserPreferences.confirmBoost=confirmBoostItem.checked;
|
||||
GlobalUserPreferences.confirmDeletePost=confirmDeleteItem.checked;
|
||||
GlobalUserPreferences.forwardReportDefault=forwardReportsItem.checked;
|
||||
GlobalUserPreferences.loadNewPosts=loadNewPostsItem.checked;
|
||||
GlobalUserPreferences.showNewPostsButton=seeNewPostsBtnItem.checked;
|
||||
GlobalUserPreferences.allowRemoteLoading=remoteLoadingItem.checked;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.joinmastodon.android.fragments.settings;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.squareup.otto.Subscribe;
|
||||
@@ -107,4 +108,9 @@ public class SettingsFiltersFragment extends BaseSettingsFragment<Filter>{
|
||||
data.add(makeListItem(ev.filter));
|
||||
itemsAdapter.notifyItemInserted(data.size()-1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getWebUri(Uri.Builder base) {
|
||||
return base.path("/filters").build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.joinmastodon.android.fragments.settings;
|
||||
|
||||
import static org.unifiedpush.android.connector.UnifiedPush.getDistributor;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Intent;
|
||||
@@ -15,6 +13,7 @@ import android.widget.ImageView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.BuildConfig;
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.PushSubscriptionManager;
|
||||
@@ -48,6 +47,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
|
||||
private HideableSingleViewRecyclerAdapter bannerAdapter;
|
||||
private ImageView bannerIcon;
|
||||
private TextView bannerText;
|
||||
private TextView bannerTitle;
|
||||
private Button bannerButton;
|
||||
|
||||
private CheckableListItem<Void> mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem;
|
||||
@@ -72,7 +72,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
|
||||
lp=AccountSessionManager.get(accountID).getLocalPreferences();
|
||||
|
||||
getPushSubscription();
|
||||
useUnifiedPush=!getDistributor(getContext()).isEmpty();
|
||||
useUnifiedPush=!UnifiedPush.getDistributor(getContext()).isEmpty();
|
||||
|
||||
onDataLoaded(List.of(
|
||||
pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_fluent_alert_snooze_24_regular, i->onPauseNotificationsClick(false)),
|
||||
@@ -158,6 +158,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
|
||||
@Override
|
||||
protected RecyclerView.Adapter<?> getAdapter(){
|
||||
View banner=getActivity().getLayoutInflater().inflate(R.layout.item_settings_banner, list, false);
|
||||
bannerTitle=banner.findViewById(R.id.title);
|
||||
bannerText=banner.findViewById(R.id.text);
|
||||
bannerIcon=banner.findViewById(R.id.icon);
|
||||
bannerButton=banner.findViewById(R.id.button);
|
||||
@@ -315,6 +316,20 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
|
||||
bannerText.setText(R.string.notifications_disabled_in_system);
|
||||
bannerButton.setText(R.string.open_system_notification_settings);
|
||||
bannerButton.setOnClickListener(v->openSystemNotificationSettings());
|
||||
}else if(BuildConfig.BUILD_TYPE.equals("fdroidRelease") && UnifiedPush.getDistributor(getContext()).isEmpty()){
|
||||
bannerAdapter.setVisible(true);
|
||||
bannerIcon.setImageResource(R.drawable.ic_fluent_warning_24_filled);
|
||||
bannerTitle.setVisibility(View.VISIBLE);
|
||||
bannerTitle.setText(R.string.mo_settings_unifiedpush_warning);
|
||||
if(UnifiedPush.getDistributors(getContext(), new ArrayList<>()).isEmpty()) {
|
||||
bannerText.setText(R.string.mo_settings_unifiedpush_warning_no_distributors);
|
||||
bannerButton.setText(R.string.info);
|
||||
bannerButton.setOnClickListener(v->UiUtils.launchWebBrowser(getContext(), "https://unifiedpush.org/"));
|
||||
} else {
|
||||
bannerText.setText(R.string.mo_settings_unifiedpush_warning_disabled);
|
||||
bannerButton.setText(R.string.mo_settings_unifiedpush_enable);
|
||||
bannerButton.setOnClickListener(v->onUnifiedPushClick());
|
||||
}
|
||||
}else if(pauseTime>System.currentTimeMillis()){
|
||||
bannerAdapter.setVisible(true);
|
||||
bannerIcon.setImageResource(R.drawable.ic_fluent_alert_snooze_24_regular);
|
||||
@@ -327,7 +342,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
|
||||
}
|
||||
|
||||
private void onUnifiedPushClick(){
|
||||
if(getDistributor(getContext()).isEmpty()){
|
||||
if(UnifiedPush.getDistributor(getContext()).isEmpty()){
|
||||
List<String> distributors = UnifiedPush.getDistributors(getContext(), new ArrayList<>());
|
||||
showUnifiedPushRegisterDialog(distributors);
|
||||
return;
|
||||
@@ -363,4 +378,9 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
|
||||
rebindItem(unifiedPushItem);
|
||||
}).setOnCancelListener(d->rebindItem(unifiedPushItem)).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getWebUri(Uri.Builder base) {
|
||||
return base.path("/settings/preferences/notifications").build();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public class SettingsPrivacyFragment extends BaseSettingsFragment<Void>{
|
||||
private Instance instance;
|
||||
|
||||
//MOSHIDON
|
||||
private CheckableListItem<Void> unlistedRepliesItem;
|
||||
private CheckableListItem<Void> unlistedRepliesItem, removeTrackingParams;
|
||||
|
||||
|
||||
@Override
|
||||
@@ -38,7 +38,8 @@ public class SettingsPrivacyFragment extends BaseSettingsFragment<Void>{
|
||||
privacy=self.source.privacy;
|
||||
onDataLoaded(List.of(
|
||||
privacyItem=new ListItem<>(R.string.sk_settings_default_visibility, getPrivacyString(privacy), R.drawable.ic_fluent_eye_24_regular, this::onPrivacyClick, 0, false),
|
||||
unlistedRepliesItem=new CheckableListItem<>(R.string.mo_change_default_reply_visibility_to_unlisted, R.string.mo_setting_default_reply_privacy_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.defaultToUnlistedReplies, R.drawable.ic_fluent_lock_open_24_regular, i->toggleCheckableItem(unlistedRepliesItem), true),
|
||||
unlistedRepliesItem=new CheckableListItem<>(R.string.mo_change_default_reply_visibility_to_unlisted, R.string.mo_setting_default_reply_privacy_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.defaultToUnlistedReplies, R.drawable.ic_fluent_lock_open_24_regular, i->toggleCheckableItem(unlistedRepliesItem)),
|
||||
removeTrackingParams=new CheckableListItem<>(R.string.mo_settings_remove_tracking_params, R.string.mo_settings_remove_tracking_params_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.removeTrackingParams, R.drawable.ic_fluent_eye_tracking_off_24_filled, i->toggleCheckableItem(removeTrackingParams), true),
|
||||
lockedItem=new CheckableListItem<>(R.string.sk_settings_lock_account, 0, CheckableListItem.Style.SWITCH, self.locked, R.drawable.ic_fluent_person_available_24_regular, i->toggleCheckableItem(lockedItem))
|
||||
));
|
||||
|
||||
@@ -89,6 +90,7 @@ public class SettingsPrivacyFragment extends BaseSettingsFragment<Void>{
|
||||
public void onPause(){
|
||||
super.onPause();
|
||||
GlobalUserPreferences.defaultToUnlistedReplies=unlistedRepliesItem.checked;
|
||||
GlobalUserPreferences.removeTrackingParams=removeTrackingParams.checked;
|
||||
GlobalUserPreferences.save();
|
||||
AccountSession s=AccountSessionManager.get(accountID);
|
||||
Account self=s.self;
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
package org.joinmastodon.android.model;
|
||||
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.jsoup.internal.StringUtil;
|
||||
|
||||
import java.util.EnumSet;
|
||||
|
||||
public class AltTextFilter extends LegacyFilter {
|
||||
|
||||
public AltTextFilter(FilterAction filterAction, FilterContext firstContext, FilterContext... restContexts) {
|
||||
this.filterAction = filterAction;
|
||||
isRemote = false;
|
||||
context = EnumSet.of(firstContext, restContexts);
|
||||
public AltTextFilter(FilterAction filterAction, EnumSet<FilterContext> filterContexts) {
|
||||
this.filterAction=filterAction;
|
||||
this.title=MastodonApp.context.getString(R.string.sk_no_alt_text);
|
||||
this.isRemote=false;
|
||||
this.context=filterContexts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matches(Status status) {
|
||||
return status.getContentStatus().mediaAttachments.stream().map(attachment -> attachment.description).anyMatch(StringUtil::isBlank);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isActive(){
|
||||
return !GlobalUserPreferences.showPostsWithoutAlt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ public class EmojiReaction {
|
||||
reaction.staticUrl=info.staticUrl;
|
||||
reaction.accounts=new ArrayList<>(Collections.singleton(me));
|
||||
reaction.accountIds=new ArrayList<>(Collections.singleton(me.id));
|
||||
reaction.request=new UrlImageLoaderRequest(info.url, V.sp(24), V.sp(24));
|
||||
reaction.request=new UrlImageLoaderRequest(info.url, 0, V.sp(24));
|
||||
return reaction;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.joinmastodon.android.model;
|
||||
|
||||
import android.text.Html;
|
||||
import android.util.Log;
|
||||
|
||||
import org.joinmastodon.android.api.ObjectValidationException;
|
||||
import org.joinmastodon.android.api.RequiredField;
|
||||
@@ -8,6 +9,7 @@ import org.joinmastodon.android.model.catalog.CatalogInstance;
|
||||
import org.parceler.Parcel;
|
||||
|
||||
import java.net.IDN;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -166,6 +168,31 @@ public class Instance extends BaseModel{
|
||||
.orElse(false);
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Returns true if the instance version is the same as or newer than the passed in version.
|
||||
* @param major: The major version to check for.
|
||||
* @param minor: the minor version to check for.
|
||||
* @param patch: The patch version to check for.
|
||||
*/
|
||||
public boolean checkVersion(int major, int minor, int patch) {
|
||||
try{
|
||||
String[] parts=version.split("-", 2);
|
||||
String[] numbers=parts[0].split("\\.", 3);
|
||||
if(numbers.length < 3)
|
||||
throw new IllegalArgumentException("Invalid version format. Expected format: major.minor.micro");
|
||||
|
||||
int majorVersion=Integer.parseInt(numbers[0]);
|
||||
int minorVersion=Integer.parseInt(numbers[1]);
|
||||
int patchVersion=Integer.parseInt(numbers[2]);
|
||||
return (majorVersion > major ||
|
||||
(majorVersion == major && minorVersion > minor) ||
|
||||
(majorVersion == major && minorVersion == minor &&
|
||||
patchVersion>= patch));
|
||||
} catch(Exception e) {
|
||||
Log.w("Instance", "checkVersion: failed to parse " + version + ", " + e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Feature {
|
||||
BUBBLE_TIMELINE,
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.annotation.StringRes;
|
||||
public class PushNotification extends BaseModel{
|
||||
public String accessToken;
|
||||
public String preferredLocale;
|
||||
public long notificationId;
|
||||
public String notificationId;
|
||||
@RequiredField
|
||||
public Type notificationType;
|
||||
@RequiredField
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
package org.joinmastodon.android.model;
|
||||
|
||||
import android.util.Patterns;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.joinmastodon.android.api.ObjectValidationException;
|
||||
import org.joinmastodon.android.api.RequiredField;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.model.Poll.Option;
|
||||
import org.parceler.Parcel;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Parcel
|
||||
public class ScheduledStatus extends BaseModel implements DisplayItemsParent{
|
||||
private static final Pattern HIGHLIGHT_PATTER=Pattern.compile("(?<!\\w)(?:@([a-z0-9_]+)(@[a-z0-9_\\.\\-]*)?|#([^\\s.]+)|:([a-z0-9_]+))|" +Patterns.WEB_URL, Pattern.CASE_INSENSITIVE);
|
||||
|
||||
@RequiredField
|
||||
public String id;
|
||||
@RequiredField
|
||||
@@ -87,7 +97,61 @@ public class ScheduledStatus extends BaseModel implements DisplayItemsParent{
|
||||
s.visibility=params.visibility;
|
||||
s.language=params.language;
|
||||
s.sensitive=params.sensitive;
|
||||
// hide media preview only if status is marked as sensitive
|
||||
s.sensitiveRevealed=!params.sensitive;
|
||||
if(params.poll!=null) s.poll=params.poll.toPoll();
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fake status, which has (somewhat) correctly formatted mentions, hashtags and URLs.
|
||||
*
|
||||
* @param accountID the ID of the account
|
||||
* @return the formatted Status object
|
||||
*/
|
||||
public Status toFormattedStatus(String accountID){
|
||||
AccountSession self=AccountSessionManager.get(accountID);
|
||||
Status s=this.toStatus();
|
||||
// the mastodon api does not return formatted (html) content, only the raw content, so we modify it
|
||||
s.content=s.content.replace("\n", "<br>");
|
||||
if(!s.content.contains("@") && !s.content.contains("#") && !s.content.contains(":"))
|
||||
return s;
|
||||
|
||||
StringBuffer sb=new StringBuffer();
|
||||
Matcher matcher=HIGHLIGHT_PATTER.matcher(s.content);
|
||||
|
||||
// I'm sure this will cause problems at some point...
|
||||
while(matcher.find()){
|
||||
String content=matcher.group();
|
||||
String href="";
|
||||
// add relevant links, so on-click actions work
|
||||
// hashtags are done by the parser
|
||||
if(content.startsWith("@"))
|
||||
href=" href=\""+formatMention(content, self.domain)+"\" class=\"u-url mention\"";
|
||||
else if(content.startsWith("https://"))
|
||||
href=" href=\""+content+"\"";
|
||||
|
||||
matcher.appendReplacement(sb, "<a"+href+">"+content+"</a>");
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
s.content=sb.toString();
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string mention into a URL of the account.
|
||||
* @param mention Mention in the form a of user name with an optional instance URL
|
||||
* @param instanceURL URL of the home instance of the user
|
||||
* @return Formatted HTML or the mention
|
||||
*/
|
||||
@NonNull
|
||||
private static String formatMention(@NonNull String mention, @NonNull String instanceURL){
|
||||
String[] parts=mention.split("@");
|
||||
if(parts.length>1){
|
||||
String username=parts[1];
|
||||
String domain=parts.length==3 ? parts[2] : instanceURL;
|
||||
return "https://"+domain+"/@"+username;
|
||||
}
|
||||
return mention;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +336,7 @@ public class TimelineDefinition {
|
||||
THUNDERSTORM(R.drawable.ic_fluent_weather_thunderstorm_24_regular, R.string.sk_icon_thunderstorm),
|
||||
RAIN(R.drawable.ic_fluent_weather_rain_24_regular, R.string.sk_icon_rain),
|
||||
SNOWFLAKE(R.drawable.ic_fluent_weather_snowflake_24_regular, R.string.sk_icon_snowflake),
|
||||
GNOME(R.drawable.ic_gnome_logo, R.string.mo_icon_gnome),
|
||||
|
||||
HOME(R.drawable.ic_fluent_home_24_regular, R.string.sk_timeline_home, true),
|
||||
LOCAL(R.drawable.ic_fluent_people_community_24_regular, R.string.sk_timeline_local, true),
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package org.joinmastodon.android.model;
|
||||
|
||||
import org.joinmastodon.android.api.AllFieldsAreRequired;
|
||||
import org.joinmastodon.android.api.RequiredField;
|
||||
|
||||
/**
|
||||
* Represents an OAuth token used for authenticating with the API and performing actions.
|
||||
*/
|
||||
@AllFieldsAreRequired
|
||||
public class Token extends BaseModel{
|
||||
/**
|
||||
* An OAuth token to be used for authorization.
|
||||
*/
|
||||
@RequiredField
|
||||
public String accessToken;
|
||||
/**
|
||||
* The OAuth token type. Mastodon uses Bearer tokens.
|
||||
@@ -23,5 +23,6 @@ public class Token extends BaseModel{
|
||||
* When the token was generated.
|
||||
* (unixtime)
|
||||
*/
|
||||
@RequiredField
|
||||
public long createdAt;
|
||||
}
|
||||
|
||||
@@ -172,7 +172,18 @@ public class AccountCardStatusDisplayItem extends StatusDisplayItem{
|
||||
|
||||
private void onFollowRequestButtonClick(View v) {
|
||||
itemView.setHasTransientState(true);
|
||||
UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, item.parentFragment.getAccountID(), null, v == acceptButton, relationship, rel -> {
|
||||
UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, item.parentFragment.getAccountID(), null, v == acceptButton, relationship, (Boolean visible) -> {
|
||||
if(v==acceptButton){
|
||||
acceptButton.setTextVisible(!visible);
|
||||
acceptProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
|
||||
acceptButton.setClickable(!visible);
|
||||
}else{
|
||||
rejectButton.setTextVisible(!visible);
|
||||
rejectProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
|
||||
rejectButton.setClickable(!visible);
|
||||
}
|
||||
itemView.setHasTransientState(false);
|
||||
}, rel -> {
|
||||
if(v.getContext()==null || rel==null) return;
|
||||
itemView.setHasTransientState(false);
|
||||
item.parentFragment.putRelationship(item.account.id, rel);
|
||||
@@ -214,8 +225,8 @@ public class AccountCardStatusDisplayItem extends StatusDisplayItem{
|
||||
cover.setImageDrawable(image);
|
||||
}else{
|
||||
item.emojiHelper.setImageDrawable(index-2, image);
|
||||
name.invalidate();
|
||||
bio.invalidate();
|
||||
name.setText(name.getText());
|
||||
bio.setText(bio.getText());
|
||||
}
|
||||
if(image instanceof Animatable && !((Animatable) image).isRunning())
|
||||
((Animatable) image).start();
|
||||
|
||||
@@ -172,7 +172,7 @@ public class EmojiReactionsStatusDisplayItem extends StatusDisplayItem {
|
||||
addButton.setSelected(false);
|
||||
AccountSession session=item.parentFragment.getSession();
|
||||
item.status.reactions.forEach(r->r.request=r.getUrl(item.playGifs)!=null
|
||||
? new UrlImageLoaderRequest(r.getUrl(item.playGifs), V.sp(24), V.sp(24))
|
||||
? new UrlImageLoaderRequest(r.getUrl(item.playGifs), 0, V.sp(24))
|
||||
: null);
|
||||
emojiKeyboard=new CustomEmojiPopupKeyboard(
|
||||
(Activity) item.parentFragment.getContext(),
|
||||
@@ -342,7 +342,9 @@ public class EmojiReactionsStatusDisplayItem extends StatusDisplayItem {
|
||||
|
||||
@Override
|
||||
public void setImage(int index, Drawable drawable){
|
||||
drawable.setBounds(0, 0, V.sp(24), V.sp(24));
|
||||
int height=V.sp(24);
|
||||
int width=drawable.getIntrinsicWidth()*height/drawable.getIntrinsicHeight();
|
||||
drawable.setBounds(0, 0, width, height);
|
||||
btn.setCompoundDrawablesRelative(drawable, null, null, null);
|
||||
if(drawable instanceof Animatable) ((Animatable) drawable).start();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.joinmastodon.android.ui.displayitems;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
|
||||
import org.joinmastodon.android.BuildConfig;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.fragments.BaseStatusListFragment;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
public class ErrorStatusDisplayItem extends StatusDisplayItem{
|
||||
private final Exception exception;
|
||||
|
||||
public ErrorStatusDisplayItem(String parentID, Status status, BaseStatusListFragment<?> parentFragment, Exception exception) {
|
||||
super(parentID, parentFragment);
|
||||
this.exception=exception;
|
||||
this.status=status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Type getType() {
|
||||
return Type.ERROR_ITEM;
|
||||
}
|
||||
|
||||
public static class Holder extends StatusDisplayItem.Holder<ErrorStatusDisplayItem> {
|
||||
private final Button openInBrowserButton;
|
||||
|
||||
public Holder(Context context, ViewGroup parent) {
|
||||
super(context, R.layout.display_item_error, parent);
|
||||
openInBrowserButton=findViewById(R.id.button_open_browser);
|
||||
openInBrowserButton.setOnClickListener(v -> UiUtils.launchWebBrowser(v.getContext(), item.status.url));
|
||||
findViewById(R.id.button_copy_error_details).setOnClickListener(this::copyErrorDetails);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
// explicitly do nothing when clicked
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled(){
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(ErrorStatusDisplayItem item) {
|
||||
openInBrowserButton.setEnabled(item.status!=null && item.status.url!=null);
|
||||
}
|
||||
|
||||
private void copyErrorDetails(View v) {
|
||||
StringWriter stringWriter=new StringWriter();
|
||||
PrintWriter printWriter=new PrintWriter(stringWriter);
|
||||
item.exception.printStackTrace(printWriter);
|
||||
String stackTrace=stringWriter.toString();
|
||||
|
||||
String errorDetails=String.format(
|
||||
"App Version: %s\nOS Version: %s\nStatus URL: %s\nException: %s",
|
||||
v.getContext().getString(R.string.mo_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE),
|
||||
"Android " + Build.VERSION.RELEASE,
|
||||
item.status.url,
|
||||
stackTrace
|
||||
);
|
||||
UiUtils.copyText(v, errorDetails);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.joinmastodon.android.ui.displayitems;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.SpannableStringBuilder;
|
||||
@@ -94,6 +95,7 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
|
||||
public void onBind(ExtendedFooterStatusDisplayItem item){
|
||||
Status s=item.status;
|
||||
favorites.setText(getFormattedPlural(R.plurals.x_favorites, item.status.favouritesCount));
|
||||
favorites.setCompoundDrawablesRelativeWithIntrinsicBounds(GlobalUserPreferences.likeIcon ? R.drawable.ic_fluent_heart_20_regular : R.drawable.ic_fluent_star_20_regular, 0, 0, 0);
|
||||
reblogs.setText(getFormattedPlural(R.plurals.x_reblogs, item.status.reblogsCount));
|
||||
if(s.editedAt!=null){
|
||||
editHistory.setVisibility(View.VISIBLE);
|
||||
|
||||
@@ -200,18 +200,10 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
|
||||
|
||||
private void onReplyClick(View v){
|
||||
if(item.status.preview) return;
|
||||
if(item.status.isRemote){
|
||||
UiUtils.lookupStatus(v.getContext(),
|
||||
item.status, item.accountID, null,
|
||||
status -> {
|
||||
UiUtils.opacityIn(v);
|
||||
openComposeView(status, item.accountID);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
UiUtils.opacityIn(v);
|
||||
openComposeView(item.status, item.accountID);
|
||||
applyInteraction(v, status -> {
|
||||
UiUtils.opacityIn(v);
|
||||
openComposeView(status, item.accountID);
|
||||
});
|
||||
}
|
||||
|
||||
private boolean onReplyLongClick(View v) {
|
||||
@@ -243,22 +235,13 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
|
||||
onBoostLongClick(v);
|
||||
return;
|
||||
}
|
||||
if(item.status.isRemote){
|
||||
UiUtils.lookupStatus(v.getContext(),
|
||||
item.status, item.accountID, null,
|
||||
status -> {
|
||||
if(status == null)
|
||||
return;
|
||||
boost.setSelected(!status.reblogged);
|
||||
vibrateForAction(boost, !status.reblogged);
|
||||
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(status, !status.reblogged, null, r->boostConsumer(v, r));
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
boost.setSelected(!item.status.reblogged);
|
||||
vibrateForAction(boost, !item.status.reblogged);
|
||||
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(item.status, !item.status.reblogged, null, r->boostConsumer(v, r));
|
||||
applyInteraction(v, status -> {
|
||||
if(status == null)
|
||||
return;
|
||||
boost.setSelected(!status.reblogged);
|
||||
vibrateForAction(boost, !status.reblogged);
|
||||
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(status, !status.reblogged, null, r->boostConsumer(v, r));
|
||||
});
|
||||
}
|
||||
|
||||
private void boostConsumer(View v, Status r) {
|
||||
@@ -275,22 +258,12 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
|
||||
|
||||
Consumer<StatusPrivacy> doReblog = (visibility) -> {
|
||||
UiUtils.opacityOut(v);
|
||||
if(item.status.isRemote){
|
||||
UiUtils.lookupStatus(v.getContext(),
|
||||
item.status, item.accountID, null,
|
||||
status -> {
|
||||
session.getStatusInteractionController()
|
||||
.setReblogged(status, !status.reblogged, visibility, r->boostConsumer(v, r));
|
||||
boost.setSelected(status.reblogged);
|
||||
dialog.dismiss();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
applyInteraction(v,status -> {
|
||||
session.getStatusInteractionController()
|
||||
.setReblogged(item.status, !item.status.reblogged, visibility, r->boostConsumer(v, r));
|
||||
boost.setSelected(item.status.reblogged);
|
||||
.setReblogged(status, !status.reblogged, visibility, r->boostConsumer(v, r));
|
||||
boost.setSelected(status.reblogged);
|
||||
dialog.dismiss();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
View separator = menu.findViewById(R.id.separator);
|
||||
@@ -364,33 +337,18 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
|
||||
|
||||
private void onFavoriteClick(View v){
|
||||
if(item.status.preview) return;
|
||||
if(item.status.isRemote){
|
||||
UiUtils.lookupStatus(v.getContext(),
|
||||
item.status, item.accountID, null,
|
||||
status -> {
|
||||
if(status == null)
|
||||
return;
|
||||
favorite.setSelected(!status.favourited);
|
||||
vibrateForAction(favorite, !status.favourited);
|
||||
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setFavorited(status, !status.favourited, r->{
|
||||
if (status.favourited && !GlobalUserPreferences.reduceMotion && !GlobalUserPreferences.likeIcon) {
|
||||
v.startAnimation(spin);
|
||||
}
|
||||
UiUtils.opacityIn(v);
|
||||
bindText(favorites, r.favouritesCount);
|
||||
});
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
favorite.setSelected(!item.status.favourited);
|
||||
vibrateForAction(favorite, !item.status.favourited);
|
||||
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setFavorited(item.status, !item.status.favourited, r->{
|
||||
if (item.status.favourited && !GlobalUserPreferences.reduceMotion && !GlobalUserPreferences.likeIcon) {
|
||||
v.startAnimation(spin);
|
||||
}
|
||||
UiUtils.opacityIn(v);
|
||||
bindText(favorites, r.favouritesCount);
|
||||
applyInteraction(v, status -> {
|
||||
if(status == null)
|
||||
return;
|
||||
favorite.setSelected(!status.favourited);
|
||||
vibrateForAction(favorite, !status.favourited);
|
||||
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setFavorited(status, !status.favourited, r->{
|
||||
if (status.favourited && !GlobalUserPreferences.reduceMotion && !GlobalUserPreferences.likeIcon) {
|
||||
v.startAnimation(spin);
|
||||
}
|
||||
UiUtils.opacityIn(v);
|
||||
bindText(favorites, r.favouritesCount);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -411,26 +369,16 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
|
||||
|
||||
private void onBookmarkClick(View v){
|
||||
if(item.status.preview) return;
|
||||
if(item.status.isRemote){
|
||||
UiUtils.lookupStatus(v.getContext(),
|
||||
item.status, item.accountID, null,
|
||||
status -> {
|
||||
if(status == null)
|
||||
return;
|
||||
bookmark.setSelected(!status.bookmarked);
|
||||
vibrateForAction(bookmark, !status.bookmarked);
|
||||
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(status, !status.bookmarked, r->{
|
||||
UiUtils.opacityIn(v);
|
||||
});
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
bookmark.setSelected(!item.status.bookmarked);
|
||||
vibrateForAction(bookmark, !item.status.bookmarked);
|
||||
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(item.status, !item.status.bookmarked, r->{
|
||||
UiUtils.opacityIn(v);
|
||||
});
|
||||
applyInteraction(v,
|
||||
status -> {
|
||||
if(status == null)
|
||||
return;
|
||||
bookmark.setSelected(!status.bookmarked);
|
||||
vibrateForAction(bookmark, !status.bookmarked);
|
||||
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setBookmarked(status, !status.bookmarked, r->{
|
||||
UiUtils.opacityIn(v);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private boolean onBookmarkLongClick(View v) {
|
||||
@@ -451,10 +399,7 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
|
||||
private void onShareClick(View v){
|
||||
if(item.status.preview) return;
|
||||
UiUtils.opacityIn(v);
|
||||
Intent intent=new Intent(Intent.ACTION_SEND);
|
||||
intent.setType("text/plain");
|
||||
intent.putExtra(Intent.EXTRA_TEXT, item.status.url);
|
||||
v.getContext().startActivity(Intent.createChooser(intent, v.getContext().getString(R.string.share_toot_title)));
|
||||
UiUtils.openSystemShareSheet(v.getContext(), item.status);
|
||||
}
|
||||
|
||||
private boolean onShareLongClick(View v){
|
||||
@@ -477,25 +422,37 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void applyInteraction(View v, Consumer<Status> interactionConsumer) {
|
||||
if(!item.status.isRemote){
|
||||
interactionConsumer.accept(item.status);
|
||||
return;
|
||||
}
|
||||
UiUtils.lookupStatus(v.getContext(),
|
||||
item.status, item.accountID, null,
|
||||
interactionConsumer
|
||||
);
|
||||
}
|
||||
|
||||
private static void vibrateForAction(View view, boolean isPositive) {
|
||||
if (!GlobalUserPreferences.hapticFeedback) return;
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
view.performHapticFeedback(isPositive ? HapticFeedbackConstants.CONFIRM : HapticFeedbackConstants.REJECT);
|
||||
} else {
|
||||
Vibrator vibrator = view.getContext().getSystemService(Vibrator.class);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
vibrator.vibrate(VibrationEffect.createPredefined(isPositive ? VibrationEffect.EFFECT_CLICK : VibrationEffect.EFFECT_DOUBLE_CLICK));
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
VibrationEffect effect = isPositive
|
||||
? VibrationEffect.createOneShot(75L, 128)
|
||||
: VibrationEffect.createWaveform(new long[]{0L, 75L, 75L, 75L}, new int[]{0, 128, 0, 128}, -1);
|
||||
vibrator.vibrate(effect);
|
||||
} else {
|
||||
if (isPositive) vibrator.vibrate(75L);
|
||||
else vibrator.vibrate(new long[]{0L, 75L, 75L, 75L}, -1);
|
||||
}
|
||||
Vibrator vibrator = view.getContext().getSystemService(Vibrator.class);
|
||||
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
|
||||
vibrator.vibrate(VibrationEffect.createPredefined(isPositive ? VibrationEffect.EFFECT_CLICK : VibrationEffect.EFFECT_DOUBLE_CLICK));
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
VibrationEffect effect = isPositive
|
||||
? VibrationEffect.createOneShot(75L, 128)
|
||||
: VibrationEffect.createWaveform(new long[]{0L, 75L, 75L, 75L}, new int[]{0, 128, 0, 128}, -1);
|
||||
vibrator.vibrate(effect);
|
||||
} else {
|
||||
if (isPositive) vibrator.vibrate(75L);
|
||||
else vibrator.vibrate(new long[]{0L, 75L, 75L, 75L}, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,11 +175,11 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
|
||||
fragment.removeNotification(item.notification);
|
||||
}
|
||||
}));
|
||||
collapseBtn.setOnClickListener(l -> item.parentFragment.onToggleExpanded(item.status, getItemID()));
|
||||
collapseBtn.setOnClickListener(l -> item.parentFragment.onToggleExpanded(item.status, item.isForQuote, getItemID()));
|
||||
|
||||
optionsMenu=new PopupMenu(activity, more);
|
||||
optionsMenu.inflate(R.menu.post);
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI())
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI() && !UiUtils.isMagic())
|
||||
optionsMenu.getMenu().setGroupDividerEnabled(true);
|
||||
optionsMenu.setOnMenuItemClickListener(menuItem->{
|
||||
Account account=item.user;
|
||||
@@ -289,7 +289,11 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
|
||||
args.putString("profileDisplayUsername", account.getDisplayUsername());
|
||||
Nav.go(item.parentFragment.getActivity(), ListsFragment.class, args);
|
||||
}else if(id==R.id.share){
|
||||
UiUtils.openSystemShareSheet(activity, item.status.url);
|
||||
UiUtils.openSystemShareSheet(activity, item.status);
|
||||
}else if(id==R.id.open_with_account){
|
||||
UiUtils.pickAccount(item.parentFragment.getActivity(), item.accountID, R.string.sk_open_with_account, R.drawable.ic_fluent_person_swap_24_regular, session ->UiUtils.openURL(
|
||||
item.parentFragment.getActivity(), session.getID(), item.status.url, false
|
||||
), null);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -418,7 +422,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
|
||||
public void setImage(int index, Drawable drawable){
|
||||
if(index>0){
|
||||
item.emojiHelper.setImageDrawable(index-1, drawable);
|
||||
name.invalidate();
|
||||
name.setText(name.getText());
|
||||
}else{
|
||||
avatar.setImageDrawable(drawable);
|
||||
}
|
||||
@@ -489,17 +493,6 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
|
||||
Account account=item.user;
|
||||
Menu menu=optionsMenu.getMenu();
|
||||
|
||||
MenuItem openWithAccounts = menu.findItem(R.id.open_with_account);
|
||||
SubMenu accountsMenu = openWithAccounts != null ? openWithAccounts.getSubMenu() : null;
|
||||
if (hasMultipleAccounts && accountsMenu != null) {
|
||||
openWithAccounts.setVisible(true);
|
||||
accountsMenu.clear();
|
||||
UiUtils.populateAccountsMenu(item.accountID, accountsMenu, s-> UiUtils.openURL(
|
||||
item.parentFragment.getActivity(), s.getID(), item.status.url, false
|
||||
));
|
||||
} else if (openWithAccounts != null) {
|
||||
openWithAccounts.setVisible(false);
|
||||
}
|
||||
|
||||
String username = account.getShortUsername();
|
||||
boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account);
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
@@ -18,12 +17,15 @@ import org.joinmastodon.android.model.Card;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable;
|
||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
|
||||
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.V;
|
||||
|
||||
public class LinkCardStatusDisplayItem extends StatusDisplayItem{
|
||||
private final UrlImageLoaderRequest imgRequest;
|
||||
@@ -142,7 +144,35 @@ public class LinkCardStatusDisplayItem extends StatusDisplayItem{
|
||||
}
|
||||
|
||||
private void onClick(View v){
|
||||
UiUtils.openURL(itemView.getContext(), item.parentFragment.getAccountID(), item.status.card.url);
|
||||
String url=item.status.card.url;
|
||||
// Mastodon.social sometimes adds an additional redirect page
|
||||
// this is really disruptive on mobile, especially since it breaks the loopUp/openURL functionality
|
||||
Uri parsedURL=Uri.parse(url);
|
||||
if(parsedURL.getPath()!=null && parsedURL.getPath().startsWith("/redirect/")){
|
||||
url=findRedirectedURL(parsedURL).orElse(url);
|
||||
}
|
||||
UiUtils.openURL(itemView.getContext(), item.parentFragment.getAccountID(), url);
|
||||
}
|
||||
|
||||
private Optional<String> findRedirectedURL(Uri url){
|
||||
// find actually linked url in status content
|
||||
Matcher matcher=HtmlParser.URL_PATTERN.matcher(item.status.content);
|
||||
boolean isAccountRedirect=url.getPath().startsWith("/redirect/accounts");
|
||||
String foundURL;
|
||||
while(matcher.find()){
|
||||
foundURL=matcher.group(3);
|
||||
if(TextUtils.isEmpty(matcher.group(4)))
|
||||
foundURL="http://"+foundURL;
|
||||
// SAFETY: Cannot be null, as otherwise the matcher wouldn't find it
|
||||
// also, group is marked as non-null
|
||||
assert foundURL!=null && url.getLastPathSegment()!=null;
|
||||
if(foundURL.endsWith(url.getLastPathSegment()) ||
|
||||
(isAccountRedirect && foundURL.matches("https://"+url.getHost()+"/@[a-zA-Z0-9_]+@[a-zA-Z0-9._]+$"))){
|
||||
// found correct URL
|
||||
return Optional.of(foundURL);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{
|
||||
avatar.setImageDrawable(image);
|
||||
}else{
|
||||
item.emojiHelper.setImageDrawable(index-1, image);
|
||||
text.invalidate();
|
||||
text.setText(text.getText());
|
||||
}
|
||||
if(image instanceof Animatable)
|
||||
((Animatable) image).start();
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package org.joinmastodon.android.ui.displayitems;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.app.Activity;
|
||||
import android.graphics.drawable.Animatable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
@@ -13,12 +18,10 @@ import org.joinmastodon.android.fragments.BaseStatusListFragment;
|
||||
import org.joinmastodon.android.model.Poll;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
|
||||
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
|
||||
@@ -29,7 +32,8 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
|
||||
private CharSequence translatedText;
|
||||
public final Poll.Option option;
|
||||
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
|
||||
private boolean showResults;
|
||||
public boolean showResults;
|
||||
public boolean isAnimating;
|
||||
private float votesFraction; // 0..1
|
||||
private boolean isMostVoted;
|
||||
private final int optionIndex;
|
||||
@@ -80,6 +84,7 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
|
||||
private final View button;
|
||||
private final ImageView icon;
|
||||
private final Drawable progressBg;
|
||||
private static final int ANIMATION_DURATION=500;
|
||||
|
||||
public Holder(Activity activity, ViewGroup parent){
|
||||
super(activity, R.layout.display_item_poll_option, parent);
|
||||
@@ -121,12 +126,17 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
|
||||
}
|
||||
text.setTextColor(UiUtils.getThemeColor(itemView.getContext(), android.R.attr.textColorPrimary));
|
||||
percent.setTextColor(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3OnSecondaryContainer));
|
||||
|
||||
if (item.isAnimating) {
|
||||
showResults(item.showResults);
|
||||
item.isAnimating= false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setImage(int index, Drawable image){
|
||||
item.emojiHelper.setImageDrawable(index, image);
|
||||
text.invalidate();
|
||||
text.setText(text.getText());
|
||||
if(image instanceof Animatable){
|
||||
((Animatable) image).start();
|
||||
}
|
||||
@@ -135,7 +145,7 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
|
||||
@Override
|
||||
public void clearImage(int index){
|
||||
item.emojiHelper.setImageDrawable(index, null);
|
||||
text.invalidate();
|
||||
text.setText(text.getText());
|
||||
}
|
||||
|
||||
private void onButtonClick(View v){
|
||||
@@ -145,7 +155,34 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
|
||||
public void showResults(boolean shown) {
|
||||
item.showResults = shown;
|
||||
item.calculateResults();
|
||||
rebind();
|
||||
Drawable bg=progressBg;
|
||||
long animationDuration = (long) (ANIMATION_DURATION*item.votesFraction);
|
||||
int startLevel=shown ? 0 : progressBg.getLevel();
|
||||
int targetLevel=shown ? Math.round(10000f*item.votesFraction) : 0;
|
||||
ObjectAnimator animator=ObjectAnimator.ofInt(bg, "level", startLevel, targetLevel);
|
||||
animator.setDuration(animationDuration);
|
||||
animator.setInterpolator(new DecelerateInterpolator());
|
||||
button.setBackground(bg);
|
||||
if(shown){
|
||||
itemView.setSelected(item.poll.ownVotes!=null && item.poll.ownVotes.contains(item.optionIndex));
|
||||
// animate percent
|
||||
percent.setVisibility(View.VISIBLE);
|
||||
ValueAnimator percentAnimation=ValueAnimator.ofInt(0, Math.round(100f*item.votesFraction));
|
||||
percentAnimation.setDuration(animationDuration);
|
||||
percentAnimation.setInterpolator(new DecelerateInterpolator());
|
||||
percentAnimation.addUpdateListener(animation -> percent.setText(String.format(Locale.getDefault(), "%d%%", (int) animation.getAnimatedValue())));
|
||||
percentAnimation.start();
|
||||
}else{
|
||||
animator.addListener(new AnimatorListenerAdapter(){
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation){
|
||||
button.setBackgroundResource(R.drawable.bg_poll_option_clickable);
|
||||
}
|
||||
});
|
||||
itemView.setSelected(item.poll.selectedOptions!=null && item.poll.selectedOptions.contains(item.option));
|
||||
percent.setVisibility(View.GONE);
|
||||
}
|
||||
animator.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +152,8 @@ public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{
|
||||
int firstHelperCount=item.emojiHelper.getImageCount();
|
||||
CustomEmojiHelper helper=index<firstHelperCount ? item.emojiHelper : item.extra.emojiHelper;
|
||||
helper.setImageDrawable(firstHelperCount>0 ? index%firstHelperCount : index, image);
|
||||
text.invalidate();
|
||||
extraText.invalidate();
|
||||
text.setText(text.getText());
|
||||
extraText.setText(extraText.getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -114,7 +114,7 @@ public class SpoilerStatusDisplayItem extends StatusDisplayItem{
|
||||
@Override
|
||||
public void setImage(int index, Drawable image){
|
||||
item.emojiHelper.setImageDrawable(index, image);
|
||||
title.invalidate();
|
||||
title.setText(title.getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -10,13 +10,17 @@ import android.graphics.drawable.ColorDrawable;
|
||||
import android.os.Bundle;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
|
||||
import org.joinmastodon.android.api.requests.search.GetSearchResults;
|
||||
import org.joinmastodon.android.api.session.AccountLocalPreferences;
|
||||
import org.joinmastodon.android.api.session.AccountSession;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.fragments.BaseStatusListFragment;
|
||||
import org.joinmastodon.android.fragments.HashtagTimelineFragment;
|
||||
@@ -28,30 +32,35 @@ import org.joinmastodon.android.fragments.ThreadFragment;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
import org.joinmastodon.android.model.Attachment;
|
||||
import org.joinmastodon.android.model.DisplayItemsParent;
|
||||
import org.joinmastodon.android.model.FilterAction;
|
||||
import org.joinmastodon.android.model.LegacyFilter;
|
||||
import org.joinmastodon.android.model.FilterContext;
|
||||
import org.joinmastodon.android.model.FilterResult;
|
||||
import org.joinmastodon.android.model.LegacyFilter;
|
||||
import org.joinmastodon.android.model.Notification;
|
||||
import org.joinmastodon.android.model.Poll;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
import org.joinmastodon.android.model.ScheduledStatus;
|
||||
import org.joinmastodon.android.model.SearchResults;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.PhotoLayoutHelper;
|
||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
|
||||
import org.joinmastodon.android.utils.StatusFilterPredicate;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
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.imageloader.requests.ImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.BindableViewHolder;
|
||||
import me.grishka.appkit.views.UsableRecyclerView;
|
||||
@@ -79,6 +88,10 @@ public abstract class StatusDisplayItem{
|
||||
public static final int FLAG_IS_FOR_QUOTE=1 << 7;
|
||||
public static final int FLAG_NO_MEDIA_PREVIEW=1 << 8;
|
||||
|
||||
|
||||
private final static Pattern QUOTE_MENTION_PATTERN=Pattern.compile("(?:<p>)?\\s?(?:RE:\\s?(<br\\s?\\/?>)?)?<a href=\"https:\\/\\/[^\"]+\"[^>]*><span class=\"invisible\">https:\\/\\/<\\/span><span class=\"ellipsis\">[^<]+<\\/span><span class=\"invisible\">[^<]+<\\/span><\\/a>(?:<\\/p>)?$");
|
||||
private final static Pattern QUOTE_PATTERN=Pattern.compile("https://[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,8}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$");
|
||||
|
||||
public void setAncestryInfo(
|
||||
boolean hasDescendantNeighbor,
|
||||
boolean hasAncestoringNeighbor,
|
||||
@@ -141,6 +154,7 @@ public abstract class StatusDisplayItem{
|
||||
case SPOILER, FILTER_SPOILER -> new SpoilerStatusDisplayItem.Holder(activity, parent, type);
|
||||
case SECTION_HEADER -> null; // new SectionHeaderStatusDisplayItem.Holder(activity, parent);
|
||||
case NOTIFICATION_HEADER -> new NotificationHeaderStatusDisplayItem.Holder(activity, parent);
|
||||
case ERROR_ITEM -> new ErrorStatusDisplayItem.Holder(activity, parent);
|
||||
case DUMMY -> new DummyStatusDisplayItem.Holder(activity);
|
||||
};
|
||||
}
|
||||
@@ -166,205 +180,202 @@ public abstract class StatusDisplayItem{
|
||||
Status statusForContent=status.getContentStatus();
|
||||
Bundle args=new Bundle();
|
||||
args.putString("account", accountID);
|
||||
ScheduledStatus scheduledStatus = parentObject instanceof ScheduledStatus s ? s : null;
|
||||
try{
|
||||
ScheduledStatus scheduledStatus=parentObject instanceof ScheduledStatus s ? s : null;
|
||||
|
||||
// Hide statuses that have a filter action of hide
|
||||
if(!new StatusFilterPredicate(accountID, filterContext, FilterAction.HIDE).test(status))
|
||||
return new ArrayList<StatusDisplayItem>() ;
|
||||
HeaderStatusDisplayItem header=null;
|
||||
boolean hideCounts=!AccountSessionManager.get(accountID).getLocalPreferences().showInteractionCounts;
|
||||
|
||||
HeaderStatusDisplayItem header=null;
|
||||
boolean hideCounts=!AccountSessionManager.get(accountID).getLocalPreferences().showInteractionCounts;
|
||||
if((flags&FLAG_NO_HEADER)==0){
|
||||
ReblogOrReplyLineStatusDisplayItem replyLine=null;
|
||||
boolean threadReply=statusForContent.inReplyToAccountId!=null &&
|
||||
statusForContent.inReplyToAccountId.equals(statusForContent.account.id);
|
||||
|
||||
if((flags & FLAG_NO_HEADER)==0){
|
||||
ReblogOrReplyLineStatusDisplayItem replyLine = null;
|
||||
boolean threadReply = statusForContent.inReplyToAccountId != null &&
|
||||
statusForContent.inReplyToAccountId.equals(statusForContent.account.id);
|
||||
if(statusForContent.inReplyToAccountId!=null && !(threadReply && fragment instanceof ThreadFragment)){
|
||||
Account account=knownAccounts.get(statusForContent.inReplyToAccountId);
|
||||
replyLine=buildReplyLine(fragment, status, accountID, parentObject, account, threadReply);
|
||||
}
|
||||
|
||||
if(statusForContent.inReplyToAccountId!=null && !(threadReply && fragment instanceof ThreadFragment)){
|
||||
Account account = knownAccounts.get(statusForContent.inReplyToAccountId);
|
||||
replyLine = buildReplyLine(fragment, status, accountID, parentObject, account, threadReply);
|
||||
if(status.reblog!=null){
|
||||
boolean isOwnPost=AccountSessionManager.getInstance().isSelf(fragment.getAccountID(), status.account);
|
||||
|
||||
statusForContent.rebloggedBy=status.account;
|
||||
|
||||
String text=fragment.getString(R.string.user_boosted, status.account.getDisplayName());
|
||||
items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, text, status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20sp_filled, isOwnPost ? status.visibility : null, i->{
|
||||
args.putParcelable("profileAccount", Parcels.wrap(status.account));
|
||||
Nav.go(fragment.getActivity(), ProfileFragment.class, args);
|
||||
}, null, status, status.account));
|
||||
}else if(!(status.tags.isEmpty() ||
|
||||
fragment instanceof HashtagTimelineFragment ||
|
||||
fragment instanceof ListTimelineFragment
|
||||
) && fragment.getParentFragment() instanceof HomeTabFragment home){
|
||||
home.getHashtags().stream()
|
||||
.filter(followed->status.tags.stream()
|
||||
.anyMatch(hashtag->followed.name.equalsIgnoreCase(hashtag.name)))
|
||||
.findAny()
|
||||
// post contains a hashtag the user is following
|
||||
.ifPresent(hashtag->items.add(new ReblogOrReplyLineStatusDisplayItem(
|
||||
parentID, fragment, hashtag.name, List.of(),
|
||||
R.drawable.ic_fluent_number_symbol_20sp_filled, null,
|
||||
i->UiUtils.openHashtagTimeline(fragment.getActivity(), accountID, hashtag),
|
||||
status
|
||||
)));
|
||||
}
|
||||
|
||||
if(replyLine!=null){
|
||||
Optional<ReblogOrReplyLineStatusDisplayItem> primaryLine=items.stream()
|
||||
.filter(i->i instanceof ReblogOrReplyLineStatusDisplayItem)
|
||||
.map(ReblogOrReplyLineStatusDisplayItem.class::cast)
|
||||
.findFirst();
|
||||
|
||||
if(primaryLine.isPresent()){
|
||||
primaryLine.get().extra=replyLine;
|
||||
}else{
|
||||
items.add(replyLine);
|
||||
}
|
||||
}
|
||||
|
||||
if((flags&FLAG_CHECKABLE)!=0)
|
||||
items.add(header=new CheckableHeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null));
|
||||
else
|
||||
items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null, parentObject instanceof Notification n ? n : null, scheduledStatus));
|
||||
}
|
||||
|
||||
if(status.reblog!=null){
|
||||
boolean isOwnPost = AccountSessionManager.getInstance().isSelf(fragment.getAccountID(), status.account);
|
||||
LegacyFilter applyingFilter=null;
|
||||
if(status.filtered!=null){
|
||||
ArrayList<FilterResult> filters= new ArrayList<>(status.filtered);
|
||||
|
||||
statusForContent.rebloggedBy = status.account;
|
||||
// Only add client filters if there are no pre-existing status filter
|
||||
if(filters.isEmpty())
|
||||
filters.addAll(AccountSessionManager.get(accountID).getClientSideFilters(status));
|
||||
|
||||
String text=fragment.getString(R.string.user_boosted, status.account.getDisplayName());
|
||||
items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, text, status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20sp_filled, isOwnPost ? status.visibility : null, i->{
|
||||
args.putParcelable("profileAccount", Parcels.wrap(status.account));
|
||||
Nav.go(fragment.getActivity(), ProfileFragment.class, args);
|
||||
}, null, status, status.account));
|
||||
} else if (!(status.tags.isEmpty() ||
|
||||
fragment instanceof HashtagTimelineFragment ||
|
||||
fragment instanceof ListTimelineFragment
|
||||
) && fragment.getParentFragment() instanceof HomeTabFragment home) {
|
||||
home.getHashtags().stream()
|
||||
.filter(followed -> status.tags.stream()
|
||||
.anyMatch(hashtag -> followed.name.equalsIgnoreCase(hashtag.name)))
|
||||
.findAny()
|
||||
// post contains a hashtag the user is following
|
||||
.ifPresent(hashtag -> items.add(new ReblogOrReplyLineStatusDisplayItem(
|
||||
parentID, fragment, hashtag.name, List.of(),
|
||||
R.drawable.ic_fluent_number_symbol_20sp_filled, null,
|
||||
i->UiUtils.openHashtagTimeline(fragment.getActivity(), accountID, hashtag),
|
||||
status
|
||||
)));
|
||||
}
|
||||
|
||||
if (replyLine != null) {
|
||||
Optional<ReblogOrReplyLineStatusDisplayItem> primaryLine = items.stream()
|
||||
.filter(i -> i instanceof ReblogOrReplyLineStatusDisplayItem)
|
||||
.map(ReblogOrReplyLineStatusDisplayItem.class::cast)
|
||||
.findFirst();
|
||||
|
||||
if (primaryLine.isPresent()) {
|
||||
primaryLine.get().extra = replyLine;
|
||||
} else {
|
||||
items.add(replyLine);
|
||||
for(FilterResult filter : filters){
|
||||
LegacyFilter f=filter.filter;
|
||||
if(f.isActive() && filterContext!=null && f.context.contains(filterContext)){
|
||||
applyingFilter=f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if((flags & FLAG_CHECKABLE)!=0)
|
||||
items.add(header=new CheckableHeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null));
|
||||
else
|
||||
items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null, parentObject instanceof Notification n ? n : null, scheduledStatus));
|
||||
}
|
||||
ArrayList<StatusDisplayItem> contentItems;
|
||||
if(statusForContent.hasSpoiler()){
|
||||
if(AccountSessionManager.get(accountID).getLocalPreferences().revealCWs) statusForContent.spoilerRevealed=true;
|
||||
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, null, statusForContent, Type.SPOILER);
|
||||
if((flags&FLAG_IS_FOR_QUOTE)!=0){
|
||||
for(StatusDisplayItem item : spoilerItem.contentItems){
|
||||
item.isForQuote=true;
|
||||
}
|
||||
}
|
||||
items.add(spoilerItem);
|
||||
contentItems=spoilerItem.contentItems;
|
||||
}else{
|
||||
contentItems=items;
|
||||
}
|
||||
|
||||
LegacyFilter applyingFilter=null;
|
||||
if(status.filtered!=null){
|
||||
for(FilterResult filter:status.filtered){
|
||||
LegacyFilter f=filter.filter;
|
||||
if(f.isActive() && filterContext != null && f.context.contains(filterContext)){
|
||||
applyingFilter=f;
|
||||
break;
|
||||
if(statusForContent.quote!=null){
|
||||
int quoteInlineIndex=statusForContent.content.lastIndexOf("<span class=\"quote-inline\"><br/><br/>RE:");
|
||||
if(quoteInlineIndex!=-1)
|
||||
statusForContent.content=statusForContent.content.substring(0, quoteInlineIndex);
|
||||
else {
|
||||
// hide non-official quote patters
|
||||
Matcher matcher=QUOTE_MENTION_PATTERN.matcher(status.content);
|
||||
if(matcher.find()){
|
||||
String quoteMention=matcher.group();
|
||||
statusForContent.content=statusForContent.content.replace(quoteMention, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Moshidon
|
||||
if(applyingFilter==null){
|
||||
StatusFilterPredicate predicate = new StatusFilterPredicate(accountID, filterContext, FilterAction.WARN);
|
||||
predicate.test(status);
|
||||
applyingFilter = predicate.getApplyingFilter();
|
||||
}
|
||||
}
|
||||
|
||||
ArrayList<StatusDisplayItem> contentItems;
|
||||
if(statusForContent.hasSpoiler()){
|
||||
if (AccountSessionManager.get(accountID).getLocalPreferences().revealCWs) statusForContent.spoilerRevealed = true;
|
||||
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, null, statusForContent, Type.SPOILER);
|
||||
if((flags & FLAG_IS_FOR_QUOTE)!=0){
|
||||
for(StatusDisplayItem item:spoilerItem.contentItems){
|
||||
item.isForQuote=true;
|
||||
}
|
||||
}
|
||||
items.add(spoilerItem);
|
||||
contentItems=spoilerItem.contentItems;
|
||||
}else{
|
||||
contentItems=items;
|
||||
}
|
||||
|
||||
if(statusForContent.quote!=null) {
|
||||
int quoteInlineIndex=statusForContent.content.lastIndexOf("<span class=\"quote-inline\"><br/><br/>RE:");
|
||||
if (quoteInlineIndex!=-1)
|
||||
statusForContent.content=statusForContent.content.substring(0, quoteInlineIndex);
|
||||
}
|
||||
|
||||
boolean hasSpoiler=!TextUtils.isEmpty(statusForContent.spoilerText);
|
||||
if(!TextUtils.isEmpty(statusForContent.content)){
|
||||
SpannableStringBuilder parsedText=HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, fragment.getContext());
|
||||
HtmlParser.applyFilterHighlights(fragment.getActivity(), parsedText, status.filtered);
|
||||
TextStatusDisplayItem text=new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, fragment.getContext()), fragment, statusForContent, (flags & FLAG_NO_TRANSLATE) != 0);
|
||||
contentItems.add(text);
|
||||
}else if(!hasSpoiler && header!=null){
|
||||
header.needBottomPadding=true;
|
||||
}else if(hasSpoiler){
|
||||
contentItems.add(new DummyStatusDisplayItem(parentID, fragment));
|
||||
}
|
||||
|
||||
List<Attachment> imageAttachments=statusForContent.mediaAttachments.stream().filter(att->att.type.isImage()).collect(Collectors.toList());
|
||||
if(!imageAttachments.isEmpty() && (flags & FLAG_NO_MEDIA_PREVIEW)==0){
|
||||
int color = UiUtils.getThemeColor(fragment.getContext(), R.attr.colorM3SurfaceVariant);
|
||||
for (Attachment att : imageAttachments) {
|
||||
if (att.blurhashPlaceholder == null) {
|
||||
att.blurhashPlaceholder = new ColorDrawable(color);
|
||||
}
|
||||
}
|
||||
PhotoLayoutHelper.TiledLayoutResult layout=PhotoLayoutHelper.processThumbs(imageAttachments);
|
||||
MediaGridStatusDisplayItem mediaGrid=new MediaGridStatusDisplayItem(parentID, fragment, layout, imageAttachments, statusForContent);
|
||||
if((flags & FLAG_MEDIA_FORCE_HIDDEN)!=0){
|
||||
mediaGrid.sensitiveTitle=fragment.getString(R.string.media_hidden);
|
||||
statusForContent.sensitiveRevealed=false;
|
||||
statusForContent.sensitive=true;
|
||||
} else if(statusForContent.sensitive && AccountSessionManager.get(accountID).getLocalPreferences().revealCWs && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia)
|
||||
statusForContent.sensitiveRevealed=true;
|
||||
contentItems.add(mediaGrid);
|
||||
}
|
||||
if((flags & FLAG_NO_MEDIA_PREVIEW)!=0){
|
||||
contentItems.add(new PreviewlessMediaGridStatusDisplayItem(parentID, fragment, null, imageAttachments, statusForContent));
|
||||
|
||||
}
|
||||
for(Attachment att:statusForContent.mediaAttachments){
|
||||
if(att.type==Attachment.Type.AUDIO){
|
||||
contentItems.add(new AudioStatusDisplayItem(parentID, fragment, statusForContent, att));
|
||||
}
|
||||
if(att.type==Attachment.Type.UNKNOWN){
|
||||
contentItems.add(new FileStatusDisplayItem(parentID, fragment, att));
|
||||
}
|
||||
}
|
||||
if(statusForContent.poll!=null){
|
||||
buildPollItems(parentID, fragment, statusForContent.poll, status, contentItems);
|
||||
}
|
||||
if(statusForContent.card!=null && statusForContent.mediaAttachments.isEmpty() && statusForContent.quote==null && !statusForContent.card.isHashtagUrl(statusForContent.url)){
|
||||
contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent, (flags & FLAG_NO_MEDIA_PREVIEW)==0));
|
||||
}
|
||||
if(statusForContent.quote!=null && !(parentObject instanceof Notification)){
|
||||
if(!statusForContent.mediaAttachments.isEmpty() && statusForContent.poll==null) // add spacing if immediately preceded by attachment
|
||||
boolean hasSpoiler=!TextUtils.isEmpty(statusForContent.spoilerText);
|
||||
if(!TextUtils.isEmpty(statusForContent.content)){
|
||||
SpannableStringBuilder parsedText=HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, fragment.getContext());
|
||||
if(applyingFilter!=null)
|
||||
HtmlParser.applyFilterHighlights(fragment.getActivity(), parsedText, status.filtered);
|
||||
TextStatusDisplayItem text=new TextStatusDisplayItem(parentID, parsedText, fragment, statusForContent, (flags&FLAG_NO_TRANSLATE)!=0);
|
||||
contentItems.add(text);
|
||||
}else if(!hasSpoiler && header!=null){
|
||||
header.needBottomPadding=true;
|
||||
}else if(hasSpoiler){
|
||||
contentItems.add(new DummyStatusDisplayItem(parentID, fragment));
|
||||
contentItems.addAll(buildItems(fragment, statusForContent.quote, accountID, parentObject, knownAccounts, filterContext, FLAG_NO_FOOTER | FLAG_INSET | FLAG_NO_EMOJI_REACTIONS | FLAG_IS_FOR_QUOTE));
|
||||
}
|
||||
if(contentItems!=items && statusForContent.spoilerRevealed){
|
||||
items.addAll(contentItems);
|
||||
}
|
||||
AccountLocalPreferences lp=fragment.getLocalPrefs();
|
||||
if((flags & FLAG_NO_EMOJI_REACTIONS)==0 && !status.preview && lp.emojiReactionsEnabled &&
|
||||
(lp.showEmojiReactions!=ONLY_OPENED || fragment instanceof ThreadFragment) &&
|
||||
statusForContent.reactions!=null){
|
||||
boolean isMainStatus=fragment instanceof ThreadFragment t && t.getMainStatus().id.equals(statusForContent.id);
|
||||
boolean showAddButton=lp.showEmojiReactions==ALWAYS || isMainStatus;
|
||||
items.add(new EmojiReactionsStatusDisplayItem(parentID, fragment, statusForContent, accountID, !showAddButton, false));
|
||||
}
|
||||
FooterStatusDisplayItem footer=null;
|
||||
if((flags & FLAG_NO_FOOTER)==0){
|
||||
footer=new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID);
|
||||
footer.hideCounts=hideCounts;
|
||||
items.add(footer);
|
||||
}
|
||||
boolean inset=(flags & FLAG_INSET)!=0;
|
||||
boolean isForQuote=(flags & FLAG_IS_FOR_QUOTE)!=0;
|
||||
// add inset dummy so last content item doesn't clip out of inset bounds
|
||||
if((inset || footer==null) && (flags & FLAG_CHECKABLE)==0 && !isForQuote){
|
||||
items.add(new DummyStatusDisplayItem(parentID, fragment));
|
||||
// in case we ever need the dummy to display a margin for the media grid again:
|
||||
// (i forgot why we apparently don't need this anymore)
|
||||
// !contentItems.isEmpty() && contentItems
|
||||
// .get(contentItems.size() - 1) instanceof MediaGridStatusDisplayItem));
|
||||
}
|
||||
GapStatusDisplayItem gap=null;
|
||||
if((flags & FLAG_NO_FOOTER)==0 && status.hasGapAfter!=null && !(fragment instanceof ThreadFragment))
|
||||
items.add(gap=new GapStatusDisplayItem(parentID, fragment, status));
|
||||
int i=1;
|
||||
for(StatusDisplayItem item:items){
|
||||
if(inset)
|
||||
item.inset=true;
|
||||
if(isForQuote){
|
||||
item.status=statusForContent;
|
||||
item.isForQuote=true;
|
||||
}
|
||||
item.index=i++;
|
||||
}
|
||||
if(items!=contentItems && !statusForContent.spoilerRevealed){
|
||||
for(StatusDisplayItem item:contentItems){
|
||||
|
||||
List<Attachment> imageAttachments=statusForContent.mediaAttachments.stream().filter(att->att.type.isImage()).collect(Collectors.toList());
|
||||
if(!imageAttachments.isEmpty() && (flags&FLAG_NO_MEDIA_PREVIEW)==0){
|
||||
int color=UiUtils.getThemeColor(fragment.getContext(), R.attr.colorM3SurfaceVariant);
|
||||
for(Attachment att : imageAttachments){
|
||||
if(att.blurhashPlaceholder==null){
|
||||
att.blurhashPlaceholder=new ColorDrawable(color);
|
||||
}
|
||||
}
|
||||
PhotoLayoutHelper.TiledLayoutResult layout=PhotoLayoutHelper.processThumbs(imageAttachments);
|
||||
MediaGridStatusDisplayItem mediaGrid=new MediaGridStatusDisplayItem(parentID, fragment, layout, imageAttachments, statusForContent);
|
||||
if((flags&FLAG_MEDIA_FORCE_HIDDEN)!=0){
|
||||
mediaGrid.sensitiveTitle=fragment.getString(R.string.media_hidden);
|
||||
statusForContent.sensitiveRevealed=false;
|
||||
statusForContent.sensitive=true;
|
||||
}else if(statusForContent.sensitive && AccountSessionManager.get(accountID).getLocalPreferences().revealCWs && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia)
|
||||
statusForContent.sensitiveRevealed=true;
|
||||
contentItems.add(mediaGrid);
|
||||
}
|
||||
if((flags&FLAG_NO_MEDIA_PREVIEW)!=0){
|
||||
contentItems.add(new PreviewlessMediaGridStatusDisplayItem(parentID, fragment, null, imageAttachments, statusForContent));
|
||||
|
||||
}
|
||||
for(Attachment att : statusForContent.mediaAttachments){
|
||||
if(att.type==Attachment.Type.AUDIO){
|
||||
contentItems.add(new AudioStatusDisplayItem(parentID, fragment, statusForContent, att));
|
||||
}
|
||||
if(att.type==Attachment.Type.UNKNOWN){
|
||||
contentItems.add(new FileStatusDisplayItem(parentID, fragment, att));
|
||||
}
|
||||
}
|
||||
if(statusForContent.poll!=null){
|
||||
buildPollItems(parentID, fragment, statusForContent.poll, status, contentItems);
|
||||
}
|
||||
if(statusForContent.card!=null && statusForContent.mediaAttachments.isEmpty() && statusForContent.quote==null && !statusForContent.card.isHashtagUrl(statusForContent.url)){
|
||||
contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent, (flags&FLAG_NO_MEDIA_PREVIEW)==0));
|
||||
}
|
||||
if(statusForContent.quote!=null && (flags & FLAG_INSET)==0){
|
||||
if(!statusForContent.mediaAttachments.isEmpty() && statusForContent.poll==null) // add spacing if immediately preceded by attachment
|
||||
contentItems.add(new DummyStatusDisplayItem(parentID, fragment));
|
||||
contentItems.addAll(buildItems(fragment, statusForContent.quote, accountID, parentObject, knownAccounts, filterContext, FLAG_NO_FOOTER|FLAG_INSET|FLAG_NO_EMOJI_REACTIONS|FLAG_IS_FOR_QUOTE));
|
||||
} else if((flags & FLAG_INSET)==0 && statusForContent.mediaAttachments.isEmpty() && statusForContent.account!=null){
|
||||
tryAddNonOfficialQuote(statusForContent, fragment, accountID, filterContext);
|
||||
}
|
||||
if(contentItems!=items && statusForContent.spoilerRevealed){
|
||||
items.addAll(contentItems);
|
||||
}
|
||||
AccountLocalPreferences lp=fragment.getLocalPrefs();
|
||||
if((flags&FLAG_NO_EMOJI_REACTIONS)==0 && !status.preview && lp.emojiReactionsEnabled &&
|
||||
(lp.showEmojiReactions!=ONLY_OPENED || fragment instanceof ThreadFragment) &&
|
||||
statusForContent.reactions!=null){
|
||||
boolean isMainStatus=fragment instanceof ThreadFragment t && t.getMainStatus().id.equals(statusForContent.id);
|
||||
boolean showAddButton=lp.showEmojiReactions==ALWAYS || isMainStatus;
|
||||
items.add(new EmojiReactionsStatusDisplayItem(parentID, fragment, statusForContent, accountID, !showAddButton, false));
|
||||
}
|
||||
FooterStatusDisplayItem footer=null;
|
||||
if((flags&FLAG_NO_FOOTER)==0){
|
||||
footer=new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID);
|
||||
footer.hideCounts=hideCounts;
|
||||
items.add(footer);
|
||||
}
|
||||
boolean inset=(flags&FLAG_INSET)!=0;
|
||||
boolean isForQuote=(flags&FLAG_IS_FOR_QUOTE)!=0;
|
||||
// add inset dummy so last content item doesn't clip out of inset bounds
|
||||
if((inset || footer==null) && (flags&FLAG_CHECKABLE)==0 && !isForQuote){
|
||||
items.add(new DummyStatusDisplayItem(parentID, fragment));
|
||||
// in case we ever need the dummy to display a margin for the media grid again:
|
||||
// (i forgot why we apparently don't need this anymore)
|
||||
// !contentItems.isEmpty() && contentItems
|
||||
// .get(contentItems.size() - 1) instanceof MediaGridStatusDisplayItem));
|
||||
}
|
||||
GapStatusDisplayItem gap=null;
|
||||
if((flags&FLAG_NO_FOOTER)==0 && status.hasGapAfter!=null && !(fragment instanceof ThreadFragment))
|
||||
items.add(gap=new GapStatusDisplayItem(parentID, fragment, status));
|
||||
int i=1;
|
||||
for(StatusDisplayItem item : items){
|
||||
if(inset)
|
||||
item.inset=true;
|
||||
if(isForQuote){
|
||||
@@ -373,15 +384,31 @@ public abstract class StatusDisplayItem{
|
||||
}
|
||||
item.index=i++;
|
||||
}
|
||||
}
|
||||
if(items!=contentItems && !statusForContent.spoilerRevealed){
|
||||
for(StatusDisplayItem item : contentItems){
|
||||
if(inset)
|
||||
item.inset=true;
|
||||
if(isForQuote){
|
||||
item.status=statusForContent;
|
||||
item.isForQuote=true;
|
||||
}
|
||||
item.index=i++;
|
||||
}
|
||||
}
|
||||
|
||||
List<StatusDisplayItem> nonGapItems=gap!=null ? items.subList(0, items.size()-1) : items;
|
||||
WarningFilteredStatusDisplayItem warning=applyingFilter==null ? null :
|
||||
new WarningFilteredStatusDisplayItem(parentID, fragment, statusForContent, nonGapItems, applyingFilter);
|
||||
return applyingFilter==null ? items : new ArrayList<>(gap!=null
|
||||
? List.of(warning, gap)
|
||||
: Collections.singletonList(warning)
|
||||
);
|
||||
List<StatusDisplayItem> nonGapItems=gap!=null ? items.subList(0, items.size()-1) : items;
|
||||
WarningFilteredStatusDisplayItem warning=applyingFilter==null ? null :
|
||||
new WarningFilteredStatusDisplayItem(parentID, fragment, statusForContent, nonGapItems, applyingFilter);
|
||||
if(warning!=null)
|
||||
warning.inset=inset;
|
||||
return applyingFilter==null ? items : new ArrayList<>(gap!=null
|
||||
? List.of(warning, gap)
|
||||
: Collections.singletonList(warning)
|
||||
);
|
||||
} catch(Exception e) {
|
||||
Log.e("StatusDisplayItem", "buildItems: failed to build StatusDisplayItem " + e);
|
||||
return new ArrayList<>(Collections.singletonList(new ErrorStatusDisplayItem(parentID, statusForContent, fragment, e)));
|
||||
}
|
||||
}
|
||||
|
||||
public static void buildPollItems(String parentID, BaseStatusListFragment fragment, Poll poll, Status status, List<StatusDisplayItem> items){
|
||||
@@ -393,6 +420,61 @@ public abstract class StatusDisplayItem{
|
||||
items.add(new PollFooterStatusDisplayItem(parentID, fragment, poll, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to adds a non-official quote to a status.
|
||||
* A non-official quote is a quote on an instance that does not support quotes officially.
|
||||
*/
|
||||
private static void tryAddNonOfficialQuote(Status status, BaseStatusListFragment fragment, String accountID, FilterContext filterContext) {
|
||||
Matcher matcher=QUOTE_PATTERN.matcher(status.getStrippedText());
|
||||
|
||||
if(!matcher.find())
|
||||
return;
|
||||
String quoteURL=matcher.group();
|
||||
|
||||
// account may be null for scheduled posts
|
||||
if (!UiUtils.looksLikeFediverseUrl(quoteURL))
|
||||
return;
|
||||
|
||||
new GetSearchResults(quoteURL, GetSearchResults.Type.STATUSES, true, null, 0, 0).setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(SearchResults results){
|
||||
AccountSessionManager.get(accountID).filterStatuses(results.statuses, filterContext);
|
||||
if (results.statuses == null || results.statuses.isEmpty())
|
||||
return;
|
||||
|
||||
Status quote=results.statuses.get(0);
|
||||
new GetAccountRelationships(Collections.singletonList(quote.account.id))
|
||||
.setCallback(new Callback<>(){
|
||||
@Override
|
||||
public void onSuccess(List<Relationship> relationships){
|
||||
if(relationships.isEmpty())
|
||||
return;
|
||||
|
||||
Relationship relationship=relationships.get(0);
|
||||
String selfId=AccountSessionManager.get(accountID).self.id;
|
||||
if(!status.account.id.equals(selfId) && (relationship.domainBlocking || relationship.muting || relationship.blocking)) {
|
||||
// do not show posts that are quoting a muted/blocked user
|
||||
fragment.removeStatus(status);
|
||||
return;
|
||||
}
|
||||
|
||||
status.quote=results.statuses.get(0);
|
||||
fragment.updateStatusWithQuote(status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){}
|
||||
})
|
||||
.exec(accountID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error){
|
||||
Log.w("StatusDisplayItem", "onError: failed to find quote status with URL: " + quoteURL + " " + error);
|
||||
}
|
||||
}).exec(accountID);
|
||||
}
|
||||
|
||||
public enum Type{
|
||||
HEADER,
|
||||
REBLOG_OR_REPLY_LINE,
|
||||
@@ -417,6 +499,7 @@ public abstract class StatusDisplayItem{
|
||||
SECTION_HEADER,
|
||||
HEADER_CHECKABLE,
|
||||
NOTIFICATION_HEADER,
|
||||
ERROR_ITEM,
|
||||
FILTER_SPOILER,
|
||||
DUMMY
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
|
||||
float textCollapsedHeight=activity.getResources().getDimension(R.dimen.text_collapsed_height);
|
||||
collapseParams=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (int) textCollapsedHeight);
|
||||
wrapParams=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
readMore.setOnClickListener(v -> item.parentFragment.onToggleExpanded(item.status, getItemID()));
|
||||
readMore.setOnClickListener(v -> item.parentFragment.onToggleExpanded(item.status, item.isForQuote, getItemID()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -155,7 +155,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
|
||||
if (GlobalUserPreferences.collapseLongPosts && !item.status.textExpandable) {
|
||||
boolean tooBig = text.getMeasuredHeight() > textMaxHeight;
|
||||
boolean expandable = tooBig && !item.status.hasSpoiler();
|
||||
item.parentFragment.onEnableExpandable(Holder.this, expandable);
|
||||
item.parentFragment.onEnableExpandable(Holder.this, expandable, item.isForQuote);
|
||||
}
|
||||
|
||||
boolean expandButtonShown=item.status.textExpandable && !item.status.textExpanded;
|
||||
@@ -173,7 +173,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
|
||||
@Override
|
||||
public void setImage(int index, Drawable image){
|
||||
getEmojiHelper().setImageDrawable(index, image);
|
||||
text.invalidate();
|
||||
text.setText(text.getText());
|
||||
if(image instanceof Animatable){
|
||||
((Animatable) image).start();
|
||||
if(image instanceof MovieDrawable)
|
||||
@@ -184,7 +184,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
|
||||
@Override
|
||||
public void clearImage(int index){
|
||||
getEmojiHelper().setImageDrawable(index, null);
|
||||
text.invalidate();
|
||||
text.setText(text.getText());
|
||||
}
|
||||
|
||||
private CustomEmojiHelper getEmojiHelper(){
|
||||
|
||||
@@ -8,14 +8,12 @@ import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.fragments.BaseStatusListFragment;
|
||||
import org.joinmastodon.android.model.AltTextFilter;
|
||||
import org.joinmastodon.android.model.Filter;
|
||||
import org.joinmastodon.android.model.LegacyFilter;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.OutlineProviders;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
// Mind the gap!
|
||||
public class WarningFilteredStatusDisplayItem extends StatusDisplayItem{
|
||||
public boolean loading;
|
||||
public List<StatusDisplayItem> filteredItems;
|
||||
@@ -24,8 +22,8 @@ public class WarningFilteredStatusDisplayItem extends StatusDisplayItem{
|
||||
public WarningFilteredStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, Status status, List<StatusDisplayItem> filteredItems, LegacyFilter applyingFilter){
|
||||
super(parentID, parentFragment);
|
||||
this.status=status;
|
||||
this.filteredItems = filteredItems;
|
||||
this.applyingFilter = applyingFilter;
|
||||
this.filteredItems=filteredItems;
|
||||
this.applyingFilter=applyingFilter;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -33,31 +31,34 @@ public class WarningFilteredStatusDisplayItem extends StatusDisplayItem{
|
||||
return Type.WARNING;
|
||||
}
|
||||
|
||||
public static class Holder extends StatusDisplayItem.Holder<WarningFilteredStatusDisplayItem>{
|
||||
public final View warningWrap;
|
||||
public final Button showBtn;
|
||||
public final TextView text;
|
||||
public List<StatusDisplayItem> filteredItems;
|
||||
public static class Holder extends StatusDisplayItem.Holder<WarningFilteredStatusDisplayItem>{
|
||||
public final View warningWrap;
|
||||
public final Button showBtn;
|
||||
public final TextView text;
|
||||
public List<StatusDisplayItem> filteredItems;
|
||||
|
||||
public Holder(Context context, ViewGroup parent){
|
||||
super(context, R.layout.display_item_warning, parent);
|
||||
warningWrap=findViewById(R.id.warning_wrap);
|
||||
showBtn=findViewById(R.id.reveal_btn);
|
||||
showBtn.setOnClickListener(i -> item.parentFragment.onWarningClick(this));
|
||||
itemView.setOnClickListener(v->item.parentFragment.onWarningClick(this));
|
||||
text=findViewById(R.id.text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBind(WarningFilteredStatusDisplayItem item) {
|
||||
filteredItems = item.filteredItems;
|
||||
String title = item.applyingFilter instanceof AltTextFilter ? item.parentFragment.getString(R.string.sk_no_alt_text) : item.applyingFilter.title;
|
||||
text.setText(item.parentFragment.getString(R.string.sk_filtered, title));
|
||||
public Holder(Context context, ViewGroup parent){
|
||||
super(context, R.layout.display_item_warning, parent);
|
||||
warningWrap=findViewById(R.id.warning_wrap);
|
||||
showBtn=findViewById(R.id.reveal_btn);
|
||||
showBtn.setOnClickListener(i->item.parentFragment.onWarningClick(this));
|
||||
itemView.setOnClickListener(v->item.parentFragment.onWarningClick(this));
|
||||
text=findViewById(R.id.text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){
|
||||
@Override
|
||||
public void onBind(WarningFilteredStatusDisplayItem item){
|
||||
filteredItems=item.filteredItems;
|
||||
String title=item.applyingFilter.title;
|
||||
text.setText(item.parentFragment.getString(R.string.sk_filtered, title));
|
||||
|
||||
}
|
||||
}
|
||||
if(item.inset){
|
||||
itemView.setClipToOutline(true);
|
||||
itemView.setOutlineProvider(OutlineProviders.roundedRect(8));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(){}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.model.Attachment;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.utils.FileProvider;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
@@ -207,32 +207,32 @@ public class PhotoViewer implements ZoomPanView.Listener{
|
||||
toolbar=uiOverlay.findViewById(R.id.toolbar);
|
||||
toolbar.setNavigationOnClickListener(v->onStartSwipeToDismissTransition(0));
|
||||
|
||||
if(status!=null) {
|
||||
toolbar.getMenu()
|
||||
.add(R.string.download)
|
||||
.setIcon(R.drawable.ic_fluent_arrow_download_24_regular)
|
||||
.setOnMenuItemClickListener(item -> {
|
||||
saveCurrentFile();
|
||||
return true;
|
||||
})
|
||||
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
toolbar.getMenu()
|
||||
.add(R.string.button_share)
|
||||
.setIcon(R.drawable.ic_fluent_share_24_regular)
|
||||
.setOnMenuItemClickListener(item -> {
|
||||
shareCurrentFile();
|
||||
return true;
|
||||
})
|
||||
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
|
||||
if(status!=null){
|
||||
toolbar.getMenu()
|
||||
.add(R.string.info)
|
||||
.setIcon(R.drawable.ic_fluent_info_24_regular)
|
||||
.setOnMenuItemClickListener(item -> {
|
||||
.setOnMenuItemClickListener(item->{
|
||||
showInfoSheet();
|
||||
return true;
|
||||
})
|
||||
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
} else {
|
||||
toolbar.getMenu()
|
||||
.add(R.string.download)
|
||||
.setIcon(R.drawable.ic_fluent_arrow_download_24_regular)
|
||||
.setOnMenuItemClickListener(item -> {
|
||||
saveCurrentFile();
|
||||
return true;
|
||||
})
|
||||
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
toolbar.getMenu()
|
||||
.add(R.string.button_share)
|
||||
.setIcon(R.drawable.ic_fluent_share_24_regular)
|
||||
.setOnMenuItemClickListener(item -> {
|
||||
shareCurrentFile();
|
||||
return true;
|
||||
})
|
||||
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
||||
}
|
||||
|
||||
uiOverlay.setAlpha(0f);
|
||||
@@ -482,40 +482,29 @@ public class PhotoViewer implements ZoomPanView.Listener{
|
||||
|
||||
private void shareCurrentFile(){
|
||||
Attachment att=attachments.get(pager.getCurrentItem());
|
||||
Intent intent = new Intent(Intent.ACTION_SEND);
|
||||
|
||||
if(att.type==Attachment.Type.IMAGE){
|
||||
UrlImageLoaderRequest req=new UrlImageLoaderRequest(att.url);
|
||||
try{
|
||||
File file=ImageCache.getInstance(activity).getFile(req);
|
||||
if(file==null){
|
||||
shareAfterDownloading(att);
|
||||
return;
|
||||
}
|
||||
MastodonAPIController.runInBackground(()->{
|
||||
File imageDir = new File(activity.getCacheDir(), ".");
|
||||
File renamedFile;
|
||||
file.renameTo(renamedFile = new File(imageDir, Uri.parse(att.url).getLastPathSegment()));
|
||||
Uri outputUri = FileProvider.getUriForFile(activity, activity.getPackageName() + ".fileprovider", renamedFile);
|
||||
|
||||
// setting type to image
|
||||
intent.setType(mimeTypeForFileName(outputUri.getLastPathSegment()));
|
||||
|
||||
intent.putExtra(Intent.EXTRA_STREAM, outputUri);
|
||||
|
||||
// calling startactivity() to share
|
||||
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.button_share)));
|
||||
|
||||
});
|
||||
}catch(IOException x){
|
||||
Log.w(TAG, "shareCurrentFile: ", x);
|
||||
Toast.makeText(activity, R.string.error, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}else{
|
||||
if(att.type!=Attachment.Type.IMAGE){
|
||||
shareAfterDownloading(att);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
UrlImageLoaderRequest req=new UrlImageLoaderRequest(att.url);
|
||||
try{
|
||||
File file=ImageCache.getInstance(activity).getFile(req);
|
||||
if(file==null){
|
||||
shareAfterDownloading(att);
|
||||
return;
|
||||
}
|
||||
MastodonAPIController.runInBackground(()->{
|
||||
File imageDir=new File(activity.getCacheDir(), ".");
|
||||
File renamedFile;
|
||||
file.renameTo(renamedFile=new File(imageDir, Uri.parse(att.url).getLastPathSegment()));
|
||||
shareFile(renamedFile);
|
||||
});
|
||||
}catch(IOException x){
|
||||
Log.w(TAG, "shareCurrentFile: ", x);
|
||||
Toast.makeText(activity, R.string.error, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void saveCurrentFile(){
|
||||
@@ -625,6 +614,8 @@ public class PhotoViewer implements ZoomPanView.Listener{
|
||||
private void shareAfterDownloading(Attachment att){
|
||||
Uri uri=Uri.parse(att.url);
|
||||
|
||||
Toast.makeText(activity, R.string.downloading, Toast.LENGTH_SHORT).show();
|
||||
|
||||
MastodonAPIController.runInBackground(()->{
|
||||
try {
|
||||
OkHttpClient client = new OkHttpClient();
|
||||
@@ -649,20 +640,22 @@ public class PhotoViewer implements ZoomPanView.Listener{
|
||||
|
||||
outputStream.close();
|
||||
inputStream.close();
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_SEND);
|
||||
|
||||
Uri outputUri = FileProvider.getUriForFile(activity, activity.getPackageName() + ".fileprovider", file);
|
||||
|
||||
intent.setType(mimeTypeForFileName(outputUri.getLastPathSegment()));
|
||||
intent.putExtra(Intent.EXTRA_STREAM, outputUri);
|
||||
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.button_share)));
|
||||
shareFile(file);
|
||||
} catch(IOException e){
|
||||
Toast.makeText(activity, R.string.error, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void shareFile(@NonNull File file) {
|
||||
Intent intent = new Intent(Intent.ACTION_SEND);
|
||||
Uri outputUri = UiUtils.getFileProviderUri(activity, file);
|
||||
intent.setDataAndType(outputUri, mimeTypeForFileName(outputUri.getLastPathSegment()));
|
||||
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.putExtra(Intent.EXTRA_STREAM, outputUri);
|
||||
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.button_share)));
|
||||
}
|
||||
|
||||
private void onAudioFocusChanged(int change){
|
||||
if(change==AudioManager.AUDIOFOCUS_LOSS || change==AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || change==AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK){
|
||||
pauseVideo();
|
||||
@@ -790,17 +783,18 @@ public class PhotoViewer implements ZoomPanView.Listener{
|
||||
if(status!=null){
|
||||
AccountSessionManager.get(accountID).getStatusInteractionController().setFavorited(status, !status.favourited, r->{});
|
||||
}
|
||||
}else if(id==R.id.btn_share){
|
||||
if(status!=null){
|
||||
shareCurrentFile();
|
||||
}
|
||||
// }else if(id==R.id.btn_share){
|
||||
// if(status!=null){
|
||||
// shareCurrentFile();
|
||||
// }
|
||||
}else if(id==R.id.btn_bookmark){
|
||||
if(status!=null){
|
||||
AccountSessionManager.get(accountID).getStatusInteractionController().setBookmarked(status, !status.bookmarked);
|
||||
}
|
||||
}else if(id==R.id.btn_download){
|
||||
saveCurrentFile();
|
||||
}
|
||||
// else if(id==R.id.btn_download){
|
||||
// saveCurrentFile();
|
||||
// }
|
||||
}
|
||||
});
|
||||
sheet.setStatus(status);
|
||||
|
||||
@@ -102,9 +102,9 @@ public class PhotoViewerInfoSheet extends BottomSheet{
|
||||
|
||||
boostBtn.setOnClickListener(clickListener);
|
||||
favoriteBtn.setOnClickListener(clickListener);
|
||||
findViewById(R.id.btn_share).setOnClickListener(clickListener);
|
||||
// findViewById(R.id.btn_share).setOnClickListener(clickListener);
|
||||
bookmarkBtn.setOnClickListener(clickListener);
|
||||
findViewById(R.id.btn_download).setOnClickListener(clickListener);
|
||||
// findViewById(R.id.btn_download).setOnClickListener(clickListener);
|
||||
}
|
||||
|
||||
private void showAltTextHelp(){
|
||||
|
||||
@@ -62,7 +62,7 @@ import me.grishka.appkit.views.UsableRecyclerView;
|
||||
public class AccountSwitcherSheet extends BottomSheet{
|
||||
private final Activity activity;
|
||||
private final HomeFragment fragment;
|
||||
private final boolean externalShare, openInApp;
|
||||
private final boolean accountChooser, openInApp;
|
||||
private BiConsumer<String, Boolean> onClick;
|
||||
private UsableRecyclerView list;
|
||||
private List<WrappedAccount> accounts;
|
||||
@@ -71,17 +71,22 @@ public class AccountSwitcherSheet extends BottomSheet{
|
||||
private Runnable onLoggedOutCallback;
|
||||
|
||||
public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment){
|
||||
this(activity, fragment, false, false);
|
||||
this(activity, fragment, 0, 0, null, false);
|
||||
}
|
||||
|
||||
public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment, boolean externalShare, boolean openInApp){
|
||||
|
||||
public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment, @DrawableRes int headerIcon, @StringRes int headerTitle, String exceptFor, boolean openInApp){
|
||||
super(activity);
|
||||
this.activity=activity;
|
||||
this.fragment=fragment;
|
||||
this.externalShare = externalShare;
|
||||
this.openInApp = openInApp;
|
||||
this.accountChooser=headerTitle!=0;
|
||||
// currently there is only one use case for a end row button (openInApp)
|
||||
// if more are needed ti should be generified
|
||||
this.openInApp=openInApp;
|
||||
|
||||
accounts=AccountSessionManager.getInstance().getLoggedInAccounts().stream().map(WrappedAccount::new).collect(Collectors.toList());
|
||||
accounts=AccountSessionManager.getInstance().getLoggedInAccounts().stream()
|
||||
.filter(accountSession -> !accountSession.getID().equals(exceptFor))
|
||||
.map(WrappedAccount::new).collect(Collectors.toList());
|
||||
|
||||
list=new UsableRecyclerView(activity);
|
||||
imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null);
|
||||
@@ -95,20 +100,21 @@ public class AccountSwitcherSheet extends BottomSheet{
|
||||
|
||||
adapter.addAdapter(new SingleViewRecyclerAdapter(handle));
|
||||
|
||||
if (externalShare) {
|
||||
if (accountChooser) {
|
||||
FrameLayout shareHeading = new FrameLayout(activity);
|
||||
activity.getLayoutInflater().inflate(R.layout.item_external_share_heading, shareHeading);
|
||||
((TextView) shareHeading.findViewById(R.id.title)).setText(openInApp
|
||||
? R.string.sk_external_share_or_open_title
|
||||
: R.string.sk_external_share_title);
|
||||
((ImageView) shareHeading.findViewById(R.id.icon)).setImageDrawable(getContext().getDrawable(headerIcon));
|
||||
((TextView) shareHeading.findViewById(R.id.title)).setText(getContext().getString(headerTitle));
|
||||
|
||||
adapter.addAdapter(new SingleViewRecyclerAdapter(shareHeading));
|
||||
|
||||
setOnDismissListener((d) -> activity.finish());
|
||||
// we're using the sheet for interactAs picking, so the activity should not be closed
|
||||
setOnDismissListener(exceptFor!=null ? null : (d) -> activity.finish());
|
||||
}
|
||||
|
||||
adapter.addAdapter(accountsAdapter = new AccountsAdapter());
|
||||
|
||||
if (!externalShare) {
|
||||
if (!accountChooser) {
|
||||
adapter.addAdapter(new ClickableSingleViewRecyclerAdapter(makeSimpleListItem(R.string.add_account, R.drawable.ic_fluent_add_24_regular), () -> {
|
||||
Nav.go(activity, CustomWelcomeFragment.class, null);
|
||||
dismiss();
|
||||
@@ -301,9 +307,9 @@ public class AccountSwitcherSheet extends BottomSheet{
|
||||
public void onBind(AccountSession item){
|
||||
HtmlParser.setTextWithCustomEmoji(name, item.self.getDisplayName(), item.self.emojis);
|
||||
username.setText(item.getFullUsername());
|
||||
radioButton.setVisibility(externalShare ? View.GONE : View.VISIBLE);
|
||||
extraBtnWrap.setVisibility(externalShare && openInApp ? View.VISIBLE : View.GONE);
|
||||
if (externalShare) view.setCheckable(false);
|
||||
radioButton.setVisibility(accountChooser ? View.GONE : View.VISIBLE);
|
||||
extraBtnWrap.setVisibility(accountChooser && openInApp ? View.VISIBLE : View.GONE);
|
||||
if (accountChooser) view.setCheckable(false);
|
||||
else {
|
||||
String accountId = fragment != null
|
||||
? fragment.getAccountID()
|
||||
@@ -338,7 +344,8 @@ public class AccountSwitcherSheet extends BottomSheet{
|
||||
onClick.accept(item.getID(), false);
|
||||
return;
|
||||
}
|
||||
if(AccountSessionManager.getInstance().tryGetAccount(item.getID())!=null){
|
||||
AccountSessionManager accountSessionManager=AccountSessionManager.getInstance();
|
||||
if(accountSessionManager.tryGetAccount(item.getID())!=null && !view.isChecked()){
|
||||
AccountSessionManager.getInstance().setLastActiveAccountID(item.getID());
|
||||
((MainActivity)activity).restartActivity();
|
||||
}
|
||||
@@ -346,7 +353,7 @@ public class AccountSwitcherSheet extends BottomSheet{
|
||||
|
||||
@Override
|
||||
public boolean onLongClick(){
|
||||
if (externalShare) return false;
|
||||
if (accountChooser) return false;
|
||||
confirmLogOut(item.getID());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,8 @@ package org.joinmastodon.android.ui.sheets;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.graphics.Typeface;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.PopupMenu;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.model.Account;
|
||||
@@ -17,6 +11,7 @@ import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.ui.views.M3Switch;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
@@ -44,10 +39,10 @@ public class MuteAccountConfirmationSheet extends AccountRestrictionConfirmation
|
||||
addRow(R.drawable.ic_fluent_alert_off_24_regular, R.string.mo_mute_notifications, m3Switch);
|
||||
|
||||
// add mute duration (Moshidon)
|
||||
secondaryBtn.setVisibility(View.VISIBLE);
|
||||
secondaryBtn.setOnClickListener(v->getMuteDurationDialog(context, muteDuration, secondaryBtn).show());
|
||||
secondaryBtn.setText(R.string.sk_duration_indefinite);
|
||||
secondaryBtn.setTypeface(null, Typeface.BOLD_ITALIC);
|
||||
Button muteDurationBtn=new Button(getContext());
|
||||
muteDurationBtn.setOnClickListener(v->getMuteDurationDialog(context, muteDuration, muteDurationBtn).show());
|
||||
muteDurationBtn.setText(R.string.sk_duration_indefinite);
|
||||
addRow(R.drawable.ic_fluent_clock_20_regular, R.string.sk_mute_label, muteDurationBtn);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@@ -55,6 +50,15 @@ public class MuteAccountConfirmationSheet extends AccountRestrictionConfirmation
|
||||
M3AlertDialogBuilder builder=new M3AlertDialogBuilder(context);
|
||||
builder.setTitle(R.string.sk_mute_label);
|
||||
builder.setIcon(R.drawable.ic_fluent_clock_20_regular);
|
||||
List<Duration> durations =List.of(Duration.ZERO,
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofMinutes(30),
|
||||
Duration.ofHours(1),
|
||||
Duration.ofHours(6),
|
||||
Duration.ofDays(1),
|
||||
Duration.ofDays(3),
|
||||
Duration.ofDays(7),
|
||||
Duration.ofDays(7));
|
||||
|
||||
String[] choices = {context.getString(R.string.sk_duration_indefinite),
|
||||
context.getString(R.string.sk_duration_minutes_5),
|
||||
@@ -65,35 +69,14 @@ public class MuteAccountConfirmationSheet extends AccountRestrictionConfirmation
|
||||
context.getString(R.string.sk_duration_days_3),
|
||||
context.getString(R.string.sk_duration_days_7)};
|
||||
|
||||
builder.setSingleChoiceItems(choices, 0, (dialog, which) -> {});
|
||||
builder.setSingleChoiceItems(choices, durations.indexOf(muteDuration.get()), (dialog, which) -> {});
|
||||
|
||||
builder.setPositiveButton(R.string.ok, (dialog, which)->{
|
||||
int selected = ((AlertDialog) dialog).getListView().getCheckedItemPosition();
|
||||
if(selected==0){
|
||||
muteDuration.set(Duration.ZERO);
|
||||
}else if(selected==1){
|
||||
muteDuration.set(Duration.ofMinutes(5));
|
||||
}else if(selected==2){
|
||||
muteDuration.set(Duration.ofMinutes(30));
|
||||
}else if(selected==3){
|
||||
muteDuration.set(Duration.ofHours(1));
|
||||
}else if(selected==4){
|
||||
muteDuration.set(Duration.ofHours(6));
|
||||
}else if(selected==5){
|
||||
muteDuration.set(Duration.ofDays(1));
|
||||
}else if(selected==6){
|
||||
muteDuration.set(Duration.ofDays(3));
|
||||
}else if(selected==7){
|
||||
muteDuration.set(Duration.ofDays(7));
|
||||
}
|
||||
if(selected >= 0 && selected <= 7){
|
||||
button.setText(choices[selected]);
|
||||
} else {
|
||||
Toast.makeText(context, "" + selected, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
muteDuration.set(durations.get(selected));
|
||||
button.setText(choices[selected]);
|
||||
});
|
||||
|
||||
builder.setNegativeButton(R.string.cancel, ((dialogInterface, i) -> {}));
|
||||
builder.setNegativeButton(R.string.cancel, null);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ public class CustomEmojiSpan extends ReplacementSpan{
|
||||
|
||||
@Override
|
||||
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){
|
||||
return Math.round(paint.descent()-paint.ascent());
|
||||
int size = Math.round(paint.descent()-paint.ascent());
|
||||
return drawable!=null ? (int) (drawable.getIntrinsicWidth()*(size/(float) drawable.getIntrinsicHeight())) : size;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -45,7 +46,8 @@ public class CustomEmojiSpan extends ReplacementSpan{
|
||||
}
|
||||
canvas.save();
|
||||
canvas.translate(x, top);
|
||||
canvas.scale(size/(float)dw, size/(float)dh, 0f, 0f);
|
||||
float scale = size/(float)dh;
|
||||
canvas.scale(scale, scale, 0f, 0f);
|
||||
drawable.draw(canvas);
|
||||
canvas.restore();
|
||||
}
|
||||
@@ -56,7 +58,6 @@ public class CustomEmojiSpan extends ReplacementSpan{
|
||||
}
|
||||
|
||||
public UrlImageLoaderRequest createImageLoaderRequest(){
|
||||
int size=V.dp(20);
|
||||
return new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? emoji.url : emoji.staticUrl, size, size);
|
||||
return new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? emoji.url : emoji.staticUrl, 0, V.dp(20));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,9 @@ public class HtmlParser{
|
||||
int colorInsert=UiUtils.getThemeColor(context, R.attr.colorM3Success);
|
||||
int colorDelete=UiUtils.getThemeColor(context, R.attr.colorM3Error);
|
||||
|
||||
if(source.endsWith("\n"))
|
||||
source=source.stripTrailing();
|
||||
|
||||
Jsoup.parseBodyFragment(source).body().traverse(new NodeVisitor(){
|
||||
private final ArrayList<SpanInfo> openSpans=new ArrayList<>();
|
||||
|
||||
@@ -137,14 +140,12 @@ public class HtmlParser{
|
||||
String href=el.attr("href");
|
||||
LinkSpan.Type linkType;
|
||||
String text=el.text();
|
||||
if(el.hasClass("hashtag")){
|
||||
if(text.startsWith("#")){
|
||||
linkType=LinkSpan.Type.HASHTAG;
|
||||
href=text.substring(1);
|
||||
linkObject=tagsByTag.get(text.substring(1).toLowerCase());
|
||||
}else{
|
||||
linkType=LinkSpan.Type.URL;
|
||||
}
|
||||
if(!TextUtils.isEmpty(text) && (el.hasClass("hashtag") || text.startsWith("#"))){
|
||||
// MOSHIDON: we have slightly refactored this so that the hashtags properly work in akkoma
|
||||
// TODO: upstream this
|
||||
linkType=LinkSpan.Type.HASHTAG;
|
||||
href=text.substring(1);
|
||||
linkObject=tagsByTag.get(text.substring(1).toLowerCase());
|
||||
}else if(el.hasClass("mention")){
|
||||
String id=idsByUrl.get(href);
|
||||
if(id!=null){
|
||||
@@ -321,12 +322,11 @@ public class HtmlParser{
|
||||
}
|
||||
|
||||
public static void applyFilterHighlights(Context context, SpannableStringBuilder text, List<FilterResult> filters){
|
||||
if (filters == null) return;
|
||||
int fgColor=UiUtils.getThemeColor(context, R.attr.colorM3Error);
|
||||
int bgColor=UiUtils.getThemeColor(context, R.attr.colorM3ErrorContainer);
|
||||
for(FilterResult filter:filters){
|
||||
if(!filter.filter.isActive())
|
||||
continue;;
|
||||
continue;
|
||||
for(String word:filter.keywordMatches){
|
||||
Matcher matcher=Pattern.compile("\\b"+Pattern.quote(word)+"\\b", Pattern.CASE_INSENSITIVE).matcher(text);
|
||||
while(matcher.find()){
|
||||
|
||||
@@ -48,6 +48,8 @@ public class CustomEmojiHelper{
|
||||
}
|
||||
|
||||
public void setImageDrawable(int image, Drawable drawable){
|
||||
if(spans.isEmpty())
|
||||
return;
|
||||
for(CustomEmojiSpan span:spans.get(image)){
|
||||
span.setDrawable(drawable);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.joinmastodon.android.fragments.BaseStatusListFragment;
|
||||
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem;
|
||||
import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -42,13 +43,16 @@ public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{
|
||||
boolean inset=(holder instanceof StatusDisplayItem.Holder<?> sdi) && sdi.getItem().inset;
|
||||
if(inset){
|
||||
if(rect.isEmpty()){
|
||||
if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder){
|
||||
rect.set(child.getX(), i == 0 && pos > 0 && displayItems.get(pos - 1).inset ? V.dp(-10) : child.getY(), child.getX() + child.getWidth(), child.getY() + child.getHeight() + V.dp(4));
|
||||
if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder || holder instanceof WarningFilteredStatusDisplayItem.Holder){
|
||||
float topInset=i == 0 && pos > 0 && displayItems.get(pos - 1).inset ? V.dp(-10) : child.getY();
|
||||
if(holder instanceof WarningFilteredStatusDisplayItem.Holder)
|
||||
topInset-=V.dp(4);
|
||||
rect.set(child.getX(), topInset, child.getX() + child.getWidth(), child.getY() + child.getHeight() + V.dp(4));
|
||||
}else {
|
||||
rect.set(child.getX(), i == 0 && pos > 0 && displayItems.get(pos - 1).inset ? V.dp(-10) : child.getY(), child.getX() + child.getWidth(), child.getY() + child.getHeight());
|
||||
}
|
||||
}else{
|
||||
if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder){
|
||||
if(holder instanceof MediaGridStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder || holder instanceof WarningFilteredStatusDisplayItem.Holder){
|
||||
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight()) + V.dp(4);
|
||||
}else {
|
||||
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight());
|
||||
|
||||
@@ -52,8 +52,6 @@ import android.util.Pair;
|
||||
import android.view.Gravity;
|
||||
import android.view.HapticFeedbackConstants;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Gravity;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.SubMenu;
|
||||
@@ -63,7 +61,6 @@ import android.view.ViewPropertyAnimator;
|
||||
import android.view.WindowInsets;
|
||||
import android.webkit.MimeTypeMap;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.PopupMenu;
|
||||
@@ -72,11 +69,10 @@ import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.joinmastodon.android.E;
|
||||
import org.joinmastodon.android.FileProvider;
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.MainActivity;
|
||||
import org.joinmastodon.android.MastodonApp;
|
||||
import org.joinmastodon.android.R;
|
||||
import org.joinmastodon.android.api.CacheController;
|
||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||
import org.joinmastodon.android.api.MastodonErrorResponse;
|
||||
import org.joinmastodon.android.api.StatusInteractionController;
|
||||
@@ -90,7 +86,6 @@ import org.joinmastodon.android.api.requests.accounts.RejectFollowRequest;
|
||||
import org.joinmastodon.android.api.requests.instance.GetInstance;
|
||||
import org.joinmastodon.android.api.requests.lists.DeleteList;
|
||||
import org.joinmastodon.android.api.requests.notifications.DismissNotification;
|
||||
import org.joinmastodon.android.api.requests.search.GetSearchResults;
|
||||
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
|
||||
import org.joinmastodon.android.api.requests.statuses.DeleteStatus;
|
||||
import org.joinmastodon.android.api.requests.statuses.GetStatusByID;
|
||||
@@ -121,19 +116,21 @@ import org.joinmastodon.android.model.Hashtag;
|
||||
import org.joinmastodon.android.model.Relationship;
|
||||
import org.joinmastodon.android.model.SearchResults;
|
||||
import org.joinmastodon.android.model.ScheduledStatus;
|
||||
import org.joinmastodon.android.model.SearchResults;
|
||||
import org.joinmastodon.android.model.Searchable;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.ui.Snackbar;
|
||||
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
|
||||
import org.joinmastodon.android.ui.sheets.BlockAccountConfirmationSheet;
|
||||
import org.joinmastodon.android.ui.sheets.MuteAccountConfirmationSheet;
|
||||
import org.joinmastodon.android.ui.text.CustomEmojiSpan;
|
||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||
import org.joinmastodon.android.utils.Tracking;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.IDN;
|
||||
import java.net.URI;
|
||||
@@ -154,7 +151,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -181,6 +177,7 @@ import androidx.viewpager2.widget.ViewPager2;
|
||||
import me.grishka.appkit.Nav;
|
||||
import me.grishka.appkit.api.Callback;
|
||||
import me.grishka.appkit.api.ErrorResponse;
|
||||
import me.grishka.appkit.imageloader.ImageCache;
|
||||
import me.grishka.appkit.imageloader.ViewImageLoader;
|
||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||
import me.grishka.appkit.utils.CubicBezierInterpolator;
|
||||
@@ -200,6 +197,8 @@ public class UiUtils {
|
||||
}
|
||||
|
||||
public static void launchWebBrowser(Context context, String url) {
|
||||
if(GlobalUserPreferences.removeTrackingParams)
|
||||
url=Tracking.removeTrackingParameters(url);
|
||||
try {
|
||||
if (GlobalUserPreferences.useCustomTabs) {
|
||||
new CustomTabsIntent.Builder()
|
||||
@@ -418,7 +417,6 @@ public class UiUtils {
|
||||
CustomEmojiSpan[] spans = text.getSpans(0, text.length(), CustomEmojiSpan.class);
|
||||
if (spans.length == 0)
|
||||
return;
|
||||
int emojiSize = V.dp(20);
|
||||
Map<Emoji, List<CustomEmojiSpan>> spansByEmoji = Arrays.stream(spans).collect(Collectors.groupingBy(s -> s.emoji));
|
||||
for (Map.Entry<Emoji, List<CustomEmojiSpan>> emoji : spansByEmoji.entrySet()) {
|
||||
ViewImageLoader.load(new ViewImageLoader.Target() {
|
||||
@@ -429,14 +427,14 @@ public class UiUtils {
|
||||
for (CustomEmojiSpan span : emoji.getValue()) {
|
||||
span.setDrawable(d);
|
||||
}
|
||||
view.invalidate();
|
||||
view.setText(view.getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getView() {
|
||||
return view;
|
||||
}
|
||||
}, null, new UrlImageLoaderRequest(emoji.getKey().url, emojiSize, emojiSize), null, false, true);
|
||||
}, null, new UrlImageLoaderRequest(emoji.getKey().url, 0, V.dp(20)), null, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,17 +933,20 @@ public class UiUtils {
|
||||
}
|
||||
|
||||
|
||||
public static void handleFollowRequest(Activity activity, Account account, String accountID, @Nullable String notificationID, boolean accepted, Relationship relationship, Consumer<Relationship> resultCallback) {
|
||||
public static void handleFollowRequest(Activity activity, Account account, String accountID, @Nullable String notificationID, boolean accepted, Relationship relationship, Consumer<Boolean> progressCallback, Consumer<Relationship> resultCallback) {
|
||||
progressCallback.accept(true);
|
||||
if (accepted) {
|
||||
new AuthorizeFollowRequest(account.id).setCallback(new Callback<>() {
|
||||
@Override
|
||||
public void onSuccess(Relationship rel) {
|
||||
E.post(new FollowRequestHandledEvent(accountID, true, account, rel));
|
||||
progressCallback.accept(false);
|
||||
resultCallback.accept(rel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error) {
|
||||
progressCallback.accept(false);
|
||||
resultCallback.accept(relationship);
|
||||
error.showToast(activity);
|
||||
}
|
||||
@@ -957,11 +958,13 @@ public class UiUtils {
|
||||
E.post(new FollowRequestHandledEvent(accountID, false, account, rel));
|
||||
if (notificationID != null)
|
||||
E.post(new NotificationDeletedEvent(notificationID));
|
||||
progressCallback.accept(false);
|
||||
resultCallback.accept(rel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ErrorResponse error) {
|
||||
progressCallback.accept(false);
|
||||
resultCallback.accept(relationship);
|
||||
error.showToast(activity);
|
||||
}
|
||||
@@ -1206,18 +1209,9 @@ public class UiUtils {
|
||||
}
|
||||
|
||||
public static void pickAccount(Context context, String exceptFor, @StringRes int titleRes, @DrawableRes int iconRes, Consumer<AccountSession> sessionConsumer, Consumer<AlertDialog.Builder> transformDialog) {
|
||||
List<AccountSession> sessions = AccountSessionManager.getInstance().getLoggedInAccounts()
|
||||
.stream().filter(s -> !s.getID().equals(exceptFor)).collect(Collectors.toList());
|
||||
|
||||
AlertDialog.Builder builder = new M3AlertDialogBuilder(context)
|
||||
.setItems(
|
||||
sessions.stream().map(AccountSession::getFullUsername).toArray(String[]::new),
|
||||
(dialog, which) -> sessionConsumer.accept(sessions.get(which))
|
||||
)
|
||||
.setTitle(titleRes == 0 ? R.string.choose_account : titleRes)
|
||||
.setIcon(iconRes);
|
||||
if (transformDialog != null) transformDialog.accept(builder);
|
||||
builder.show();
|
||||
AccountSwitcherSheet sheet = new AccountSwitcherSheet((Activity) context, null, iconRes, titleRes == 0 ? R.string.choose_account : titleRes, exceptFor, false);
|
||||
sheet.setOnClick((accountId, open) ->sessionConsumer.accept(AccountSessionManager.get(accountId)));
|
||||
sheet.show();
|
||||
}
|
||||
|
||||
public static void restartApp() {
|
||||
@@ -1354,10 +1348,6 @@ public class UiUtils {
|
||||
openURL(context, accountID, url, true);
|
||||
}
|
||||
|
||||
public static void openURL(Context context, String accountID, String url, Object parentObject) {
|
||||
openURL(context, accountID, url, !(parentObject instanceof Status || parentObject instanceof Account));
|
||||
}
|
||||
|
||||
public static void openURL(Context context, String accountID, String url, boolean launchBrowser) {
|
||||
lookupURL(context, accountID, url, (clazz, args) -> {
|
||||
if (clazz == null) {
|
||||
@@ -1475,7 +1465,7 @@ public class UiUtils {
|
||||
return;
|
||||
}
|
||||
Optional<Account> account = results.accounts.stream()
|
||||
.filter(a -> uri.equals(Uri.parse(a.url))).findAny();
|
||||
.filter(a -> uri.getPath().contains(a.username)).findAny();
|
||||
if (account.isPresent()) {
|
||||
args.putParcelable("profileAccount", Parcels.wrap(account.get()));
|
||||
go.accept(ProfileFragment.class, args);
|
||||
@@ -1497,6 +1487,8 @@ public class UiUtils {
|
||||
}
|
||||
|
||||
public static void copyText(View v, String text) {
|
||||
if(GlobalUserPreferences.removeTrackingParams)
|
||||
text=Tracking.cleanUrlsInText(text);
|
||||
Context context = v.getContext();
|
||||
context.getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, text));
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || UiUtils.isMIUI()) { // Android 13+ SystemUI shows its own thing when you put things into the clipboard
|
||||
@@ -1523,6 +1515,10 @@ public class UiUtils {
|
||||
return !TextUtils.isEmpty(getSystemProperty("ro.build.version.emui"));
|
||||
}
|
||||
|
||||
public static boolean isMagic() {
|
||||
return !TextUtils.isEmpty(getSystemProperty("ro.build.version.magic"));
|
||||
}
|
||||
|
||||
public static int alphaBlendColors(int color1, int color2, float alpha) {
|
||||
float alpha0 = 1f - alpha;
|
||||
int r = Math.round(((color1 >> 16) & 0xFF) * alpha0 + ((color2 >> 16) & 0xFF) * alpha);
|
||||
@@ -1543,7 +1539,7 @@ public class UiUtils {
|
||||
|
||||
public static boolean pickAccountForCompose(Activity activity, String accountID, Bundle args) {
|
||||
if (AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1) {
|
||||
UiUtils.pickAccount(activity, accountID, 0, 0, session -> {
|
||||
UiUtils.pickAccount(activity, accountID, 0, R.drawable.ic_fluent_compose_28_regular, session -> {
|
||||
args.putString("account", session.getID());
|
||||
Nav.go(activity, ComposeFragment.class, args);
|
||||
}, null);
|
||||
@@ -1645,17 +1641,6 @@ public class UiUtils {
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static void populateAccountsMenu(String excludeAccountID, Menu menu, Consumer<AccountSession> onClick) {
|
||||
List<AccountSession> sessions=AccountSessionManager.getInstance().getLoggedInAccounts();
|
||||
sessions.stream().filter(s -> !s.getID().equals(excludeAccountID)).forEach(s -> {
|
||||
String username = "@"+s.self.username+"@"+s.domain;
|
||||
menu.add(username).setOnMenuItemClickListener((c) -> {
|
||||
onClick.accept(s);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static void showFragmentForNotification(Context context, Notification n, String accountID, Bundle extras) {
|
||||
if (extras == null) extras = new Bundle();
|
||||
extras.putString("account", accountID);
|
||||
@@ -1754,10 +1739,48 @@ public class UiUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static void openSystemShareSheet(Context context, String url){
|
||||
public static Uri getFileProviderUri(Context context, File file){
|
||||
return FileProvider.getUriForFile(context, context.getPackageName()+".fileprovider", file);
|
||||
}
|
||||
|
||||
public static void openSystemShareSheet(Context context, Object obj){
|
||||
Intent intent=new Intent(Intent.ACTION_SEND);
|
||||
intent.setType("text/plain");
|
||||
Account account;
|
||||
String url;
|
||||
String previewTitle;
|
||||
|
||||
if(obj instanceof Account acc){
|
||||
account=acc;
|
||||
url=acc.url;
|
||||
previewTitle=context.getString(R.string.share_sheet_preview_profile, account.displayName);
|
||||
}else if(obj instanceof Status st){
|
||||
account=st.account;
|
||||
url=st.url;
|
||||
String postText=st.getStrippedText();
|
||||
if(TextUtils.isEmpty(postText)){
|
||||
previewTitle=context.getString(R.string.share_sheet_preview_profile, account.displayName);
|
||||
}else{
|
||||
if(postText.length()>100)
|
||||
postText=postText.substring(0, 100)+"...";
|
||||
previewTitle=context.getString(R.string.share_sheet_preview_post, account.displayName, postText);
|
||||
}
|
||||
}else{
|
||||
throw new IllegalArgumentException("Unsupported share object type");
|
||||
}
|
||||
|
||||
intent.putExtra(Intent.EXTRA_TEXT, url);
|
||||
intent.putExtra(Intent.EXTRA_TITLE, previewTitle);
|
||||
ImageCache cache=ImageCache.getInstance(context);
|
||||
try{
|
||||
File ava=cache.getFile(new UrlImageLoaderRequest(account.avatarStatic));
|
||||
if(ava==null || !ava.exists())
|
||||
ava=cache.getFile(new UrlImageLoaderRequest(account.avatar));
|
||||
if(ava!=null && ava.exists()){
|
||||
intent.setClipData(ClipData.newRawUri(null, getFileProviderUri(context, ava)));
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
}
|
||||
}catch(IOException ignore){}
|
||||
context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_toot_title)));
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
|
||||
contextMenu.inflate(R.menu.profile);
|
||||
contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected);
|
||||
menuButton.setOnClickListener(v->showMenuFromButton());
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI())
|
||||
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI() && !UiUtils.isMagic())
|
||||
contextMenu.getMenu().setGroupDividerEnabled(true);
|
||||
UiUtils.enablePopupMenuIcons(fragment.getContext(), contextMenu);
|
||||
|
||||
@@ -148,9 +148,7 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
|
||||
pronouns.setVisibility(pronounsString.isPresent() ? View.VISIBLE : View.GONE);
|
||||
pronounsString.ifPresent(p -> HtmlParser.setTextWithCustomEmoji(pronouns, p, item.account.emojis));
|
||||
|
||||
if(item.account.bot) {
|
||||
botIcon.setVisibility(View.VISIBLE);
|
||||
}
|
||||
botIcon.setVisibility(item.account.bot ? View.VISIBLE : View.GONE);
|
||||
|
||||
/* unused in megalodon
|
||||
boolean hasVerifiedLink=item.verifiedLink!=null;
|
||||
@@ -187,8 +185,8 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
|
||||
avatar.setImageDrawable(image);
|
||||
}else{
|
||||
item.emojiHelper.setImageDrawable(index-1, image);
|
||||
name.invalidate();
|
||||
bio.invalidate();
|
||||
name.setText(name.getText());
|
||||
bio.setText(bio.getText());
|
||||
}
|
||||
|
||||
if(image instanceof Animatable a && !a.isRunning())
|
||||
@@ -298,10 +296,7 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
|
||||
|
||||
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);
|
||||
fragment.startActivity(Intent.createChooser(intent, item.getTitle()));
|
||||
UiUtils.openSystemShareSheet(fragment.getActivity(), account);
|
||||
}else if(id==R.id.mute){
|
||||
UiUtils.confirmToggleMuteUser(fragment.getActivity(), accountID, account, relationship.muting, this::updateRelationship);
|
||||
}else if(id==R.id.block){
|
||||
|
||||
@@ -95,7 +95,7 @@ public class MastodonLanguage {
|
||||
private final MastodonLanguage fallbackLanguage;
|
||||
|
||||
public LanguageResolver(Instance instanceInfo) {
|
||||
String fallbackLanguageTag = (instanceInfo.languages != null && !instanceInfo.languages.isEmpty()) ? instanceInfo.languages.get(0) : ENGLISH.languageTag;
|
||||
String fallbackLanguageTag = (instanceInfo != null && instanceInfo.languages != null && !instanceInfo.languages.isEmpty()) ? instanceInfo.languages.get(0) : ENGLISH.languageTag;
|
||||
fallbackLanguage = allLanguages.stream()
|
||||
.filter(l->l.languageTag.equalsIgnoreCase(fallbackLanguageTag)).findAny()
|
||||
.orElse(ENGLISH);
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package org.joinmastodon.android.utils;
|
||||
|
||||
import static org.joinmastodon.android.model.FilterAction.HIDE;
|
||||
import static org.joinmastodon.android.model.FilterAction.WARN;
|
||||
import static org.joinmastodon.android.model.FilterContext.ACCOUNT;
|
||||
import static org.joinmastodon.android.model.FilterContext.HOME;
|
||||
import static org.joinmastodon.android.model.FilterContext.NOTIFICATIONS;
|
||||
import static org.joinmastodon.android.model.FilterContext.PUBLIC;
|
||||
import static org.joinmastodon.android.model.FilterContext.THREAD;
|
||||
|
||||
import org.joinmastodon.android.GlobalUserPreferences;
|
||||
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||
import org.joinmastodon.android.model.AltTextFilter;
|
||||
import org.joinmastodon.android.model.LegacyFilter;
|
||||
import org.joinmastodon.android.model.FilterAction;
|
||||
import org.joinmastodon.android.model.FilterContext;
|
||||
import org.joinmastodon.android.model.Status;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
// TODO: This whole class has been ditched upstream. I plan to eventually refactor it to only have the still relevant clientFilters code
|
||||
|
||||
public class StatusFilterPredicate implements Predicate<Status>{
|
||||
private final List<LegacyFilter> clientFilters;
|
||||
private final List<LegacyFilter> filters;
|
||||
private final FilterContext context;
|
||||
private final FilterAction action;
|
||||
private LegacyFilter applyingFilter;
|
||||
|
||||
/**
|
||||
* @param context null makes the predicate pass automatically
|
||||
* @param action defines what the predicate should check:
|
||||
* status should not be hidden or should not display with warning
|
||||
*/
|
||||
public StatusFilterPredicate(List<LegacyFilter> filters, FilterContext context, FilterAction action){
|
||||
this.filters = filters;
|
||||
this.context = context;
|
||||
this.action = action;
|
||||
this.clientFilters = getClientFilters();
|
||||
}
|
||||
|
||||
public StatusFilterPredicate(List<LegacyFilter> filters, FilterContext context){
|
||||
this(filters, context, HIDE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param context null makes the predicate pass automatically
|
||||
* @param action defines what the predicate should check:
|
||||
* status should not be hidden or should not display with warning
|
||||
*/
|
||||
public StatusFilterPredicate(String accountID, FilterContext context, FilterAction action){
|
||||
filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(context)).collect(Collectors.toList());
|
||||
this.context = context;
|
||||
this.action = action;
|
||||
this.clientFilters = getClientFilters();
|
||||
}
|
||||
|
||||
private List<LegacyFilter> getClientFilters() {
|
||||
List<LegacyFilter> filters = new ArrayList<>();
|
||||
if(!GlobalUserPreferences.showPostsWithoutAlt) {
|
||||
filters.add(new AltTextFilter(WARN, HOME, PUBLIC, ACCOUNT, THREAD, NOTIFICATIONS));
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param context null makes the predicate pass automatically
|
||||
*/
|
||||
public StatusFilterPredicate(String accountID, FilterContext context){
|
||||
this(accountID, context, HIDE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the status should be displayed without being hidden/warned about.
|
||||
* will always return true if the context is null.
|
||||
* true = display this status,
|
||||
* false = filter this status
|
||||
*/
|
||||
@Override
|
||||
public boolean test(Status status){
|
||||
if (context == null) return true;
|
||||
|
||||
Stream<LegacyFilter> matchingFilters = status.filtered != null
|
||||
// use server-provided per-status info (status.filtered) if available
|
||||
? status.filtered.stream().map(f -> f.filter)
|
||||
// or fall back to cached filters
|
||||
: filters.stream().filter(filter -> filter.matches(status));
|
||||
|
||||
Optional<LegacyFilter> applyingFilter = matchingFilters
|
||||
// discard expired filters
|
||||
.filter(filter -> filter.expiresAt == null || filter.expiresAt.isAfter(Instant.now()))
|
||||
// only apply filters for given context
|
||||
.filter(filter -> filter.context.contains(context))
|
||||
// treating filterAction = null (from filters list) as FilterAction.HIDE
|
||||
.filter(filter -> filter.filterAction == null ? action == HIDE : filter.filterAction == action)
|
||||
.findAny();
|
||||
|
||||
//Apply client filters if no server filter is triggered
|
||||
if (applyingFilter.isEmpty() && !clientFilters.isEmpty()) {
|
||||
applyingFilter = clientFilters.stream()
|
||||
.filter(filter -> filter.context.contains(context))
|
||||
.filter(filter -> filter.filterAction == null ? action == HIDE : filter.filterAction == action)
|
||||
.filter(filter -> filter.matches(status))
|
||||
.findAny();
|
||||
}
|
||||
|
||||
this.applyingFilter = applyingFilter.orElse(null);
|
||||
return applyingFilter.isEmpty();
|
||||
}
|
||||
|
||||
public LegacyFilter getApplyingFilter() {
|
||||
return applyingFilter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package org.joinmastodon.android.utils;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.util.Patterns;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
// Inspired by https://github.com/GeopJr/Tuba/blob/91a036edff9ab1ffb38d5b54a33023e5db551051/src/Utils/Tracking.vala
|
||||
|
||||
public class Tracking{
|
||||
/* https://github.com/brave/brave-core/blob/face8d58ab81422480c8c05b9ba5d518e1a2d227/components/query_filter/utils.cc#L23-L119 */
|
||||
private static final String[] TRACKING_IDS={
|
||||
// Strip any utm_ based ones
|
||||
"utm_",
|
||||
// https://github.com/brave/brave-browser/issues/4239
|
||||
"fbclid", "gclid", "msclkid", "mc_eid",
|
||||
// New Facebook one
|
||||
"mibexid",
|
||||
// https://github.com/brave/brave-browser/issues/9879
|
||||
"dclid",
|
||||
// https://github.com/brave/brave-browser/issues/13644
|
||||
"oly_anon_id", "oly_enc_id",
|
||||
// https://github.com/brave/brave-browser/issues/11579
|
||||
"_openstat",
|
||||
// https://github.com/brave/brave-browser/issues/11817
|
||||
"vero_conv", "vero_id",
|
||||
// https://github.com/brave/brave-browser/issues/13647
|
||||
"wickedid",
|
||||
// https://github.com/brave/brave-browser/issues/11578
|
||||
"yclid",
|
||||
// https://github.com/brave/brave-browser/issues/8975
|
||||
"__s",
|
||||
// https://github.com/brave/brave-browser/issues/17451
|
||||
"rb_clickid",
|
||||
// https://github.com/brave/brave-browser/issues/17452
|
||||
"s_cid",
|
||||
// https://github.com/brave/brave-browser/issues/17507
|
||||
"ml_subscriber", "ml_subscriber_hash",
|
||||
// https://github.com/brave/brave-browser/issues/18020
|
||||
"twclid",
|
||||
// https://github.com/brave/brave-browser/issues/18758
|
||||
"gbraid", "wbraid",
|
||||
// https://github.com/brave/brave-browser/issues/9019
|
||||
"_hsenc", "__hssc", "__hstc", "__hsfp", "hsCtaTracking",
|
||||
// https://github.com/brave/brave-browser/issues/22082
|
||||
"oft_id", "oft_k", "oft_lk", "oft_d", "oft_c", "oft_ck", "oft_ids", "oft_sk",
|
||||
// https://github.com/brave/brave-browser/issues/11580
|
||||
"igshid",
|
||||
// Instagram Threads
|
||||
"ad_id", "adset_id", "campaign_id", "ad_name", "adset_name", "campaign_name", "placement",
|
||||
// Reddit
|
||||
"share_id", "ref", "ref_share",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tries to remove tracking parameters from a URL.
|
||||
*
|
||||
* @param url The original URL with tracking parameters
|
||||
* @return The URL with the tracking parameters removed.
|
||||
*/
|
||||
@NonNull
|
||||
public static String removeTrackingParameters(@NonNull String url){
|
||||
Uri uri=Uri.parse(url);
|
||||
if(uri==null || !uri.isHierarchical())
|
||||
return url;
|
||||
Uri.Builder uriBuilder=uri.buildUpon().clearQuery();
|
||||
|
||||
// Iterate over existing parameters and add them back if they are not tracking parameters
|
||||
for(String paramName : uri.getQueryParameterNames()){
|
||||
if(!isTrackingParameter(paramName)){
|
||||
for(String paramValue : uri.getQueryParameters(paramName)){
|
||||
uriBuilder.appendQueryParameter(paramName, paramValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uriBuilder.build().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans URLs within the provided text, removing the tracking parameters from them.
|
||||
*
|
||||
* @param text The text that may contain URLs.
|
||||
* @return The given text with cleaned URLs.
|
||||
*/
|
||||
public static String cleanUrlsInText(String text){
|
||||
Matcher matcher=Patterns.WEB_URL.matcher(text);
|
||||
StringBuffer sb=new StringBuffer();
|
||||
|
||||
while(matcher.find()){
|
||||
String url=matcher.group();
|
||||
matcher.appendReplacement(sb, removeTrackingParameters(url));
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given parameter is used for tracking.
|
||||
*/
|
||||
private static boolean isTrackingParameter(String parameter){
|
||||
return Arrays.stream(TRACKING_IDS).anyMatch(trackingId->parameter.toLowerCase().contains(trackingId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M2.75,4.504a0.75,0.75 0,0 1,0.743 0.648l0.007,0.102v13.499a0.75,0.75 0,0 1,-1.493 0.101L2,18.753v-13.5a0.75,0.75 0,0 1,0.75 -0.75ZM15.21,6.387 L15.293,6.293a1,1 0,0 1,1.32 -0.083l0.094,0.083 4.997,4.998a1,1 0,0 1,0.083 1.32l-0.083,0.093 -4.996,5.004a1,1 0,0 1,-1.499 -1.32l0.083,-0.094L18.581,13L6,13a1,1 0,0 1,-0.993 -0.883L5,12a1,1 0,0 1,0.883 -0.993L6,11h12.584l-3.291,-3.293a1,1 0,0 1,-0.083 -1.32l0.083,-0.094 -0.083,0.094Z"
|
||||
android:fillColor="#212121"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M21.25,4.5a0.75,0.75 0,0 1,0.743 0.648L22,5.25v13.5a0.75,0.75 0,0 1,-1.493 0.102l-0.007,-0.102L20.5,5.25a0.75,0.75 0,0 1,0.75 -0.75ZM12.21,6.387 L12.293,6.293a1,1 0,0 1,1.32 -0.083l0.094,0.083 4.997,4.998a1,1 0,0 1,0.083 1.32l-0.083,0.093 -4.996,5.004a1,1 0,0 1,-1.499 -1.32l0.083,-0.094L15.581,13L3,13a1,1 0,0 1,-0.993 -0.883L2,12a1,1 0,0 1,0.883 -0.993L3,11h12.584l-3.291,-3.293a1,1 0,0 1,-0.083 -1.32l0.083,-0.094 -0.083,0.094Z"
|
||||
android:fillColor="#212121"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="28dp"
|
||||
android:height="28dp"
|
||||
android:viewportWidth="28"
|
||||
android:viewportHeight="28">
|
||||
<path
|
||||
android:pathData="M25.78,3.28C26.073,2.987 26.073,2.513 25.78,2.22C25.487,1.927 25.013,1.927 24.72,2.22L11.47,15.47L11.004,17L12.53,16.53L25.78,3.28ZM6.25,3C4.455,3 3,4.455 3,6.25V21.75C3,23.545 4.455,25 6.25,25H21.75C23.545,25 25,23.545 25,21.75V11.205C25,10.79 24.664,10.455 24.25,10.455C23.836,10.455 23.5,10.79 23.5,11.205V21.75C23.5,22.716 22.716,23.5 21.75,23.5H6.25C5.284,23.5 4.5,22.716 4.5,21.75V6.25C4.5,5.284 5.284,4.5 6.25,4.5H16.795C17.21,4.5 17.545,4.164 17.545,3.75C17.545,3.336 17.21,3 16.795,3H6.25Z"
|
||||
android:fillColor="@color/fluent_default_icon_tint"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M3.28,2.22a0.75,0.75 0,1 0,-1.06 1.06l0.127,0.127c-0.223,0.397 -0.35,0.855 -0.35,1.343v3.502l0.007,0.102a0.75,0.75 0,0 0,1.493 -0.102V4.75l0.007,-0.128 0.006,-0.051 3.675,3.675a7.573,7.573 0,0 0,-2.02 2.251,6.262 6.262,0 0,0 -0.358,0.716l-0.006,0.015c-0.002,0.005 -0.162,0.75 0.436,0.974a0.75,0.75 0,0 0,0.964 -0.436l0.001,-0.002 0.008,-0.02 0.044,-0.1c0.043,-0.09 0.11,-0.226 0.206,-0.391a6.072,6.072 0,0 1,1.8 -1.932l1.494,1.494a3.5,3.5 0,1 0,4.93 4.93l4.744,4.744a1.26,1.26 0,0 1,-0.18 0.013h-3.5l-0.103,0.007a0.75,0.75 0,0 0,0.102 1.493h3.5l0.168,-0.005a2.732,2.732 0,0 0,1.176 -0.345l0.128,0.128a0.75,0.75 0,0 0,1.061 -1.06L3.28,2.22ZM11.45,8.268 L10.122,6.94A9.051,9.051 0,0 1,12 6.75c2.726,0 4.535,1.1 5.655,2.22a7.573,7.573 0,0 1,1.18 1.527,6.294 6.294,0 0,1 0.34,0.67l0.018,0.046 0.006,0.015 0.002,0.005v0.002l0.001,0.002a0.75,0.75 0,0 1,-0.439 0.965,0.758 0.758,0 0,1 -0.965,-0.438l-0.008,-0.02s-0.023,-0.055 -0.044,-0.1a4.776,4.776 0,0 0,-0.206 -0.391,6.073 6.073,0 0,0 -0.945,-1.223c-0.88,-0.88 -2.32,-1.78 -4.595,-1.78a8.22,8.22 0,0 0,-0.55 0.018ZM21.997,18.815l-1.5,-1.5V15.75a0.75,0.75 0,0 1,1.493 -0.102l0.007,0.102v3.065ZM6.682,3.5 L5.182,2h3.065a0.75,0.75 0,0 1,0.102 1.493l-0.102,0.007H6.682ZM2.747,15a0.75,0.75 0,0 1,0.743 0.648l0.007,0.102v3.502l0.007,0.128a1.25,1.25 0,0 0,1.115 1.116l0.128,0.006h3.5l0.102,0.007a0.75,0.75 0,0 1,0 1.486l-0.102,0.007h-3.5l-0.167,-0.005a2.75,2.75 0,0 1,-2.578 -2.57l-0.005,-0.175V15.75l0.007,-0.102A0.75,0.75 0,0 1,2.747 15ZM19.247,2l0.168,0.005a2.75,2.75 0,0 1,2.577 2.57l0.005,0.175v3.502l-0.007,0.102a0.75,0.75 0,0 1,-1.486 0l-0.007,-0.102V4.75l-0.006,-0.128a1.25,1.25 0,0 0,-1.116 -1.116l-0.128,-0.006h-3.5l-0.102,-0.007a0.75,0.75 0,0 1,0 -1.486L15.747,2h3.5Z"
|
||||
android:fillColor="@color/fluent_default_icon_tint"/>
|
||||
</vector>
|
||||
3
mastodon/src/main/res/drawable/ic_gnome_logo.xml
Normal file
3
mastodon/src/main/res/drawable/ic_gnome_logo.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="103.97" android:viewportWidth="85.6" android:width="19.759546dp">
|
||||
<path android:fillColor="@color/fluent_default_icon_tint" android:pathData="m74.46,0c-11.52,0 -18.66,8.37 -20.99,16.54 -1.17,4.08 -1.28,8.19 -0.1,11.64 1.18,3.45 4.25,6.36 8.26,6.36 4,0 7.83,-2.21 11.44,-5.17 3.61,-2.95 6.93,-6.79 9.28,-10.75 2.35,-3.96 3.91,-8.07 2.96,-12.02 -0.48,-1.97 -1.78,-3.83 -3.68,-4.96 -1.91,-1.13 -4.29,-1.63 -7.17,-1.63zM40.1,4c-2.84,0.56 -5.36,2.67 -6.65,5.04 -1.48,2.71 -1.87,5.83 -1.68,8.83 0.19,3 0.98,5.9 2.28,8.28 1.29,2.38 3.22,4.58 6.17,4.76 1.58,0.1 2.97,-0.57 4.03,-1.41 1.06,-0.84 1.92,-1.9 2.67,-3.11 1.52,-2.4 2.64,-5.4 3.26,-8.47 0.62,-3.08 0.76,-6.22 -0.2,-9.03 -0.96,-2.81 -4,-5.3 -7.22,-5.3h-0c-1.04,0.07 -1.77,0.19 -2.65,0.42zM74.46,5.29c2.25,0 3.68,0.42 4.46,0.88 0.78,0.47 1.06,0.89 1.24,1.66 0.37,1.53 -0.39,4.73 -2.37,8.08 -1.98,3.35 -4.99,6.82 -8.08,9.35 -2.76,2.26 -5.64,3.65 -7.48,3.92 -0.22,0.03 -0.42,0.05 -0.6,0.05 -1.77,0 -2.55,-0.75 -3.25,-2.78 -0.7,-2.04 -0.74,-5.24 0.18,-8.46 1.84,-6.45 6.89,-12.7 15.9,-12.7zM42.74,8.87c1.55,0 1.76,0.34 2.24,1.72 0.47,1.38 0.51,3.81 0.02,6.28 -0.49,2.47 -1.48,5 -2.55,6.69 -0.53,0.84 -1.09,1.47 -1.48,1.78 -0.39,0.31 -0.52,0.27 -0.42,0.28 -0.12,-0.01 -1.01,-0.46 -1.85,-2 -0.84,-1.54 -1.5,-3.83 -1.64,-6.09 -0.15,-2.26 0.23,-4.46 1.04,-5.95 0.81,-1.49 1.85,-2.38 3.97,-2.66 0.26,-0.03 0.48,-0.05 0.68,-0.05zM22.67,11.53c-0.99,0.02 -1.98,0.25 -2.95,0.66 -3.04,1.25 -4.97,3.81 -5.62,6.49 -0.65,2.68 -0.26,5.43 0.59,7.93 0.85,2.5 2.17,4.78 3.82,6.51 1.65,1.74 3.85,3.23 6.54,2.69 2.76,-0.56 3.98,-2.91 4.74,-5.16 0.76,-2.25 1.07,-4.85 0.98,-7.46 -0.08,-2.61 -0.54,-5.22 -1.7,-7.46 -1.16,-2.24 -3.49,-4.26 -6.4,-4.2zM22.79,16.82c0.69,-0.02 0.98,0.18 1.59,1.34 0.6,1.16 1.04,3.15 1.11,5.19 0.06,2.04 -0.23,4.17 -0.71,5.61 -0.48,1.44 -1.14,1.73 -0.78,1.66h-0v0c0.09,-0.02 -0.65,-0.1 -1.64,-1.15 -1,-1.05 -2.04,-2.77 -2.65,-4.57 -0.61,-1.8 -0.78,-3.65 -0.46,-4.98 0.32,-1.33 0.89,-2.18 2.49,-2.85 0.48,-0.2 0.82,-0.25 1.05,-0.26zM8.02,23.93c-1.45,-0.05 -2.92,0.46 -4.2,1.35 -2.49,1.72 -3.76,4.44 -3.82,6.99 -0.06,2.55 0.82,4.93 2.06,7 1.24,2.07 2.86,3.84 4.68,5.08 1.82,1.24 4.13,2.19 6.52,1.14 2.34,-1.03 3.02,-3.29 3.3,-5.32 0.28,-2.03 0.12,-4.26 -0.35,-6.46 -0.47,-2.2 -1.26,-4.37 -2.49,-6.19 -1.23,-1.82 -3.14,-3.5 -5.69,-3.58zM7.85,29.22c0.28,0.01 0.79,0.24 1.48,1.26 0.69,1.02 1.34,2.66 1.7,4.34 0.36,1.69 0.45,3.44 0.29,4.63 -0.16,1.13 -0.54,1.34 -0.2,1.19 0.19,-0.09 -0.37,0.03 -1.4,-0.67 -1.04,-0.7 -2.26,-1.99 -3.12,-3.43 -0.86,-1.43 -1.34,-2.99 -1.31,-4.15 0.03,-1.16 0.31,-1.92 1.54,-2.77 0.58,-0.4 0.86,-0.42 1.02,-0.42zM50.43,33.42c-8.43,-0.14 -18.01,1.86 -26.16,6.06 -8.15,4.21 -15,10.78 -17.01,19.79 -2.21,9.88 2.23,20.9 9.71,29.56 7.47,8.66 18.2,15.14 29.59,15.14 11.79,0 24.28,-9.92 26.76,-23.02v-0c0.3,-1.6 0.09,-3.25 -0.67,-4.57 -0.76,-1.32 -1.94,-2.22 -3.18,-2.8 -2.48,-1.16 -5.34,-1.3 -8.18,-1.01 -2.84,0.29 -5.66,1.05 -7.96,2.31 -1.15,0.63 -2.2,1.38 -3.03,2.42 -0.83,1.04 -1.41,2.53 -1.21,4.06 0.36,2.73 -0.54,4.08 -1.64,4.68 -1.1,0.6 -2.9,0.67 -5.28,-1.14 -2.11,-1.61 -2.94,-2.91 -3.16,-3.72 -0.22,-0.82 -0.11,-1.43 0.61,-2.51 1.44,-2.17 5.65,-5.13 10.54,-8.16 4.88,-3.02 10.41,-6.27 14.7,-10.25 4.29,-3.98 7.5,-9.12 6.48,-15.17 -0.68,-4.07 -3.67,-7.1 -7.43,-8.92 -3.76,-1.82 -8.41,-2.66 -13.47,-2.74zM50.34,38.71c4.5,0.08 8.5,0.88 11.26,2.22 2.76,1.33 4.16,2.94 4.51,5.03 0.65,3.84 -1.21,7.02 -4.86,10.41 -3.65,3.39 -8.94,6.56 -13.88,9.63 -4.95,3.06 -9.59,5.86 -12.16,9.73 -1.29,1.93 -1.97,4.42 -1.31,6.84 0.67,2.43 2.42,4.52 5.06,6.53 3.59,2.73 7.82,3.34 11.04,1.57 3.22,-1.77 4.9,-5.68 4.34,-10.01 0.01,0.06 -0.08,0.15 0.11,-0.09 0.19,-0.24 0.68,-0.67 1.41,-1.06 1.45,-0.79 3.75,-1.47 5.96,-1.69 2.21,-0.23 4.36,0.05 5.4,0.54 0.52,0.24 0.74,0.48 0.83,0.64 0.09,0.16 0.18,0.34 0.06,0.97 -1.93,10.22 -12.95,18.71 -21.56,18.71 -9.34,0 -18.91,-5.57 -25.58,-13.3 -6.67,-7.73 -10.22,-17.45 -8.54,-24.95 1.57,-7.04 7.03,-12.5 14.27,-16.24 7.24,-3.74 16.14,-5.6 23.64,-5.47z"/>
|
||||
</vector>
|
||||
76
mastodon/src/main/res/layout/display_item_error.xml
Normal file
76
mastodon/src/main/res/layout/display_item_error.xml
Normal file
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="8dp"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:padding="16dp"
|
||||
android:clipToPadding="false"
|
||||
android:background="@drawable/bg_settings_banner">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:scaleType="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:tint="?colorM3OnPrimaryContainer"
|
||||
android:background="@drawable/white_circle"
|
||||
android:backgroundTint="?colorM3PrimaryContainer"
|
||||
android:src="@drawable/ic_fluent_warning_24_regular" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="24dp"
|
||||
android:layout_toEndOf="@id/icon"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:textAppearance="@style/m3_title_medium"
|
||||
android:textColor="?colorM3OnSurface"
|
||||
android:singleLine="true"
|
||||
android:gravity="center_vertical"
|
||||
android:text="@string/mo_error_display_title"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_toEndOf="@id/icon"
|
||||
android:layout_below="@id/title"
|
||||
android:textAppearance="@style/m3_body_medium"
|
||||
android:minHeight="20dp"
|
||||
android:gravity="center_vertical"
|
||||
android:textColor="?colorM3OnSurface"
|
||||
android:text="@string/mo_error_display_text"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_open_browser"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/text"
|
||||
android:layout_toEndOf="@id/icon"
|
||||
android:layout_marginStart="-16dp"
|
||||
android:layout_marginBottom="-10dp"
|
||||
style="@style/Widget.Mastodon.M3.Button.Text"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:minWidth="0dp"
|
||||
android:text="@string/open_in_browser"
|
||||
tools:text="@string/resume_notifications_now"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_copy_error_details"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/text"
|
||||
android:layout_toEndOf="@id/button_open_browser"
|
||||
android:layout_marginBottom="-10dp"
|
||||
style="@style/Widget.Mastodon.M3.Button.Text"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:minWidth="0dp"
|
||||
android:text="@string/mo_error_display_copy_error_details"/>
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -29,8 +29,7 @@
|
||||
android:layout_weight="1"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"
|
||||
android:visibility="visible"
|
||||
/>
|
||||
android:visibility="visible"/>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_toStartOf="@id/button_wrap"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:textAppearance="@style/m3_headline_small"
|
||||
android:textColor="?colorM3OnSurface"
|
||||
android:maxLines="3"
|
||||
android:ellipsize="end"
|
||||
android:minHeight="48dp"
|
||||
android:gravity="center_vertical"
|
||||
tools:text="Microsoft Chose Profit Over Security"/>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/button_wrap"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignTop="@id/title"
|
||||
android:layout_alignBottom="@id/title"
|
||||
android:layout_alignParentEnd="true">
|
||||
|
||||
<Button
|
||||
android:id="@+id/profile_action_btn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="center"
|
||||
style="@style/Widget.Mastodon.M3.Button.Filled"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:text="@string/mo_trending_link_read"
|
||||
tools:text="@string/mark_all_notifications_read" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/title"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="@style/m3_label_large"
|
||||
android:textColor="?colorM3OnSurfaceVariant"
|
||||
tools:text="@string/article_by_author"/>
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_boost"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="92dp"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/button_reblog"
|
||||
android:drawableTop="@drawable/ic_boost"
|
||||
@@ -47,24 +47,24 @@
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_favorite"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="92dp"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/button_favorite"
|
||||
android:drawableTop="@drawable/ic_fluent_star_24_selector"
|
||||
style="@style/Widget.Mastodon.M3.Button.IconWithLabel"/>
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1"/>
|
||||
<!-- <Space-->
|
||||
<!-- android:layout_width="0dp"-->
|
||||
<!-- android:layout_height="1dp"-->
|
||||
<!-- android:layout_weight="1"/>-->
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_share"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/button_share"
|
||||
android:drawableTop="@drawable/ic_fluent_share_24_regular"
|
||||
style="@style/Widget.Mastodon.M3.Button.IconWithLabel"/>
|
||||
<!-- <Button-->
|
||||
<!-- android:id="@+id/btn_share"-->
|
||||
<!-- android:layout_width="wrap_content"-->
|
||||
<!-- android:layout_height="64dp"-->
|
||||
<!-- android:text="@string/button_share"-->
|
||||
<!-- android:drawableTop="@drawable/ic_fluent_share_24_regular"-->
|
||||
<!-- style="@style/Widget.Mastodon.M3.Button.IconWithLabel"/>-->
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
@@ -73,24 +73,24 @@
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_bookmark"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="92dp"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/add_bookmark"
|
||||
android:drawableTop="@drawable/ic_fluent_bookmark_24_selector"
|
||||
style="@style/Widget.Mastodon.M3.Button.IconWithLabel"/>
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1"/>
|
||||
<!-- <Space-->
|
||||
<!-- android:layout_width="0dp"-->
|
||||
<!-- android:layout_height="1dp"-->
|
||||
<!-- android:layout_weight="1"/>-->
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_download"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/download"
|
||||
android:drawableTop="@drawable/ic_fluent_arrow_download_24_regular"
|
||||
style="@style/Widget.Mastodon.M3.Button.IconWithLabel"/>
|
||||
<!-- <Button-->
|
||||
<!-- android:id="@+id/btn_download"-->
|
||||
<!-- android:layout_width="wrap_content"-->
|
||||
<!-- android:layout_height="64dp"-->
|
||||
<!-- android:text="@string/download"-->
|
||||
<!-- android:drawableTop="@drawable/ic_fluent_arrow_download_24_regular"-->
|
||||
<!-- style="@style/Widget.Mastodon.M3.Button.IconWithLabel"/>-->
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
<!-- <item android:id="@+id/share" android:title="@string/button_share" android:icon="@drawable/ic_fluent_share_24_regular"/>-->
|
||||
<item android:id="@+id/copy_link" android:title="@string/sk_copy_link_to_post" android:icon="@drawable/ic_fluent_link_24_regular"/>
|
||||
<item android:id="@+id/open_in_browser" android:title="@string/open_in_browser" android:icon="@drawable/ic_fluent_globe_24_regular"/>
|
||||
<item android:id="@+id/open_with_account" android:title="@string/sk_open_with_account" android:icon="@drawable/ic_fluent_person_swap_24_regular">
|
||||
<menu android:id="@+id/accounts" />
|
||||
</item>
|
||||
<item android:id="@+id/open_with_account" android:title="@string/sk_open_with_account" android:icon="@drawable/ic_fluent_person_swap_24_regular"/>
|
||||
</group>
|
||||
</menu>
|
||||
@@ -15,8 +15,6 @@
|
||||
<group android:id="@+id/menu_group3">
|
||||
<item android:id="@+id/open_in_browser" android:title="@string/open_in_browser" android:icon="@drawable/ic_fluent_globe_24_regular"/>
|
||||
<item android:id="@+id/share" android:title="@string/share_user" android:icon="@drawable/ic_fluent_share_24_regular"/>
|
||||
<item android:id="@+id/open_with_account" android:title="@string/sk_open_with_account" android:visible="false" android:icon="@drawable/ic_fluent_person_swap_24_regular">
|
||||
<menu android:id="@+id/accounts" />
|
||||
</item>
|
||||
<item android:id="@+id/open_with_account" android:title="@string/sk_open_with_account" android:visible="false" android:icon="@drawable/ic_fluent_person_swap_24_regular"/>
|
||||
</group>
|
||||
</menu>
|
||||
8
mastodon/src/main/res/menu/trending_links_timeline.xml
Normal file
8
mastodon/src/main/res/menu/trending_links_timeline.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/open_link"
|
||||
android:icon="@drawable/ic_fluent_open_24_regular"
|
||||
android:showAsAction="always"
|
||||
android:title="@string/mo_trending_link_read"/>
|
||||
</menu>
|
||||
@@ -34,4 +34,6 @@
|
||||
<string name="mo_instance_info_moderated_servers">خوادم تحت الإشراف</string>
|
||||
<string name="mo_donate_url">https://github.com/sponsors/LucasGGamerM</string>
|
||||
<string name="mo_emoji_recent">المُستخدَمة حديثًا</string>
|
||||
<string name="mo_muted_accounts">الحسابات المكتومة</string>
|
||||
<string name="mo_blocked_accounts">الحسابات المحظورة</string>
|
||||
</resources>
|
||||
@@ -264,7 +264,7 @@
|
||||
<string name="sk_icon_academic_cap">قبعة جامعية</string>
|
||||
<string name="sk_icon_tag">ملصقة</string>
|
||||
<string name="sk_add_timeline_tag_error_empty">لا يجب أن يُترَك الوسم فارغًا</string>
|
||||
<string name="sk_unfinished_attachments">إصلاح المرفقات؟</string>
|
||||
<string name="sk_unfinished_attachments">تحميل المرفقات</string>
|
||||
<plurals name="sk_posts_count_label">
|
||||
<item quantity="zero">لا منشور</item>
|
||||
<item quantity="one">منشور واحد</item>
|
||||
@@ -293,7 +293,7 @@
|
||||
<string name="sk_settings_forward_report_default">إعادة ”تحويل الإبلاغ“ افتراضيا</string>
|
||||
<string name="sk_content_type_mfm">MFM</string>
|
||||
<string name="sk_settings_hide_fab">إخفاء زر التحرير مبدئيا</string>
|
||||
<string name="sk_enter_emoji_hint">اضغط للتفاعل بوجوه تعبيرية</string>
|
||||
<string name="sk_enter_emoji_hint">أكتب وجها تعبيريا أو قم ببحث</string>
|
||||
<string name="sk_content_type_markdown">Markdown</string>
|
||||
<string name="sk_content_type_bbcode">BBCode</string>
|
||||
<string name="sk_enter_emoji_toast">يُرجى إدخال إيموجي</string>
|
||||
@@ -324,4 +324,20 @@
|
||||
<string name="sk_settings_enable_marquee">تمكين تمرير النص في عناوين الأشرطة</string>
|
||||
<string name="sk_settings_tabs_disable_swipe">تعطيل التمرير بين الألسِنة</string>
|
||||
<string name="sk_settings_color_palette_default">افتراضي (%s)</string>
|
||||
<string name="sk_poll_hide_results">إخفاء النتائج</string>
|
||||
<string name="sk_poll_multiple_choice">خيارات متعددة</string>
|
||||
<string name="sk_private_note_update_failed">فشل حفظ الملاحظة</string>
|
||||
<string name="sk_delete_note">حذف الملاحظة الخاصة</string>
|
||||
<string name="sk_settings_crash_log_unavailable">غير متوفر … بعد</string>
|
||||
<string name="sk_add_note">إضافة ملاحظة خاصة</string>
|
||||
<string name="sk_poll_show_results">إظهار النتائج</string>
|
||||
<string name="sk_open_post_preview">معاينة المنشور</string>
|
||||
<string name="sk_post_preview">معاينة</string>
|
||||
<string name="sk_confirm_changes">أكِّد التغييرات</string>
|
||||
<string name="sk_crash_log_copied">تم نسخ سجل الأعطال</string>
|
||||
<string name="sk_post_scheduled">تم جدولة المنشور</string>
|
||||
<string name="sk_alt_text_missing">مرفق واحد على الأقل ليس له وصف.</string>
|
||||
<string name="sk_settings_auto_reveal_anyone">ردود مِن الجميع</string>
|
||||
<string name="sk_favorited_as">تمت الإضافة إلى المفضلة كـ %s</string>
|
||||
<string name="sk_bookmarked_as">تم وضع فاصل مرجعي كـ %s</string>
|
||||
</resources>
|
||||
2
mastodon/src/main/res/values-bn/strings_sk.xml
Normal file
2
mastodon/src/main/res/values-bn/strings_sk.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
@@ -24,4 +24,8 @@
|
||||
<string name="mo_mute_label">Durada:</string>
|
||||
<string name="mo_duration_minutes_5">5 minuts</string>
|
||||
<string name="mo_mention_reblogger_automatically">En respondre, menciona automàticament el compte que ha impulsat la publicació</string>
|
||||
<string name="mo_color_palette_black_and_white">Blanc i Negre</string>
|
||||
<string name="mo_welcome_text">Per començar, per favor introduïu el nom de la vostra instància a continuació</string>
|
||||
<string name="mo_no_image_desc">Les imatges incloses no tenen descripció. Penseu a afegir-ne un per permetre que les persones amb discapacitat visual hi puguin participar.</string>
|
||||
<string name="mo_enable_dividers">Mostrar els separadors de les publicacions</string>
|
||||
</resources>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user