Compare commits

...

160 Commits

Author SHA1 Message Date
sk
10a405ef13 change self updater api url 2022-11-01 21:38:30 +01:00
sk
a4cb05080a Merge branch 'upstream' into fork 2022-11-01 21:26:44 +01:00
Gregory K
4f8f698911 Merge pull request #275 from davidmhewitt/fix-duplicate-emoji-crash
Fix crash on duplicate custom emojis
2022-11-01 21:51:24 +03:00
David Hewitt
244f2ed911 Fix crash on duplicate custom emojis
Fixes #274

Add a merge function to `Collectors.toMap` to discard any duplicate custom emojis that may be returned if a user uses the same custom emoji in both their name and profile.
2022-11-01 14:47:25 +00:00
Gregory K
0ee494bcfc Merge pull request #273 from davidmhewitt/load-post-visibility-preference
Load post privacy preference
2022-11-01 00:30:01 +03:00
David Hewitt
eea00b0d53 Load post privacy preference
This queries the user's post visibility preference when opening the composer, and sets it on the composer.

In the case of composing a reply, the user's preference is only respected if it is "more private" than the privacy of the post being replied to, as this appears to be the behaviour in the web interface (and is what I'd expect)
2022-10-31 20:51:28 +00:00
Gregory K
e8fa82d0de Merge pull request #269 from davidmhewitt/fix-image-keyboard
Fix receiving images from keyboards
2022-10-31 09:26:29 +03:00
Grishka
e381de812c Add self-updater for github builds 2022-10-31 09:26:17 +03:00
David Hewitt
8ff3ecb4d4 Fix receiving images from keyboards
The call to `super.onCreateInputConnection` was overwriting the mimes in the `outAttrs`, so we can call that first and then modify the mimes.

This fixes the ability to insert GIFs with the default GBoard GIF menu for me.
2022-10-30 14:56:47 +00:00
Grishka
1fa8a9e858 Always show domain for own account 2022-10-26 18:28:57 +03:00
Grishka
212e8893b9 Fix editing 2022-10-26 03:01:39 +03:00
Grishka
367057421b Declare and request notifications permission (should fix #262) 2022-10-26 02:46:23 +03:00
Grishka
01970ab69b Compose media attachment redesign 2022-10-04 07:35:31 +03:00
Grishka
3aa252f681 Fix editing 2022-10-01 01:12:31 +03:00
Grishka
18633291e6 Add monochrome icon 2022-09-29 01:23:00 +03:00
sk
8aeda56fc8 merge upstream changes 2022-09-08 14:56:43 +02:00
Grishka
f531a90b41 Better editing 2022-09-02 11:21:28 +03:00
Grishka
ff52c37868 Editing 2022-09-02 05:47:20 +03:00
Grishka
8fb2b454dd Post edit history + extended footer redesign 2022-09-02 02:00:25 +03:00
Grishka
265b2ad32c Fix #218 2022-08-29 21:21:08 +03:00
Grishka
ba3219d9fc Fix #209, fix #198 2022-08-29 00:57:09 +03:00
Grishka
b44e3424e3 Fix #249 2022-08-29 00:12:15 +03:00
sk
f073eba538 Merge branch 'feature/pin-posts' into fork 2022-07-22 12:18:42 +02:00
sk
7f78431eff minor code style fix 2022-07-22 12:18:16 +02:00
sk
24c1ac042c Merge branch 'master' into feature/pin-posts 2022-07-22 11:57:02 +02:00
sk
105fe68438 update app name, close #15 2022-07-22 11:51:10 +02:00
sk
46057af093 update readme 2022-07-22 11:50:04 +02:00
sk
750fa4c112 Merge branch 'master' into fork 2022-07-22 11:30:17 +02:00
Grishka
8b40643e63 Update more colors 2022-07-15 00:45:17 +03:00
Grishka
5968dcd05b Merge branch 'l10n_master' 2022-07-13 01:28:36 +03:00
Eugen Rochko
0c382bdbf6 New translations full_description.txt (Thai) 2022-07-08 15:39:13 +02:00
Grishka
74f03026cf Everything is purple now 2022-07-02 02:03:07 +03:00
Eugen Rochko
8ad6bd52ef New translations strings.xml (Russian) 2022-07-01 14:00:14 +02:00
Eugen Rochko
04da21edf3 New translations strings.xml (Catalan) 2022-06-15 20:48:39 +02:00
Eugen Rochko
ee709b6db7 New translations strings.xml (Spanish) 2022-06-15 20:48:38 +02:00
Eugen Rochko
b7432fe422 New translations full_description.txt (Spanish) 2022-06-15 19:26:46 +02:00
Eugen Rochko
8a0991d533 New translations strings.xml (Spanish) 2022-06-15 19:26:45 +02:00
Eugen Rochko
6fffe778d3 New translations strings.xml (Portuguese, Brazilian) 2022-06-13 06:36:58 +02:00
Eugen Rochko
b67b61dfe4 New translations strings.xml (Portuguese, Brazilian) 2022-06-13 05:36:15 +02:00
Eugen Rochko
65d86d9238 New translations strings.xml (Galician) 2022-06-10 07:15:17 +02:00
sk
c1e67c4f73 bump version 2022-06-08 21:44:39 +02:00
sk
e0e48f87eb Merge branch 'master' into fork 2022-06-08 21:42:42 +02:00
Grishka
b2db64022f Add pre-upload avatar and header resizing 2022-06-06 16:45:56 +03:00
Eugen Rochko
8336bfdf5c New translations strings.xml (Galician) 2022-06-06 11:07:30 +02:00
Samuel Kaiser
0ec14fe8fa Merge pull request #20 from Y32Gcnte8z/fork
fix simplified Chinese strings
2022-06-05 12:03:08 +02:00
Y32Gcnte8z
01a2f1d95c fix simplified Chinese strings 2022-06-04 13:20:56 +08:00
Eugen Rochko
b38bf5e431 New translations strings.xml (Chinese Simplified) 2022-06-03 15:20:59 +02:00
Samuel Kaiser
67b3e85837 Merge pull request #19 from Y32Gcnte8z/fork
New translations strings.xml (Chinese Simplified)
2022-06-03 11:31:19 +02:00
Eugen Rochko
310fb7db42 New translations strings.xml (Chinese Simplified) 2022-06-03 08:32:51 +02:00
Y32Gcnte8z
9f4d330ab1 New translations strings.xml (Chinese Simplified) 2022-06-03 13:55:49 +08:00
sk
25092fbcfb add icon to readme 2022-05-31 17:04:58 +02:00
sk
705e98729d initial pink branding 2022-05-31 16:51:05 +02:00
Eugen Rochko
2f24977996 New translations strings.xml (Chinese Simplified) 2022-05-29 10:45:49 +02:00
Eugen Rochko
6c336ba89e New translations strings.xml (Chinese Simplified) 2022-05-29 09:32:03 +02:00
Eugen Rochko
d1b1cb2082 New translations strings.xml (Korean) 2022-05-25 08:21:34 +02:00
Eugen Rochko
5bbe99be51 New translations full_description.txt (Portuguese) 2022-05-24 00:19:57 +02:00
Eugen Rochko
556d1e7433 New translations full_description.txt (Portuguese) 2022-05-23 23:03:11 +02:00
Eugen Rochko
293d7032ce New translations strings.xml (Russian) 2022-05-22 12:23:45 +02:00
Eugen Rochko
48e7071450 New translations strings.xml (Arabic) 2022-05-19 22:57:49 +02:00
Eugen Rochko
bcc8d55c7b New translations strings.xml (Arabic) 2022-05-19 21:55:19 +02:00
Eugen Rochko
8d477efc28 New translations strings.xml (Czech) 2022-05-19 15:32:16 +02:00
Eugen Rochko
4ae21862a5 New translations strings.xml (Czech) 2022-05-19 14:30:33 +02:00
Eugen Rochko
6e6aebef35 New translations strings.xml (Czech) 2022-05-19 13:34:29 +02:00
Eugen Rochko
cd138032da New translations strings.xml (Czech) 2022-05-18 18:19:40 +02:00
Eugen Rochko
864e6fb9d0 New translations strings.xml (Czech) 2022-05-18 16:41:35 +02:00
Eugen Rochko
9d356b0635 New translations strings.xml (Czech) 2022-05-18 15:25:33 +02:00
Eugen Rochko
6c5d720a40 New translations strings.xml (Czech) 2022-05-18 14:28:15 +02:00
Eugen Rochko
d733d76ccf New translations strings.xml (Arabic) 2022-05-18 13:29:20 +02:00
Eugen Rochko
3483d8c3c0 New translations strings.xml (Portuguese, Brazilian) 2022-05-18 09:01:46 +02:00
Eugen Rochko
0f326c1362 New translations strings.xml (Kabyle) 2022-05-17 23:04:46 +02:00
Eugen Rochko
c6eda38400 New translations strings.xml (Kabyle) 2022-05-17 22:08:14 +02:00
Eugen Rochko
3c59c8cc0f New translations strings.xml (Arabic) 2022-05-17 22:08:13 +02:00
Eugen Rochko
8f1b9ec092 New translations strings.xml (French) 2022-05-17 22:08:12 +02:00
Eugen Rochko
5a42136395 New translations strings.xml (Czech) 2022-05-17 21:03:40 +02:00
Eugen Rochko
e9b347d130 New translations strings.xml (Thai) 2022-05-17 21:03:39 +02:00
Eugen Rochko
d86e203127 New translations strings.xml (Italian) 2022-05-17 18:03:57 +02:00
Eugen Rochko
c80417e671 New translations strings.xml (Ukrainian) 2022-05-17 18:03:56 +02:00
Eugen Rochko
55a55fbb1c New translations strings.xml (Croatian) 2022-05-17 18:03:55 +02:00
Eugen Rochko
b42236999b New translations strings.xml (Occitan) 2022-05-17 18:03:53 +02:00
Eugen Rochko
8ca8bd765b New translations strings.xml (Bosnian) 2022-05-17 18:03:51 +02:00
Eugen Rochko
7f4cf77283 New translations strings.xml (Portuguese, Brazilian) 2022-05-17 18:03:50 +02:00
Eugen Rochko
950d413bd1 New translations strings.xml (Chinese Traditional) 2022-05-17 18:03:49 +02:00
Eugen Rochko
475827b1c1 New translations strings.xml (Chinese Simplified) 2022-05-17 18:03:48 +02:00
Eugen Rochko
1c9164e559 New translations strings.xml (Galician) 2022-05-17 18:03:47 +02:00
Eugen Rochko
7311a394d8 New translations strings.xml (Turkish) 2022-05-17 18:03:46 +02:00
Eugen Rochko
aa09bc7ab2 New translations strings.xml (Russian) 2022-05-17 18:03:45 +02:00
Eugen Rochko
267a6a75ef New translations strings.xml (Portuguese) 2022-05-17 18:03:43 +02:00
Eugen Rochko
37660b4c73 New translations strings.xml (Polish) 2022-05-17 18:03:42 +02:00
Eugen Rochko
15b4d46ea1 New translations strings.xml (Korean) 2022-05-17 18:03:41 +02:00
Eugen Rochko
0b99e76b25 New translations strings.xml (Japanese) 2022-05-17 18:03:40 +02:00
Eugen Rochko
c851f666b3 New translations strings.xml (Basque) 2022-05-17 18:03:38 +02:00
Eugen Rochko
7662c81754 New translations strings.xml (German) 2022-05-17 18:03:36 +02:00
Eugen Rochko
5c1b583448 New translations strings.xml (Catalan) 2022-05-17 18:03:35 +02:00
Eugen Rochko
4071c1552d New translations strings.xml (Swedish) 2022-05-17 18:03:34 +02:00
Eugen Rochko
89856a81a3 New translations strings.xml (Spanish) 2022-05-17 18:03:33 +02:00
Eugen Rochko
005ddfb651 New translations strings.xml (Vietnamese) 2022-05-17 18:03:32 +02:00
Eugen Rochko
92d10d59c6 New translations strings.xml (Armenian) 2022-05-17 18:03:31 +02:00
Eugen Rochko
150f70edd8 New translations strings.xml (Arabic) 2022-05-17 18:03:22 +02:00
Eugen Rochko
73019eaade New translations strings.xml (Thai) 2022-05-17 18:03:17 +02:00
Eugen Rochko
46bac59ff5 New translations strings.xml (Finnish) 2022-05-17 18:03:15 +02:00
Eugen Rochko
9b162cb63b New translations strings.xml (Kabyle) 2022-05-17 18:03:13 +02:00
Eugen Rochko
7d216314c9 New translations strings.xml (French) 2022-05-17 18:03:11 +02:00
Eugen Rochko
28787b4068 New translations strings.xml (Vietnamese) 2022-05-17 15:22:20 +02:00
Eugen Rochko
a73ea62a9c New translations strings.xml (Galician) 2022-05-17 13:38:15 +02:00
Eugen Rochko
69b399e397 New translations strings.xml (Japanese) 2022-05-17 06:27:43 +02:00
Eugen Rochko
fc2c033e93 New translations strings.xml (Chinese Simplified) 2022-05-17 05:29:56 +02:00
Eugen Rochko
1d81abca5b New translations title.txt (Czech) 2022-05-17 04:16:52 +02:00
Eugen Rochko
0f3cd5d8d0 New translations short_description.txt (Czech) 2022-05-17 04:16:51 +02:00
Eugen Rochko
f0476f3187 New translations full_description.txt (Czech) 2022-05-17 04:16:50 +02:00
Eugen Rochko
b4677d14e5 New translations strings.xml (Czech) 2022-05-17 04:16:50 +02:00
Eugen Rochko
a8837bd4f8 New translations strings.xml (Basque) 2022-05-16 23:18:13 +02:00
Eugen Rochko
c6991a7067 New translations strings.xml (Chinese Simplified) 2022-05-16 19:37:18 +02:00
Eugen Rochko
0723e942f0 New translations strings.xml (Chinese Simplified) 2022-05-16 18:11:12 +02:00
Eugen Rochko
52fd300d1e New translations strings.xml (German) 2022-05-15 20:26:29 +02:00
Eugen Rochko
68c9f7a861 New translations full_description.txt (German) 2022-05-15 19:31:06 +02:00
Eugen Rochko
8eb0b12a09 New translations strings.xml (German) 2022-05-15 19:31:05 +02:00
Eugen Rochko
68ecd7bc28 New translations strings.xml (German) 2022-05-14 18:55:50 +02:00
Eugen Rochko
5c7d4e389f New translations strings.xml (Polish) 2022-05-14 16:56:43 +02:00
Eugen Rochko
55fd74c227 New translations strings.xml (Italian) 2022-05-14 11:30:44 +02:00
Eugen Rochko
b65b7c53bc New translations strings.xml (Chinese Traditional) 2022-05-14 05:38:51 +02:00
Eugen Rochko
7a23c9b348 New translations strings.xml (Italian) 2022-05-13 19:23:19 +02:00
Eugen Rochko
32cc760272 New translations full_description.txt (Polish) 2022-05-11 00:53:22 +02:00
Eugen Rochko
e105764aa8 New translations strings.xml (Polish) 2022-05-11 00:53:21 +02:00
Eugen Rochko
5a9a352e56 New translations full_description.txt (Polish) 2022-05-10 23:44:44 +02:00
Eugen Rochko
7deb2d452e New translations short_description.txt (Swedish) 2022-05-10 18:01:59 +02:00
Eugen Rochko
c3d2df88e8 New translations full_description.txt (Swedish) 2022-05-10 18:01:58 +02:00
Eugen Rochko
9943d19c31 New translations short_description.txt (Finnish) 2022-05-10 18:01:56 +02:00
Eugen Rochko
51c1e115c5 New translations full_description.txt (Finnish) 2022-05-10 18:01:55 +02:00
Eugen Rochko
f83ff93c68 New translations strings.xml (Finnish) 2022-05-10 18:01:54 +02:00
Eugen Rochko
7deb5d44c2 New translations strings.xml (Swedish) 2022-05-10 18:01:53 +02:00
Eugen Rochko
3140ae8046 New translations strings.xml (Finnish) 2022-05-10 17:00:35 +02:00
Eugen Rochko
772f79219b New translations strings.xml (Arabic) 2022-05-09 01:41:42 +02:00
Eugen Rochko
8830d67af0 New translations full_description.txt (Polish) 2022-05-09 00:34:14 +02:00
Eugen Rochko
89c02be41c New translations strings.xml (Polish) 2022-05-09 00:34:13 +02:00
Eugen Rochko
1d092c660b New translations strings.xml (Polish) 2022-05-08 23:32:42 +02:00
Eugen Rochko
a34084da5a New translations strings.xml (Vietnamese) 2022-05-07 17:18:03 +02:00
Eugen Rochko
212f5a9beb New translations full_description.txt (Korean) 2022-05-07 17:18:02 +02:00
Eugen Rochko
f6333de4e6 New translations strings.xml (Korean) 2022-05-07 17:17:59 +02:00
Eugen Rochko
5af22f1bab New translations strings.xml (Korean) 2022-05-07 16:19:40 +02:00
Eugen Rochko
02d866d7d6 New translations strings.xml (Turkish) 2022-05-06 23:36:32 +02:00
Eugen Rochko
fa7aa6240b New translations strings.xml (Turkish) 2022-05-06 22:37:34 +02:00
Eugen Rochko
cb38e0d367 New translations strings.xml (Thai) 2022-05-06 17:20:31 +02:00
Eugen Rochko
a133a1d01f New translations strings.xml (Thai) 2022-05-06 16:22:05 +02:00
Eugen Rochko
79bfc43431 New translations strings.xml (Thai) 2022-05-06 15:22:42 +02:00
Eugen Rochko
72f3a51af7 New translations strings.xml (Italian) 2022-05-06 00:01:49 +02:00
Eugen Rochko
ee73b487ae New translations strings.xml (Italian) 2022-05-05 23:05:54 +02:00
Eugen Rochko
e580d2e890 New translations strings.xml (Thai) 2022-05-05 22:07:42 +02:00
Eugen Rochko
4f6f53061f New translations strings.xml (Thai) 2022-05-05 19:32:57 +02:00
Eugen Rochko
be23ec4176 New translations strings.xml (Thai) 2022-05-05 18:32:43 +02:00
Eugen Rochko
186636c2ef New translations strings.xml (French) 2022-05-05 18:32:42 +02:00
Eugen Rochko
f0396ff418 New translations strings.xml (Polish) 2022-05-05 16:53:36 +02:00
Eugen Rochko
67e793b56a New translations strings.xml (Japanese) 2022-05-05 15:31:36 +02:00
Eugen Rochko
d84f011d27 New translations strings.xml (French) 2022-05-05 15:31:35 +02:00
Eugen Rochko
f685d9ccdd New translations strings.xml (French) 2022-05-05 14:36:22 +02:00
Eugen Rochko
380c742f54 New translations full_description.txt (Korean) 2022-05-05 06:07:07 +02:00
Eugen Rochko
ed84ea6162 New translations strings.xml (Korean) 2022-05-05 06:07:06 +02:00
Eugen Rochko
e99ffc0d4c New translations strings.xml (Korean) 2022-05-05 05:05:13 +02:00
Eugen Rochko
ca015db188 New translations strings.xml (Arabic) 2022-05-05 00:05:19 +02:00
Eugen Rochko
af3a3761f2 New translations strings.xml (French) 2022-05-04 19:48:30 +02:00
Eugen Rochko
95085b6306 New translations strings.xml (Italian) 2022-05-04 18:51:31 +02:00
131 changed files with 3493 additions and 636 deletions

View File

@@ -1,8 +1,6 @@
# Forked Mastodon for Android
![Pink version of the Mastodon for Android launcher icon](mastodon/src/main/res/mipmap-xhdpi/ic_launcher_round.png)
This is the repository for an officially forked Android app for Mastodon.
Learn more about the official app in the [blog post](https://blog.joinmastodon.org/2022/02/official-mastodon-for-android-app-is-coming-soon/).
# Mastodon for Android Fork
## Changes
@@ -18,6 +16,19 @@ Learn more about the official app in the [blog post](https://blog.joinmastodon.o
* [Implement a bookmark button and list](https://github.com/sk22/mastodon-android-fork/tree/feature/bookmarks) ([Fixes issue](https://github.com/mastodon/mastodon-android/issues/22))
* [Implement deleting and re-drafting](https://github.com/sk22/mastodon-android-fork/tree/feature/delete-redraft) ([Fixes issue](https://github.com/mastodon/mastodon-android/issues/21))
## Fork-specific changes
* Custom app name
* Custom icon: Modulate upstream icon using ImageMagick
```bash
mogrify -modulate 90,100,140 mastodon/src/main/res/mipmap-*/ic_launcher*.png
```
* Custom primary color: Hue of all `primary` colors in `colors.xml` is rotated, on basis of upstream Mastodon's [old branding](https://github.com/mastodon/mastodon-android/commit/74f03026cfcfcfd23237c38ff47d2b2a98a6f92a#diff-59134ec2a1cf3761f80b0ecccbbf8b9e433d9780d2f5c5d6ac3ac8cc254e808f)
by `109.8°` (equivalent of `161%`, done by hand using
[PineTools](https://pinetools.com/shift-hue-color))
## Building
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:

View File

@@ -0,0 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
More features:
• Dark Mode: Read posts in light, dark, or true black mode
• Polls: Ask followers for their opinion and tally the votes
• Explore: Trending hashtags and accounts are a tap away
• Notifications: Get notified about new follows, replies, and reblogs
• Sharing: Post directly to Mastodon from any share sheet in any app
• Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.

View File

@@ -0,0 +1 @@
Decentralized social network

View File

@@ -0,0 +1 @@
Mastodon

View File

@@ -1,8 +1,8 @@
Mastodon ist das größte dezentralisierte soziale Netzwerk im Internet. Statt einer einzigen Website ist es ein Netzwerk von Millionen von Benutzer*innen in unabhängigen Gemeinschaften, die alle miteinander interagieren können. Egal was dich interessiert, auf Mastodon kannst du interessierte Leute treffen, die darüber schreiben!
Trete einer Gemeinschaft bei und erstelle dein Profil. Finde und verfolge faszinierende Leute und lese ihre Beiträge in einer werbefreien, chronologischen Zeitachse. Drücke dich mit benutzerdefinierten Emojis, Bilderns, GIFs, Videos und Audio in 500-Zeichen Beiträgen aus. Antworte auf Threads und teile Beiträge von anderen um großartige Sachen zu verbreiten. Finde neue Accounts zum Folgen und angesagte Hashtags, um dein Netzwerk zu erweitern.
Tritt einer Gemeinschaft bei und erstelle dein Profil. Finde und folge faszinierenden Leuten und lies ihre Beiträge in einer werbefreien, chronologischen Zeitachse. Drücke dich mit benutzerdefinierten Emojis, Bilderns, GIFs, Videos und Audio in 500-Zeichen-Beiträgen aus. Antworte auf Threads und teile Beiträge von anderen, um großartige Sachen zu verbreiten. Finde neue Accounts zum Folgen und angesagte Hashtags, um dein Netzwerk zu erweitern.
Mastodon wurde mit einem Schwerpunkt auf Privatsphäre und Sicherheit gebaut. Entscheide, ob du deine Beiträge mit deinen Followern, nur mit den Menschen, die du erwähnst, oder mit der ganzen Welt teilen möchtest. Mit Inhaltswarnungen kannst du Beiträge mit sensiblem oder triggerndem Inhalt ausblenden, bis du bereit bist, dich damit auseinanderzusetzen. Jede Gemeinschaft hat ihre eigenen Regeln und Moderator*innen, um die Sicherheit ihrer Mitglieder zu gewährleisten, sowie robuste Sperr- und Meldewerkzeuge um Missbrauch vorzubeugen.
Mastodon wurde mit einem Schwerpunkt auf Privatsphäre und Sicherheit gebaut. Entscheide, ob du deine Beiträge mit deinen Followern, nur mit den Menschen, die du erwähnst, oder mit der ganzen Welt teilen möchtest. Mit Inhaltswarnungen kannst du Beiträge mit sensiblem oder triggerndem Inhalt ausblenden, bis du bereit bist, dich damit auseinanderzusetzen. Jede Gemeinschaft hat ihre eigenen Regeln und Moderator*innen, um die Sicherheit ihrer Mitglieder zu gewährleisten, sowie robuste Sperr- und Meldewerkzeuge, um Missbrauch vorzubeugen.
Weitere Funktionen:
@@ -11,6 +11,6 @@ Weitere Funktionen:
• Entdecken: Trending Hashtags und Accounts sind nur einen Fingertipp entfernt
• Benachrichtigungen: Erhalte Benachrichtigungen über neue Follower, Antworten und geteilte Beiträge
• Teilen: Veröffentliche auf Mastodon aus jeder beliebigen anderen App
• Niedlichkeit: Unser Maskottchen ist ein entzückender Elefant und du wirst ihn von Zeit zu Zeit auftauchen sehen
• Niedlichkeit: Unser Maskottchen ist ein entzückender Elefant, und du wirst ihn von Zeit zu Zeit auftauchen sehen
Mastodon ist eine eingetragene gemeinnützige Organisation und die Entwicklung wird direkt durch deine Spenden unterstützt. Es gibt keine Werbung, keine Monetisierung und kein Venture-Capital, und wir planen es so zu erhalten.
Mastodon ist eine eingetragene gemeinnützige Organisation, und die Entwicklung wird direkt durch deine Spenden unterstützt. Es gibt keine Werbung, keine Monetisierung und kein Venture-Capital, und wir planen es so zu erhalten.

View File

@@ -1,6 +1,6 @@
Mastodon es la red social descentralizada más grande de internet. En lugar de ser una sola web, es una red de millones de usuarios en comunidades independientes que pueden interactuar entre ellas de forma transparente. No importa qué es lo que hagas, podrás encontrar gente apasionada escribiendo sobre ello en Mastodon!
Únete a una comunidad y crea tu perfil. Encuentra y sigue a gente fascinante y lee sus publicaciones sin anuncios y de forma cronológica. Exprésate con emoticonos personalizados, imágenes, GIFs, vídeos y audio en publicaciónes de 500 caracteres. Responde a hilos e impulsa publicaciones de cualquiera para compartir contenido genial. Encuentra nuevas cuentas para seguir y los hashtags de actualidad para expandir tu red.
Únete a una comunidad y crea tu perfil. Encuentra y sigue a gente fascinante y lee sus publicaciones sin anuncios y de forma cronológica. Exprésate con emoticonos personalizados, imágenes, GIFs, vídeos y audio en publicaciónes de 500 caracteres. Responde a hilos e rebloguea publicaciones de cualquiera para compartir contenido genial. Encuentra nuevas cuentas para seguir y los hashtags de actualidad para expandir tu red.
Mastodon está construída con un enfoque en la privacidad y la seguridad. Decide si tus publicaciones se comparten con tus seguidores, solo a la gente que menciones, o a todo el mundo. Las advertencias de contenido te permiten esconder publicaciones con contenido sensible o limitarlas de tu visión hasta que estés listo para interactuar con ellas. Cada comunidad tiene sus propias reglas y moderadores para mantener a salvo a sus miembros, además de herramientas robustas para bloquear y reportar contenido para prevenir el abuso.
@@ -9,7 +9,7 @@ Más características:
• Modo oscuro: Lee las publicaciones en modo claro, oscuro o negro real
• Encuestas: Pide opinión a tus seguidores y cuenta los votos
• Explora: Hashtags y cuentas en tendencia a un solo toque
• Notificaciones: Recibe notificaciones sobre nuevos seguidores, respuestas e impulsos
• Notificaciones: Recibe notificaciones sobre nuevos seguidores, respuestas y reblogueos
• Compartir: Publica directamente a Mastodon desde cualquier hoja de acción en cualquier aplicación
• Preciosidad: Nuestra mascota es un elefante adorable, y verás que aparece de vez en cuando

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Mastodon on internetin suurin hajautettu sosiaalinen verkosto. Yhden verkkopalvelun sijaan, se on miljoonien itsenäisissä yhteisöissä olevien käyttäjien verkosto, jotka voivat olla vuorovaikutuksessa toistensa kanssa saumattomasti. Riippumatta siitä, mistä olet kiinnostunut, voit tavata intohimoisia ihmisiä, jotka julkaisevat aiheesta Mastodonissa!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Liity yhteisöön ja luo itsellesi tili. Löydä ja seuraa kiehtovia ihmisiä ja lue heidän julkaisunsa ilman mainoksia, kronologisella aikajanalla. Ilmaise itseäsi mukautetuilla emojeilla, kuvilla, videoilla ja audiolla 500 merkin pituisissa julkaisuissa. Vastaa viestiketjuihin ja edelleen jaa julkaisuja keneltä tahansa, jakaaksesi hienoja juttuja. Löydä uusia tilejä seurattavaksi ja trendaavia hashtageja laajentaaksesi verkostoasi.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon on rakennettu keskittyen yksityisyyteen ja turvallisuuteen. Päätä, jaetaanko julkaisusi seuraajille, vain mainitsemillesi ihmisille vai koko maailmalle. Sisältövaroitusten avulla, voit piilottaa julkaisut, jotka sisältävät arkaluontoista tai laukaisevaa materiaalia, kunnes olet valmis käsittelemään niitä. Jokaisella yhteisöllä on omat ohjeistonsa ja valvojansa, jotka pitävät jäsenensä turvassa, ja tehokkaat esto- ja ilmiantotyökalut auttavat torjumaan väärinkäytöksiä.
More features:
Lisää ominaisuuksia:
Dark Mode: Read posts in light, dark, or true black mode
Polls: Ask followers for their opinion and tally the votes
Explore: Trending hashtags and accounts are a tap away
Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Tumma tila: Lue julkaisut vaaleassa, tummassa tai mustan tummassa tilassa
Kyselyt: Kysy seuraajilta heidän mielipidettään ja laske äänet
Tutustu: Trendaavat hashtagit ja tilit ovat vain napsautuksen päässä
Ilmoitukset: Saat ilmoituksen uusista seuraajista, vastauksista ja edelleen jaoista
Jakaminen: Julkaise suoraan Mastodoniin minkä tahansa sovelluksen jakovalikon kautta
Suloisuus: Maskottimme on ihastuttava mastodontti ja näet sen ajoittain
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodon on rekisteröity voittoa tavoittelematon organisaatio ja kehitystä tuetaan suoraan lahjoituksillasi. Ei mainontaa, kaupallistamista eikä riskipääomaa, ja aiomme pitää sen sellaisena.

View File

@@ -1 +1 @@
Decentralized social network
Hajautettu sosiaalinen verkosto

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
마스토돈은 인터넷에서 가장 큰 분산 소셜 네트워크입니다. 단 하나의 통일된 웹사이트 대신, 수백만 명의 사용자들이, 서로 경계 없이 소통할 수 있는 독립적인 커뮤니티의 네트워크입니다. 당신이 어디에 속하든간에, 마스토돈에 열정적으로 게시물을 남기는 사람들을 만날 수 있습니다!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
커뮤니티에 가입하고 프로필을 생성하세요. 매력적인 사람들을 찾아 팔로우하고 그들의 글을 광고가 없고, 시간순으로 정렬된 타임라인에서 읽으세요. 커스텀 에모지, 그림, 움짤, 동영상, 소리와 함께 500자의 글로 당신을 표현하세요. 아무에게나 글타래에 답장을 하고 게시물을 리블로그하여 멋진 것들을 공유하세요. 새로 팔로우 할 계정이나 유행 중인 해시태그를 찾아 당신의 인맥을 넓히세요.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
마스토돈은 개인정보 보호와 안전에 중점을 두고 만들어졌습니다. 당신의 게시물을 팔로워들에게만 공개할지, 언급한 사람들에게만 공유할지, 아니면 전세계에 공유할 지 선택하세요. 열람주의는 민감하거나 남들에게 껄끄러울 수 있는 게시물을 마음의 준비가 된 사람들만 열람하도록 숨길 수 있도록 해줍니다. 각각의 커뮤니티는 구성원들을 안전하게 지키기 위한 각자의 규정과 중재자들을 가지고 있으며, 남용을 방지하기 위한 강력한 차단 도구와 신고 도구를 가지고 있습니다.
더 많은 기능:
• 다크모드: 게시물을 밝음, 어두움, 진정한 검정 모드에서 읽으세요
Polls: Ask followers for their opinion and tally the votes
Explore: Trending hashtags and accounts are a tap away
Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
투표: 팔로워들에게 의견을 물어보고 투표를 집계하세요
탐색: 유행 중인 해시태그와 계정을 탭 한 번에 볼 수 있습니다
알림: 새로운 팔로우, 답글, 리블로그에 대한 알림을 받으세요
공유: 어떤 앱에서든 공유 기능으로 바로 마스토돈에 게시하세요
귀여움: 우리의 마스코트는 귀여운 코끼리입니다, 때때로 이들을 볼 수 있습니다
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
마스토돈은 등록된 상호이며 여러분들의 직접적인 기부를 통해 개발되고 있습니다. 광고도, 유료화도, 벤처 캐피탈도 없습니다, 그리고 계속 그렇게 할 생각입니다.

View File

@@ -1,16 +1,16 @@
Mastodon to największa zdecentralizowana sieć społecznościowa w Internecie. Zamiast jednej strony internetowej, jest to sieć milionów użytkowników w niezależnych społecznościach, które mogą ze sobą wchodzić w interakcje. Niezależnie od swoich zainteresowań, momżesz poznać interesujących ludzi piszących o nich na Mastodonie!
Dołącz do społeczności i utwórz swój profil. Poznaj i obserwuj fascynujących ludzi i czytaj ich wpisy w chronologicznym osi czasu. Wyrażaj siebie za pomocą niestandardowych emoji, obrazów, GIFów, filmów i audio w 500-znakowych wpisach. Odpowiadaj na wątki i podawaj dalej posty od każdego, aby dzielić się wspaniałymi rzeczami. Find new accounts to follow and trending hashtags to expand your network.
Dołącz do społeczności i utwórz swój profil. Poznaj i obserwuj fascynujących ludzi i czytaj ich wpisy w chronologicznym osi czasu. Wyrażaj siebie za pomocą niestandardowych emoji, obrazów, GIFów, filmów i audio w 500-znakowych wpisach. Odpowiadaj na wątki i podawaj dalej posty od każdego, aby dzielić się wspaniałymi rzeczami. Odnajduj nowe konta do śledzenia i zyskujące popularność hashtagi, by poszerzać swoją sieć.
Mastodon został zaprojektowany z myślą o prywatności i bezpieczeństwie. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Ostrzeżenia dotyczące zawartości pozwalają ukrywać posty zawierające wrażliwe lub wyzywające materiały, dopóki nie będziesz gotów się z nimi pogodzić. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon został zaprojektowany z myślą o prywatności i bezpieczeństwie. Decyduj, czy Twoje wpisy są udostępniane osobom śledzącym Cię, tylko wzmiankowanym, czy całemu światu. Ostrzeżenia dotyczące zawartości pozwalają ukrywać posty zawierające wrażliwe lub wyzywające materiały, dopóki nie będziesz gotów się z nimi pogodzić. Każda społeczność ma własne wytyczne i moderatorów, aby zapewniać swoim członkom bezpieczeństwo, a także solidne narzędzia blokowania i raportowania pomagające zapobiegać nadużyciom.
Więcej funkcji:
• Tryb ciemny: Czytaj wpisy w jasnym, ciemnym lub czarnym trybie
• Ankiety: Poproś obserwujących o ich opinię i poznaj ich głosy
Explore: Trending hashtags and accounts are a tap away
Odkrywaj: Najpopularniejsze hashtagi i konta są dostępne za jednym dotknięciem
• Powiadomienia: Otrzymuj powiadomienia o nowych obserwacjach, odpowiedziach i udostępnieniach
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Udostępnianie: Publikuj bezpośrednio na Mastodonie z menu udostępniania w dowolnej aplikacji
Słodycz: Nasza maskotka to uroczy słoń i zobaczysz go pojawiającego się od czasu do czasu
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodon to zarejestrowana organizacja non-profit i rozwój jest wspierany bezpośrednio przez darowizny. Nie ma reklam, monetyzacji, ani kapitału inwestycyjnego i planujemy, by tak pozostało.

View File

@@ -1,8 +1,8 @@
O Mastodon é a maior rede social descentralizada da Internet. Em vez de ser um único site, é uma rede de milhões de utilizadores em comunidades independentes que podem facilmente interagir uns com os outros. No matter what youre into, you can meet passionate people posting about it on Mastodon!
O Mastodon é a maior rede social descentralizada da Internet. Em vez de ser um único site, é uma rede de milhões de utilizadores em comunidades independentes que podem facilmente interagir uns com os outros. Independemente dos teus gostos, consegues encontrar pessoas que os partilhem no Mastodon!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Junta-te a uma comunidade e cria o teu perfil. Encontra, segue gente fascinante e lê as suas publicações numa cronologia sem anúncios. Expressa-te com emojis personalizados imagens, GIFs, vídeos e áudio em publicações com até 500 caracteres. Responde a tópicos e promove publicações de qualquer pessoa para partilhares ótimas coisas. Encontra novas contas e tendências a seguir para expandires a tua rede.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
O Mastodon é construído com foco na privacidade e segurança. Decide se as tuas publicações são partilhadas com os teus seguidores, apenas com as pessoas mencionadas, ou com o mundo inteiro. Avisos de conteúdo permitem-te esconder publicações que contenham material sensível ou provocatório até estares pronto(a) para o veres. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
More features:

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Mastodon är det största decentraliserade sociala nätverket på internet. I stället för en enda webbplats är det ett nätverk av miljontals användare på oberoende servrar som alla kan interagera med varandra, sömlöst. Oavsett vad du är intresserad av kan du träffa passionerade personer som diskuterar ämnet på Mastodon!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
Gå med på en server och skapa din profil. Hitta och följ fascinerande människor och läsa deras inlägg i en annonsfri, kronologisk tidslinje. Uttryck dig med anpassade emoji, bilder, GIF:ar, videor och ljud i 500-teckensinlägg. Svara på trådar och ompostningar från vem som helst för att dela bra saker. Hitta nya konton att följa och trendande hashtaggar för att utöka ditt nätverk.
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon är byggt med fokus på integritet och trygghet. Bestäm om dina inlägg delas med dina följare, bara personer du omnämner, eller hela världen. Innehållsvarningar låter dig dölja inlägg som innehåller känsligt eller triggande material tills du är redo att interagera med dem. Varje server har sina egna riktlinjer och moderatorer för att hålla sina medlemmar trygga, och robusta blockerings- och rapporteringsverktyg för att förhindra missbruk.
More features:
Fler funktioner:
Dark Mode: Read posts in light, dark, or true black mode
Polls: Ask followers for their opinion and tally the votes
Explore: Trending hashtags and accounts are a tap away
• Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
Mörkt läge: Läs inlägg i ljust, mörkt eller helsvart läge
Omröstningar: Fråga följare om deras åsikt och sammanställ deras röster
Utforska: Trendande hashtaggar och konton är ett tryck bort
• Notiser: Bli meddelad om nya följare, svar och ompostningar
Delning: Posta direkt till Mastodon från delningsbladet i alla appar
Gullighet: Vår maskot är en bedårande elefant, och du kommer att se dem dyka upp då och då
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodon är en registrerad ideell förening och utvecklingen stöds direkt av dina donationer. Det finns ingen reklam, ingen monetarisering, och inget riskkapital, och vi planerar att behålla det på det sättet.

View File

@@ -1 +1 @@
Decentralized social network
Decentraliserat socialt nätverk

View File

@@ -1,16 +1,16 @@
Mastodon is the largest decentralized social network on the internet. Instead of a single website, its a network of millions of users in independent communities that can all interact with one another, seamlessly. No matter what youre into, you can meet passionate people posting about it on Mastodon!
Mastodon เป็นเครือข่ายสังคมแบบกระจายศูนย์ที่ใหญ่ที่สุดบนอินเทอร์เน็ต ซึ่งไม่ได้เป็นเว็บไซต์เดียว แต่เป็นเครือข่ายของผู้ใช้หลายล้านคนในชุมชนอิสระที่ทุกคนสามารถโต้ตอบซึ่งกันและกันได้แบบไร้รอยต่อ ไม่ว่าคุณจะชอบอะไร คุณก็พบคนที่ชื่นชอบเหมือนกันโพสต์เกี่ยวกับสิ่งที่คุณชอบได้บน Mastodon!
Join a community and create your profile. Find and and follow fascinating folks and read their posts in an ad-free, chronological timeline. Express yourself with custom emoji, images, GIFs, videos, and audio in 500-character posts. Reply to threads and reblog posts from anyone to share great stuff. Find new accounts to follow and trending hashtags to expand your network.
เข้าร่วมชุมชนและสร้างโปรไฟล์ ค้นหาและติดตามผู้คนที่น่าสนใจและอ่านโพสต์ของเขาในไทม์ไลน์ที่ไร้โฆษณาและเรียงตามลำดับเวลาล้วน ๆ แสดงความรู้สึกของตัวคุณเองด้วยอีโมจิที่กำหนดเอง รูปภาพ GIF วิดีโอ และเสียงในโพสต์ 500 ตัวอักษร ตอบกลับและดันโพสต์จากคนอื่น ๆ เพื่อแชร์สิ่งดี ๆ และค้นหาบัญชีใหม่ ๆ ที่จะติดตามและแฮชแท็กที่เป็นที่นิยมเพื่อขยายเครือข่ายของคุณ
Mastodon is built with a focus on privacy and safety. Decide whether your posts are shared with your followers, just the people you mention, or the whole world. Content warnings let you hide posts containing sensitive or triggering material until you're ready to engage with them. Each community has its own guidelines and moderators to keep its members safe, and robust blocking and reporting tools help prevent abuse.
Mastodon สร้างขึ้นโดยเน้นความเป็นส่วนตัวและความปลอดภัยเป็นสำคัญ คุณสามารถตัดสินใจได้ว่าโพสต์ของคุณจะถูกแชร์กับผู้ติดตามของคุณ คนที่คุณกล่าวถึง หรือคนทั้งโลกก็ได้ ฟังก์ชั่นคำเตือนเนื้อหาช่วยให้คุณซ่อนโพสต์ที่มีเนื้อหาที่ละเอียดอ่อนจนกว่าคุณจะพร้อมเห็นเนื้อหานั้น แต่ละชุมชนจะมีหลักเกณฑ์และผู้ควบคุมเป็นของตนเองเพื่อรักษาความปลอดภัยของสมาชิก รวมถึงเครื่องมือการปิดกั้นและรายงานที่มีประสิทธิภาพเพื่อช่วยป้องกันการกระทำผิด
More features:
คุณสมบัติอื่น ๆ:
Dark Mode: Read posts in light, dark, or true black mode
Polls: Ask followers for their opinion and tally the votes
Explore: Trending hashtags and accounts are a tap away
Notifications: Get notified about new follows, replies, and reblogs
Sharing: Post directly to Mastodon from any share sheet in any app
Cuteness: Our mascot is an adorable elephant, and you'll see them pop up from time to time
โหมดมืด: อ่านโพสต์ในโหมดสว่าง มืด หรือโหมดมืดดำสนิท
การสำรวจความคิดเห็น: สำรวจความคิดเห็นของผู้ติดตามและนับจำนวนการลงคะแนน
สำรวจ: แตะปุ่มเดียวเพื่อดูแฮชแท็กและบัญชีที่เป็นที่นิยม
การแจ้งเตือน: รับการแจ้งเตือนเกี่ยวกับผู้ติดตามใหม่ การตอบกลับ และการดันโพสต์
การแชร์: โพสต์ลง Mastodon ได้โดยตรงจากแอปอื่น ๆ ที่อยู่ในเครื่อง
ความน่ารัก: มาสคอตของเราเป็นช้างน่ารัก และคุณจะเห็นมันโผล่ออกมาเป็นระยะ ๆ
Mastodon is a registered nonprofit and development is supported directly by your donations. Theres no advertising, no monetization, and no venture capital, and we plan to keep it that way.
Mastodon เป็นองค์กรไม่แสวงหาผลกำไรที่จดทะเบียนแล้ว และการพัฒนาได้รับการสนับสนุนจากเงินบริจาคของคุณโดยตรง ดังนั้นจึงไม่มีโฆษณา ไม่มีการทำกำไร และไม่มีการร่วมลงทุน และเรามีแผนจะทำให้เป็นอย่างนี้ต่อไป

View File

@@ -4,13 +4,13 @@ plugins {
}
android {
compileSdk 31
compileSdk 33
defaultConfig {
applicationId "org.joinmastodon.android.sk"
minSdk 23
targetSdk 31
versionCode 17
versionName '1.1.1+fork.17'
targetSdk 33
versionCode 23
versionName "1.1.3+fork.23"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -33,6 +33,9 @@ android {
initWith release
versionNameSuffix "-beta"
}
githubRelease{
initWith release
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@@ -46,6 +49,13 @@ android {
appcenterPublicBeta{
setRoot "src/appcenter"
}
githubRelease{
setRoot "src/github"
}
}
lintOptions{
checkReleaseBuilds false
abortOnError false
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.joinmastodon.android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application>
<!-- <receiver android:name=".updater.GithubSelfUpdaterImpl$InstallerStatusReceiver" android:exported="false"/>-->
<!-- <receiver android:name=".updater.GithubSelfUpdaterImpl$AfterUpdateRestartReceiver" android:exported="true" android:enabled="false">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>-->
<!-- </intent-filter>-->
<!-- </receiver>-->
<provider
android:authorities="${applicationId}.self_update_provider"
android:name=".updater.SelfUpdateContentProvider"
android:grantUriPermissions="true"
android:exported="false"/>
</application>
</manifest>

View File

@@ -0,0 +1,336 @@
package org.joinmastodon.android.updater;
import android.app.Activity;
import android.app.DownloadManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageInstaller;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import android.widget.Toast;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import java.io.File;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import androidx.annotation.Keep;
import okhttp3.Call;
import okhttp3.Request;
import okhttp3.Response;
@Keep
public class GithubSelfUpdaterImpl extends GithubSelfUpdater{
private static final long CHECK_PERIOD=24*3600*1000L;
private static final String TAG="GithubSelfUpdater";
private UpdateState state=UpdateState.NO_UPDATE;
private UpdateInfo info;
private long downloadID;
private BroadcastReceiver downloadCompletionReceiver=new BroadcastReceiver(){
@Override
public void onReceive(Context context, Intent intent){
if(downloadID!=0 && intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)==downloadID){
MastodonApp.context.unregisterReceiver(this);
setState(UpdateState.DOWNLOADED);
}
}
};
public GithubSelfUpdaterImpl(){
SharedPreferences prefs=getPrefs();
int checkedByBuild=prefs.getInt("checkedByBuild", 0);
if(prefs.contains("version") && checkedByBuild==BuildConfig.VERSION_CODE){
info=new UpdateInfo();
info.version=prefs.getString("version", null);
info.size=prefs.getLong("apkSize", 0);
downloadID=prefs.getLong("downloadID", 0);
if(downloadID==0 || !getUpdateApkFile().exists()){
state=UpdateState.UPDATE_AVAILABLE;
}else{
DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
state=dm.getUriForDownloadedFile(downloadID)==null ? UpdateState.DOWNLOADING : UpdateState.DOWNLOADED;
if(state==UpdateState.DOWNLOADING){
MastodonApp.context.registerReceiver(downloadCompletionReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
}
}
}else if(checkedByBuild!=BuildConfig.VERSION_CODE && checkedByBuild>0){
// We are in a new version, running for the first time after update. Gotta clean things up.
long id=getPrefs().getLong("downloadID", 0);
if(id!=0){
MastodonApp.context.getSystemService(DownloadManager.class).remove(id);
}
getUpdateApkFile().delete();
getPrefs().edit()
.remove("apkSize")
.remove("version")
.remove("apkURL")
.remove("checkedByBuild")
.remove("downloadID")
.apply();
}
}
private SharedPreferences getPrefs(){
return MastodonApp.context.getSharedPreferences("githubUpdater", Context.MODE_PRIVATE);
}
@Override
public void maybeCheckForUpdates(){
if(state!=UpdateState.NO_UPDATE && state!=UpdateState.UPDATE_AVAILABLE)
return;
long timeSinceLastCheck=System.currentTimeMillis()-getPrefs().getLong("lastCheck", 0);
if(timeSinceLastCheck>CHECK_PERIOD){
setState(UpdateState.CHECKING);
MastodonAPIController.runInBackground(this::actuallyCheckForUpdates);
}
}
private void actuallyCheckForUpdates(){
Request req=new Request.Builder()
.url("https://api.github.com/repos/sk22/mastodon-android-fork/releases/latest")
.build();
Call call=MastodonAPIController.getHttpClient().newCall(req);
try(Response resp=call.execute()){
JsonObject obj=JsonParser.parseReader(resp.body().charStream()).getAsJsonObject();
String tag=obj.get("tag_name").getAsString();
Matcher matcher=Pattern.compile("v(\\d+)\\.(\\d+)\\.(\\d+)").matcher(tag);
if(!matcher.find()){
Log.w(TAG, "actuallyCheckForUpdates: release tag has wrong format: "+tag);
return;
}
int newMajor=Integer.parseInt(matcher.group(1)), newMinor=Integer.parseInt(matcher.group(2)), newRevision=Integer.parseInt(matcher.group(3));
String[] currentParts=BuildConfig.VERSION_NAME.split("\\.");
int curMajor=Integer.parseInt(currentParts[0]), curMinor=Integer.parseInt(currentParts[1]), curRevision=Integer.parseInt(currentParts[2]);
long newVersion=((long)newMajor << 32) | ((long)newMinor << 16) | newRevision;
long curVersion=((long)curMajor << 32) | ((long)curMinor << 16) | curRevision;
if(newVersion>curVersion || BuildConfig.DEBUG){
String version=newMajor+"."+newMinor+"."+newRevision;
Log.d(TAG, "actuallyCheckForUpdates: new version: "+version);
for(JsonElement el:obj.getAsJsonArray("assets")){
JsonObject asset=el.getAsJsonObject();
if("application/vnd.android.package-archive".equals(asset.get("content_type").getAsString()) && "uploaded".equals(asset.get("state").getAsString())){
long size=asset.get("size").getAsLong();
String url=asset.get("browser_download_url").getAsString();
UpdateInfo info=new UpdateInfo();
info.size=size;
info.version=version;
this.info=info;
getPrefs().edit()
.putLong("apkSize", size)
.putString("version", version)
.putString("apkURL", url)
.putInt("checkedByBuild", BuildConfig.VERSION_CODE)
.remove("downloadID")
.apply();
break;
}
}
}
getPrefs().edit().putLong("lastCheck", System.currentTimeMillis()).apply();
}catch(Exception x){
Log.w(TAG, "actuallyCheckForUpdates", x);
}finally{
setState(info==null ? UpdateState.NO_UPDATE : UpdateState.UPDATE_AVAILABLE);
}
}
private void setState(UpdateState state){
this.state=state;
E.post(new SelfUpdateStateChangedEvent(state));
}
@Override
public UpdateState getState(){
return state;
}
@Override
public UpdateInfo getUpdateInfo(){
return info;
}
public File getUpdateApkFile(){
return new File(MastodonApp.context.getExternalCacheDir(), "update.apk");
}
@Override
public void downloadUpdate(){
if(state==UpdateState.DOWNLOADING)
throw new IllegalStateException();
DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
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()))
);
getPrefs().edit().putLong("downloadID", downloadID).apply();
setState(UpdateState.DOWNLOADING);
}
@Override
public void installUpdate(Activity activity){
if(state!=UpdateState.DOWNLOADED)
throw new IllegalStateException();
Uri uri;
Intent intent=new Intent(Intent.ACTION_INSTALL_PACKAGE);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
uri=new Uri.Builder().scheme("content").authority(activity.getPackageName()+".self_update_provider").path("update.apk").build();
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
}else{
uri=Uri.fromFile(getUpdateApkFile());
}
intent.setDataAndType(uri, "application/vnd.android.package-archive");
activity.startActivity(intent);
// TODO figure out how to restart the app when updating via this new API
/*
PackageInstaller installer=activity.getPackageManager().getPackageInstaller();
try{
final int sid=installer.createSession(new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL));
installer.registerSessionCallback(new PackageInstaller.SessionCallback(){
@Override
public void onCreated(int i){
}
@Override
public void onBadgingChanged(int i){
}
@Override
public void onActiveChanged(int i, boolean b){
}
@Override
public void onProgressChanged(int id, float progress){
}
@Override
public void onFinished(int id, boolean success){
activity.getPackageManager().setComponentEnabledSetting(new ComponentName(activity, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
}
});
activity.getPackageManager().setComponentEnabledSetting(new ComponentName(activity, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
PackageInstaller.Session session=installer.openSession(sid);
try(OutputStream out=session.openWrite("mastodon.apk", 0, info.size); InputStream in=new FileInputStream(getUpdateApkFile())){
byte[] buffer=new byte[16384];
int read;
while((read=in.read(buffer))>0){
out.write(buffer, 0, read);
}
}
// PendingIntent intent=PendingIntent.getBroadcast(activity, 1, new Intent(activity, InstallerStatusReceiver.class), PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE);
PendingIntent intent=PendingIntent.getActivity(activity, 1, new Intent(activity, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
session.commit(intent.getIntentSender());
}catch(IOException x){
Log.w(TAG, "installUpdate", x);
Toast.makeText(activity, x.getMessage(), Toast.LENGTH_SHORT).show();
}
*/
}
@Override
public float getDownloadProgress(){
if(state!=UpdateState.DOWNLOADING)
throw new IllegalStateException();
DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
try(Cursor cursor=dm.query(new DownloadManager.Query().setFilterById(downloadID))){
if(cursor.moveToFirst()){
long loaded=cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
long total=cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
// Log.d(TAG, "getDownloadProgress: "+loaded+" of "+total);
return total>0 ? (float)loaded/total : 0f;
}
}
return 0;
}
@Override
public void cancelDownload(){
if(state!=UpdateState.DOWNLOADING)
throw new IllegalStateException();
DownloadManager dm=MastodonApp.context.getSystemService(DownloadManager.class);
dm.remove(downloadID);
downloadID=0;
getPrefs().edit().remove("downloadID").apply();
setState(UpdateState.UPDATE_AVAILABLE);
}
@Override
public void handleIntentFromInstaller(Intent intent, Activity activity){
int status=intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0);
if(status==PackageInstaller.STATUS_PENDING_USER_ACTION){
Intent confirmIntent=intent.getParcelableExtra(Intent.EXTRA_INTENT);
activity.startActivity(confirmIntent);
}else if(status!=PackageInstaller.STATUS_SUCCESS){
String msg=intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
Toast.makeText(activity, activity.getString(R.string.error)+":\n"+msg, Toast.LENGTH_LONG).show();
}
}
/*public static class InstallerStatusReceiver extends BroadcastReceiver{
@Override
public void onReceive(Context context, Intent intent){
int status=intent.getIntExtra(PackageInstaller.EXTRA_STATUS, 0);
if(status==PackageInstaller.STATUS_PENDING_USER_ACTION){
Intent confirmIntent=intent.getParcelableExtra(Intent.EXTRA_INTENT);
context.startActivity(confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
}else if(status!=PackageInstaller.STATUS_SUCCESS){
String msg=intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
Toast.makeText(context, context.getString(R.string.error)+":\n"+msg, Toast.LENGTH_LONG).show();
}
}
}
public static class AfterUpdateRestartReceiver extends BroadcastReceiver{
@Override
public void onReceive(Context context, Intent intent){
if(Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())){
context.getPackageManager().setComponentEnabledSetting(new ComponentName(context, AfterUpdateRestartReceiver.class), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
Toast.makeText(context, R.string.update_installed, Toast.LENGTH_SHORT).show();
Intent restartIntent=new Intent(context, MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setPackage(context.getPackageName());
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.P){
context.startActivity(restartIntent);
}else{
// Bypass activity starting restrictions by starting it from a notification
NotificationManager nm=context.getSystemService(NotificationManager.class);
NotificationChannel chan=new NotificationChannel("selfUpdateRestart", context.getString(R.string.update_installed), NotificationManager.IMPORTANCE_HIGH);
nm.createNotificationChannel(chan);
Notification n=new Notification.Builder(context, "selfUpdateRestart")
.setContentTitle(context.getString(R.string.update_installed))
.setContentIntent(PendingIntent.getActivity(context, 1, restartIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE))
.setFullScreenIntent(PendingIntent.getActivity(context, 1, restartIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE), true)
.setSmallIcon(R.drawable.ic_ntf_logo)
.build();
nm.notify(1, n);
}
}
}
}*/
}

View File

@@ -0,0 +1,62 @@
package org.joinmastodon.android.updater;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import java.io.FileNotFoundException;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class SelfUpdateContentProvider extends ContentProvider{
@Override
public boolean onCreate(){
return true;
}
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder){
return null;
}
@Nullable
@Override
public String getType(@NonNull Uri uri){
if(isCorrectUri(uri))
return "application/vnd.android.package-archive";
return null;
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values){
return null;
}
@Override
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs){
return 0;
}
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs){
return 0;
}
@Nullable
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException{
if(isCorrectUri(uri)){
return ParcelFileDescriptor.open(((GithubSelfUpdaterImpl)GithubSelfUpdater.getInstance()).getUpdateApkFile(), ParcelFileDescriptor.MODE_READ_ONLY);
}
throw new FileNotFoundException();
}
private boolean isCorrectUri(Uri uri){
return "/update.apk".equals(uri.getPath());
}
}

View File

@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature"/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -1,8 +1,12 @@
package org.joinmastodon.android;
import android.Manifest;
import android.app.Application;
import android.app.Fragment;
import android.content.Intent;
import android.content.pm.PackageInstaller;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
@@ -17,6 +21,7 @@ import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import org.parceler.Parcels;
import java.lang.reflect.InvocationTargetException;
@@ -59,6 +64,8 @@ public class MainActivity extends FragmentStackActivity{
showFragmentForNotification(notification, session.getID());
}else if(intent.getBooleanExtra("compose", false)){
showCompose();
}else{
maybeRequestNotificationsPermission();
}
}
}
@@ -68,6 +75,8 @@ public class MainActivity extends FragmentStackActivity{
try{
Class.forName("org.joinmastodon.android.AppCenterWrapper").getMethod("init", Application.class).invoke(null, getApplication());
}catch(ClassNotFoundException|NoSuchMethodException|IllegalAccessException|InvocationTargetException ignore){}
}else if(GithubSelfUpdater.needSelfUpdating()){
GithubSelfUpdater.getInstance().maybeCheckForUpdates();
}
}
@@ -96,7 +105,9 @@ public class MainActivity extends FragmentStackActivity{
}
}else if(intent.getBooleanExtra("compose", false)){
showCompose();
}
}/*else if(intent.hasExtra(PackageInstaller.EXTRA_STATUS) && GithubSelfUpdater.needSelfUpdating()){
GithubSelfUpdater.getInstance().handleIntentFromInstaller(intent, this);
}*/
}
private void showFragmentForNotification(Notification notification, String accountID){
@@ -131,4 +142,10 @@ public class MainActivity extends FragmentStackActivity{
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);
}
}
}

View File

@@ -0,0 +1,39 @@
package org.joinmastodon.android.api;
import android.graphics.Rect;
import android.net.Uri;
import java.io.IOException;
public class AvatarResizedImageRequestBody extends ResizedImageRequestBody{
public AvatarResizedImageRequestBody(Uri uri, ProgressListener progressListener) throws IOException{
super(uri, 0, progressListener);
}
@Override
protected int[] getTargetSize(int srcWidth, int srcHeight){
float factor=400f/Math.min(srcWidth, srcHeight);
return new int[]{Math.round(srcWidth*factor), Math.round(srcHeight*factor)};
}
@Override
protected boolean needResize(int srcWidth, int srcHeight){
return srcHeight>400 || srcWidth!=srcHeight;
}
@Override
protected boolean needCrop(int srcWidth, int srcHeight){
return srcWidth!=srcHeight;
}
@Override
protected Rect getCropBounds(int srcWidth, int srcHeight){
Rect rect=new Rect();
if(srcWidth>srcHeight){
rect.set(srcWidth/2-srcHeight/2, 0, srcWidth/2-srcHeight/2+srcHeight, srcHeight);
}else{
rect.set(0, srcHeight/2-srcWidth/2, srcWidth, srcHeight/2-srcWidth/2+srcWidth);
}
return rect;
}
}

View File

@@ -102,7 +102,7 @@ public class CacheController{
.exec(accountID);
}catch(SQLiteException x){
Log.w(TAG, x);
uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500)));
uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x)));
}finally{
closeDelayed();
}
@@ -184,7 +184,7 @@ public class CacheController{
.exec(accountID);
}catch(SQLiteException x){
Log.w(TAG, x);
uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500)));
uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x)));
}finally{
closeDelayed();
}

View File

@@ -96,11 +96,11 @@ public class MastodonAPIController{
if(call.isCanceled())
return;
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed: "+e);
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+hreq+" failed", e);
synchronized(req){
req.okhttpCall=null;
}
req.onError(e.getLocalizedMessage(), 0);
req.onError(e.getLocalizedMessage(), 0, e);
}
@Override
@@ -133,7 +133,7 @@ public class MastodonAPIController{
}catch(JsonIOException|JsonSyntaxException x){
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x);
req.onError(x.getLocalizedMessage(), response.code());
req.onError(x.getLocalizedMessage(), response.code(), x);
return;
}
@@ -142,7 +142,7 @@ public class MastodonAPIController{
}catch(IOException x){
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x);
req.onError(x.getLocalizedMessage(), response.code());
req.onError(x.getLocalizedMessage(), response.code(), x);
return;
}
@@ -155,7 +155,7 @@ public class MastodonAPIController{
JsonObject error=JsonParser.parseReader(reader).getAsJsonObject();
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" received error: "+error);
if(error.has("details")){
MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code());
MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code(), null);
HashMap<String, List<MastodonDetailedErrorResponse.FieldError>> details=new HashMap<>();
JsonObject errorDetails=error.getAsJsonObject("details");
for(String key:errorDetails.keySet()){
@@ -172,12 +172,12 @@ public class MastodonAPIController{
err.detailedErrors=details;
req.onError(err);
}else{
req.onError(error.get("error").getAsString(), response.code());
req.onError(error.get("error").getAsString(), response.code(), null);
}
}catch(JsonIOException|JsonSyntaxException x){
req.onError(response.code()+" "+response.message(), response.code());
req.onError(response.code()+" "+response.message(), response.code(), x);
}catch(Exception x){
req.onError("Error parsing an API error", response.code());
req.onError("Error parsing an API error", response.code(), x);
}
}
}catch(Exception x){
@@ -189,7 +189,7 @@ public class MastodonAPIController{
}catch(Exception x){
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x);
req.onError(x.getLocalizedMessage(), 0);
req.onError(x.getLocalizedMessage(), 0, x);
}
}, 0);
}
@@ -197,4 +197,8 @@ public class MastodonAPIController{
public static void runInBackground(Runnable action){
thread.postRunnable(action, 0);
}
public static OkHttpClient getHttpClient(){
return httpClient;
}
}

View File

@@ -82,7 +82,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
account.getApiController().submitRequest(this);
}catch(Exception x){
Log.e(TAG, "exec: this shouldn't happen, but it still did", x);
invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1));
invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1, x));
}
return this;
}
@@ -194,8 +194,8 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
invokeErrorCallback(err);
}
void onError(String msg, int httpStatus){
invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus));
void onError(String msg, int httpStatus, Throwable exception){
invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus, exception));
}
void onSuccess(T resp){

View File

@@ -7,8 +7,8 @@ import java.util.Map;
public class MastodonDetailedErrorResponse extends MastodonErrorResponse{
public Map<String, List<FieldError>> detailedErrors;
public MastodonDetailedErrorResponse(String error, int httpStatus){
super(error, httpStatus);
public MastodonDetailedErrorResponse(String error, int httpStatus, Throwable exception){
super(error, httpStatus, exception);
}
public static class FieldError{

View File

@@ -12,10 +12,12 @@ import me.grishka.appkit.api.ErrorResponse;
public class MastodonErrorResponse extends ErrorResponse{
public final String error;
public final int httpStatus;
public final Throwable underlyingException;
public MastodonErrorResponse(String error, int httpStatus){
public MastodonErrorResponse(String error, int httpStatus, Throwable exception){
this.error=error;
this.httpStatus=httpStatus;
this.underlyingException=exception;
}
@Override

View File

@@ -14,6 +14,7 @@ import android.os.Build;
import android.provider.OpenableColumns;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.FileOutputStream;
@@ -30,62 +31,105 @@ public class ResizedImageRequestBody extends CountingRequestBody{
private File tempFile;
private Uri uri;
private String contentType;
private int maxSize;
public ResizedImageRequestBody(Uri uri, int maxSize, ProgressListener progressListener) throws IOException{
super(progressListener);
this.uri=uri;
contentType=MastodonApp.context.getContentResolver().getType(uri);
this.maxSize=maxSize;
BitmapFactory.Options opts=new BitmapFactory.Options();
opts.inJustDecodeBounds=true;
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
BitmapFactory.decodeStream(in, null, opts);
if("file".equals(uri.getScheme())){
BitmapFactory.decodeFile(uri.getPath(), opts);
contentType=UiUtils.getFileMediaType(new File(uri.getPath())).type();
}else{
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
BitmapFactory.decodeStream(in, null, opts);
}
contentType=MastodonApp.context.getContentResolver().getType(uri);
}
if(opts.outWidth*opts.outHeight>maxSize){
if(needResize(opts.outWidth, opts.outHeight) || needCrop(opts.outWidth, opts.outHeight)){
Bitmap bitmap;
if(Build.VERSION.SDK_INT>=29){
bitmap=ImageDecoder.decodeBitmap(ImageDecoder.createSource(MastodonApp.context.getContentResolver(), uri), (decoder, info, source)->{
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)info.getSize().getWidth()/info.getSize().getHeight())));
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)info.getSize().getHeight()/info.getSize().getWidth())));
if(Build.VERSION.SDK_INT>=28){
ImageDecoder.Source source;
if("file".equals(uri.getScheme())){
source=ImageDecoder.createSource(new File(uri.getPath()));
}else{
source=ImageDecoder.createSource(MastodonApp.context.getContentResolver(), uri);
}
bitmap=ImageDecoder.decodeBitmap(source, (decoder, info, _source)->{
int[] size=getTargetSize(info.getSize().getWidth(), info.getSize().getHeight());
decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE);
decoder.setTargetSize(targetWidth, targetHeight);
decoder.setTargetSize(size[0], size[1]);
// Breaks images in mysterious ways
// if(needCrop(size[0], size[1]))
// decoder.setCrop(getCropBounds(size[0], size[1]));
});
if(needCrop(bitmap.getWidth(), bitmap.getHeight())){
Rect crop=getCropBounds(bitmap.getWidth(), bitmap.getHeight());
bitmap=Bitmap.createBitmap(bitmap, crop.left, crop.top, crop.width(), crop.height());
}
}else{
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)opts.outWidth/opts.outHeight)));
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)opts.outHeight/opts.outWidth)));
int[] size=getTargetSize(opts.outWidth, opts.outHeight);
int targetWidth=size[0];
int targetHeight=size[1];
float factor=opts.outWidth/(float)targetWidth;
opts=new BitmapFactory.Options();
opts.inSampleSize=(int)factor;
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
bitmap=BitmapFactory.decodeStream(in, null, opts);
if("file".equals(uri.getScheme())){
bitmap=BitmapFactory.decodeFile(uri.getPath(), opts);
}else{
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
bitmap=BitmapFactory.decodeStream(in, null, opts);
}
}
if(factor%1f!=0f){
Bitmap scaled=Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888);
new Canvas(scaled).drawBitmap(bitmap, null, new Rect(0, 0, targetWidth, targetHeight), new Paint(Paint.FILTER_BITMAP_FLAG));
boolean needCrop=needCrop(targetWidth, targetHeight);
if(factor%1f!=0f || needCrop){
Rect srcBounds=null;
Rect dstBounds;
if(needCrop){
Rect crop=getCropBounds(targetWidth, targetHeight);
dstBounds=new Rect(0, 0, crop.width(), crop.height());
srcBounds=new Rect(
Math.round(crop.left/(float)targetWidth*bitmap.getWidth()),
Math.round(crop.top/(float)targetHeight*bitmap.getHeight()),
Math.round(crop.right/(float)targetWidth*bitmap.getWidth()),
Math.round(crop.bottom/(float)targetHeight*bitmap.getHeight())
);
}else{
dstBounds=new Rect(0, 0, targetWidth, targetHeight);
}
Bitmap scaled=Bitmap.createBitmap(dstBounds.width(), dstBounds.height(), Bitmap.Config.ARGB_8888);
new Canvas(scaled).drawBitmap(bitmap, srcBounds, dstBounds, new Paint(Paint.FILTER_BITMAP_FLAG));
bitmap=scaled;
}
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
int rotation;
int orientation=0;
if("file".equals(uri.getScheme())){
ExifInterface exif=new ExifInterface(uri.getPath());
orientation=exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
}else if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
ExifInterface exif=new ExifInterface(in);
int orientation=exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
rotation=switch(orientation){
case ExifInterface.ORIENTATION_ROTATE_90 -> 90;
case ExifInterface.ORIENTATION_ROTATE_180 -> 180;
case ExifInterface.ORIENTATION_ROTATE_270 -> 270;
default -> 0;
};
}
if(rotation!=0){
Matrix matrix=new Matrix();
matrix.setRotate(rotation);
bitmap=Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
orientation=exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
}
}
int rotation=switch(orientation){
case ExifInterface.ORIENTATION_ROTATE_90 -> 90;
case ExifInterface.ORIENTATION_ROTATE_180 -> 180;
case ExifInterface.ORIENTATION_ROTATE_270 -> 270;
default -> 0;
};
if(rotation!=0){
Matrix matrix=new Matrix();
matrix.setRotate(rotation);
bitmap=Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
}
}
tempFile=new File(MastodonApp.context.getCacheDir(), "tmp_upload_image");
boolean isPNG="image/png".equals(contentType);
tempFile=File.createTempFile("mastodon_tmp_resized", null);
try(FileOutputStream out=new FileOutputStream(tempFile)){
if("image/png".equals(contentType)){
if(isPNG){
bitmap.compress(Bitmap.CompressFormat.PNG, 0, out);
}else{
bitmap.compress(Bitmap.CompressFormat.JPEG, 97, out);
@@ -94,9 +138,13 @@ public class ResizedImageRequestBody extends CountingRequestBody{
}
length=tempFile.length();
}else{
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
cursor.moveToFirst();
length=cursor.getInt(0);
if("file".equals(uri.getScheme())){
length=new File(uri.getPath()).length();
}else{
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
cursor.moveToFirst();
length=cursor.getInt(0);
}
}
}
}
@@ -125,4 +173,22 @@ public class ResizedImageRequestBody extends CountingRequestBody{
}
}
}
protected int[] getTargetSize(int srcWidth, int srcHeight){
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)srcWidth/srcHeight)));
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)srcHeight/srcWidth)));
return new int[]{targetWidth, targetHeight};
}
protected boolean needResize(int srcWidth, int srcHeight){
return srcWidth*srcHeight>maxSize;
}
protected boolean needCrop(int srcWidth, int srcHeight){
return false;
}
protected Rect getCropBounds(int srcWidth, int srcHeight){
return null;
}
}

View File

@@ -27,6 +27,7 @@ public class GetAccountStatuses extends MastodonAPIRequest<List<Status>>{
addQueryParameter("exclude_replies", "true");
addQueryParameter("exclude_reblogs", "true");
}
case OWN_POSTS_AND_REPLIES -> addQueryParameter("exclude_reblogs", "true");
}
}
@@ -35,6 +36,7 @@ public class GetAccountStatuses extends MastodonAPIRequest<List<Status>>{
INCLUDE_REPLIES,
PINNED,
MEDIA,
NO_REBLOGS
NO_REBLOGS,
OWN_POSTS_AND_REPLIES
}
}

View File

@@ -0,0 +1,10 @@
package org.joinmastodon.android.api.requests.accounts;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Preferences;
public class GetPreferences extends MastodonAPIRequest<Preferences> {
public GetPreferences(){
super(HttpMethod.GET, "/preferences", Preferences.class);
}
}

View File

@@ -2,13 +2,16 @@ package org.joinmastodon.android.api.requests.accounts;
import android.net.Uri;
import org.joinmastodon.android.api.AvatarResizedImageRequestBody;
import org.joinmastodon.android.api.ContentUriRequestBody;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.ResizedImageRequestBody;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.AccountField;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.IOException;
import java.util.List;
import okhttp3.MultipartBody;
@@ -39,21 +42,21 @@ public class UpdateAccountCredentials extends MastodonAPIRequest<Account>{
}
@Override
public RequestBody getRequestBody(){
public RequestBody getRequestBody() throws IOException{
MultipartBody.Builder bldr=new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("display_name", displayName)
.addFormDataPart("note", bio);
if(avatar!=null){
bldr.addFormDataPart("avatar", UiUtils.getFileName(avatar), new ContentUriRequestBody(avatar, null));
bldr.addFormDataPart("avatar", UiUtils.getFileName(avatar), new AvatarResizedImageRequestBody(avatar, null));
}else if(avatarFile!=null){
bldr.addFormDataPart("avatar", avatarFile.getName(), RequestBody.create(UiUtils.getFileMediaType(avatarFile), avatarFile));
bldr.addFormDataPart("avatar", avatarFile.getName(), new AvatarResizedImageRequestBody(Uri.fromFile(avatarFile), null));
}
if(cover!=null){
bldr.addFormDataPart("header", UiUtils.getFileName(cover), new ContentUriRequestBody(cover, null));
bldr.addFormDataPart("header", UiUtils.getFileName(cover), new ResizedImageRequestBody(cover, 1500*500, null));
}else if(coverFile!=null){
bldr.addFormDataPart("header", coverFile.getName(), RequestBody.create(UiUtils.getFileMediaType(coverFile), coverFile));
bldr.addFormDataPart("header", coverFile.getName(), new ResizedImageRequestBody(Uri.fromFile(coverFile), 1500*500, null));
}
if(fields.isEmpty()){
bldr.addFormDataPart("fields_attributes[0][name]", "").addFormDataPart("fields_attributes[0][value]", "");

View File

@@ -11,7 +11,7 @@ public class CreateOAuthApp extends MastodonAPIRequest<Application>{
}
private static class Request{
public String clientName="Mastadon for Android";
public String clientName="Mastodon for Android Fork";
public String redirectUris=AccountSessionManager.REDIRECT_URI;
public String scopes=AccountSessionManager.SCOPE;
public String website="https://github.com/sk22/mastodon-android-fork";

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
public class EditStatus extends MastodonAPIRequest<Status>{
public EditStatus(CreateStatus.Request req, String id){
super(HttpMethod.PUT, "/statuses/"+id, Status.class);
setRequestBody(req);
}
}

View File

@@ -0,0 +1,21 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Attachment;
import java.io.IOException;
import okhttp3.Response;
public class GetAttachmentByID extends MastodonAPIRequest<Attachment>{
public GetAttachmentByID(String id){
super(HttpMethod.GET, "/media/"+id, Attachment.class);
}
@Override
public void validateAndPostprocessResponse(Attachment respObj, Response httpResponse) throws IOException{
if(httpResponse.code()==206)
respObj.url="";
super.validateAndPostprocessResponse(respObj, httpResponse);
}
}

View File

@@ -0,0 +1,33 @@
package org.joinmastodon.android.api.requests.statuses;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import okhttp3.Response;
public class GetStatusEditHistory extends MastodonAPIRequest<List<Status>>{
public GetStatusEditHistory(String id){
super(HttpMethod.GET, "/statuses/"+id+"/history", new TypeToken<>(){});
}
@Override
public void validateAndPostprocessResponse(List<Status> respObj, Response httpResponse) throws IOException{
int i=0;
for(Status s:respObj){
s.uri="";
s.id="fakeID"+i;
s.visibility=StatusPrivacy.PUBLIC;
s.mentions=Collections.emptyList();
s.tags=Collections.emptyList();
i++;
}
super.validateAndPostprocessResponse(respObj, httpResponse);
}
}

View File

@@ -0,0 +1,18 @@
package org.joinmastodon.android.api.requests.statuses;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.BaseModel;
public class GetStatusSourceText extends MastodonAPIRequest<GetStatusSourceText.Response>{
public GetStatusSourceText(String id){
super(HttpMethod.GET, "/statuses/"+id+"/source", Response.class);
}
@AllFieldsAreRequired
public static class Response extends BaseModel{
public String id;
public String text;
public String spoilerText;
}
}

View File

@@ -17,6 +17,7 @@ import java.io.IOException;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import okhttp3.Response;
public class UploadAttachment extends MastodonAPIRequest<Attachment>{
private Uri uri;
@@ -40,6 +41,18 @@ public class UploadAttachment extends MastodonAPIRequest<Attachment>{
return this;
}
@Override
protected String getPathPrefix(){
return "/api/v2";
}
@Override
public void validateAndPostprocessResponse(Attachment respObj, Response httpResponse) throws IOException{
if(respObj.url==null)
respObj.url="";
super.validateAndPostprocessResponse(respObj, httpResponse);
}
@Override
public RequestBody getRequestBody() throws IOException{
MultipartBody.Builder builder=new MultipartBody.Builder()

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.updater.GithubSelfUpdater;
public class SelfUpdateStateChangedEvent{
public final GithubSelfUpdater.UpdateState state;
public SelfUpdateStateChangedEvent(GithubSelfUpdater.UpdateState state){
this.state=state;
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.Status;
public class StatusUpdatedEvent{
public Status status;
public StatusUpdatedEvent(Status status){
this.status=status;
}
}

View File

@@ -457,7 +457,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
status.spoilerRevealed=true;
TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class);
if(text!=null)
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()+getMainAdapterOffset());
adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()-getMainAdapterOffset());
HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
if(header!=null)
header.rebind();
@@ -579,6 +579,10 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return true;
}
public ArrayList<StatusDisplayItem> getDisplayItems(){
return displayItems;
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0 && wantsOverlaySystemNavigation()){

View File

@@ -4,13 +4,18 @@ import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ClipData;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Outline;
import android.graphics.PixelFormat;
import android.graphics.RenderEffect;
import android.graphics.Shader;
import android.graphics.drawable.LayerDrawable;
import android.icu.text.BreakIterator;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@@ -52,19 +57,27 @@ import com.twitter.twittertext.TwitterTextEmojiRegex;
import org.joinmastodon.android.E;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.ProgressListener;
import org.joinmastodon.android.api.requests.accounts.GetPreferences;
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.EditStatus;
import org.joinmastodon.android.api.requests.statuses.GetAttachmentByID;
import org.joinmastodon.android.api.requests.statuses.UploadAttachment;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Mention;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.ComposeAutocompleteViewController;
@@ -76,6 +89,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.ui.utils.TransferSpeedTracker;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ComposeEditText;
import org.joinmastodon.android.ui.views.ComposeMediaLayout;
@@ -84,6 +98,9 @@ import org.joinmastodon.android.ui.views.SizeListenerLinearLayout;
import org.parceler.Parcel;
import org.parceler.Parcels;
import java.io.InterruptedIOException;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -105,6 +122,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private static final int MEDIA_RESULT=717;
private static final int IMAGE_DESCRIPTION_RESULT=363;
private static final int MAX_ATTACHMENTS=4;
private static final String TAG="ComposeFragment";
private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE);
@@ -152,8 +170,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private ArrayList<DraftPollOption> pollOptions=new ArrayList<>();
private ArrayList<DraftMediaAttachment> queuedAttachments=new ArrayList<>(), failedAttachments=new ArrayList<>(), attachments=new ArrayList<>(), allAttachments=new ArrayList<>();
private DraftMediaAttachment uploadingAttachment;
private ArrayList<DraftMediaAttachment> attachments=new ArrayList<>();
private List<EmojiCategory> customEmojis;
private CustomEmojiPopupKeyboard emojiKeyboard;
@@ -175,6 +192,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private Instance instance;
private boolean attachmentsErrorShowing;
private Status editingStatus;
private boolean pollChanged;
private boolean creatingView;
private boolean ignoreSelectionChanges=false;
private Runnable updateUploadEtaRunnable;
public static DraftMediaAttachment redraftAttachment(Attachment att) {
DraftMediaAttachment draft=new DraftMediaAttachment();
draft.serverAttachment=att;
@@ -194,6 +217,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
instanceDomain=session.domain;
customEmojis=AccountSessionManager.getInstance().getCustomEmojis(instanceDomain);
instance=AccountSessionManager.getInstance().getInstanceInfo(instanceDomain);
if(getArguments().containsKey("editStatus")){
editingStatus=Parcels.unwrap(getArguments().getParcelable("editStatus"));
}
if(instance==null){
Nav.finish(this);
return;
@@ -209,25 +235,20 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
else
charLimit=500;
if(getArguments().containsKey("replyTo")){
replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo"));
statusVisibility=replyTo.visibility;
}
if(getArguments().containsKey("visibility")){
statusVisibility=(StatusPrivacy) getArguments().getSerializable("visibility");
}
if(savedInstanceState!=null){
statusVisibility=(StatusPrivacy) savedInstanceState.getSerializable("visibility");
}
loadDefaultStatusVisibility(savedInstanceState);
}
@Override
public void onDestroy(){
super.onDestroy();
if(uploadingAttachment!=null && uploadingAttachment.uploadRequest!=null)
uploadingAttachment.uploadRequest.cancel();
for(DraftMediaAttachment att:attachments){
if(att.isUploadingOrProcessing())
att.cancelUpload();
}
if(updateUploadEtaRunnable!=null){
UiUtils.removeCallbacks(updateUploadEtaRunnable);
updateUploadEtaRunnable=null;
}
}
@Override
@@ -239,6 +260,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
creatingView=true;
emojiKeyboard=new CustomEmojiPopupKeyboard(getActivity(), customEmojis, instanceDomain);
emojiKeyboard.setListener(this::onCustomEmojiClick);
@@ -317,6 +339,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
}
updatePollOptionHints();
pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr));
}else if(savedInstanceState==null && editingStatus!=null && editingStatus.poll!=null){
pollBtn.setSelected(true);
mediaBtn.setEnabled(false);
pollWrap.setVisibility(View.VISIBLE);
for(Poll.Option eopt:editingStatus.poll.options){
DraftPollOption opt=createDraftPollOption();
opt.edit.setText(eopt.title);
}
updatePollOptionHints();
pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr));
}else{
pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr=getResources().getQuantityString(R.plurals.x_days, 1, 1)));
}
@@ -329,6 +361,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if((savedInstanceState!=null && savedInstanceState.getBoolean("hasSpoiler", false)) || hasSpoiler){
spoilerEdit.setVisibility(View.VISIBLE);
spoilerBtn.setSelected(true);
}else if(editingStatus!=null && !TextUtils.isEmpty(editingStatus.spoilerText)){
spoilerEdit.setVisibility(View.VISIBLE);
spoilerEdit.setText(getArguments().getString("sourceSpoiler", editingStatus.spoilerText));
spoilerBtn.setSelected(true);
}
ArrayList<Parcelable> serializedAttachments=(savedInstanceState!=null ? savedInstanceState : getArguments())
@@ -340,9 +376,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
attachments.add(att);
}
attachmentsView.setVisibility(View.VISIBLE);
}else if(!allAttachments.isEmpty()){
}else if(!attachments.isEmpty()){
attachmentsView.setVisibility(View.VISIBLE);
for(DraftMediaAttachment att:allAttachments){
for(DraftMediaAttachment att:attachments){
attachmentsView.addView(createMediaAttachmentView(att));
}
}
@@ -354,6 +390,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
autocompleteView.setVisibility(View.GONE);
mainEditTextWrap.addView(autocompleteView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(178), Gravity.TOP));
creatingView=false;
return view;
}
@@ -455,9 +493,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void afterTextChanged(Editable s){
updateCharCounter(s);
updateCharCounter();
}
});
spoilerEdit.addTextChangedListener(new SimpleTextWatcher(e->updateCharCounter()));
if(replyTo!=null){
replyText.setText(getString(R.string.in_reply_to, replyTo.account.displayName));
ArrayList<String> mentions=new ArrayList<>();
@@ -474,46 +513,65 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
initialText=mentions.isEmpty() ? "" : TextUtils.join(" ", mentions)+" ";
if(savedInstanceState==null){
mainEditText.setText(initialText);
ignoreSelectionChanges=true;
mainEditText.setSelection(mainEditText.length());
// TODO: setting for preserving cw always / only when replying to own posts
// && AccountSessionManager.getInstance().isSelf(accountID, replyTo.account)
ignoreSelectionChanges=false;
if(!TextUtils.isEmpty(replyTo.spoilerText)){
insertSpoiler(replyTo.spoilerText);
hasSpoiler=true;
spoilerEdit.setVisibility(View.VISIBLE);
spoilerEdit.setText(replyTo.spoilerText);
spoilerBtn.setSelected(true);
}
}
}else{
replyText.setVisibility(View.GONE);
}
if(savedInstanceState==null){
String prefilledText=getArguments().getString("prefilledText");
if(!TextUtils.isEmpty(prefilledText)){
mainEditText.setText(prefilledText);
if(editingStatus!=null){
initialText=getArguments().getString("sourceText", "");
mainEditText.setText(initialText);
ignoreSelectionChanges=true;
mainEditText.setSelection(mainEditText.length());
initialText=prefilledText;
}
String spoilerText=getArguments().getString("spoilerText");
if(!TextUtils.isEmpty(spoilerText)) insertSpoiler(spoilerText);
ArrayList<Uri> mediaUris=getArguments().getParcelableArrayList("mediaAttachments");
if(mediaUris!=null && !mediaUris.isEmpty()){
for(Uri uri:mediaUris){
addMediaAttachment(uri, null);
ignoreSelectionChanges=false;
if(!editingStatus.mediaAttachments.isEmpty()){
attachmentsView.setVisibility(View.VISIBLE);
for(Attachment att:editingStatus.mediaAttachments){
DraftMediaAttachment da=new DraftMediaAttachment();
da.serverAttachment=att;
da.description=att.description;
da.uri=Uri.parse(att.previewUrl);
attachmentsView.addView(createMediaAttachmentView(da));
attachments.add(da);
}
pollBtn.setEnabled(false);
}
}else{
String prefilledText=getArguments().getString("prefilledText");
if(!TextUtils.isEmpty(prefilledText)){
mainEditText.setText(prefilledText);
ignoreSelectionChanges=true;
mainEditText.setSelection(mainEditText.length());
ignoreSelectionChanges=false;
initialText=prefilledText;
}
ArrayList<Uri> mediaUris=getArguments().getParcelableArrayList("mediaAttachments");
if(mediaUris!=null && !mediaUris.isEmpty()){
for(Uri uri:mediaUris){
addMediaAttachment(uri, null);
}
}
}
}
}
private void insertSpoiler(String text) {
hasSpoiler=true;
if (text!=null) spoilerEdit.setText(text);
spoilerEdit.setVisibility(View.VISIBLE);
spoilerBtn.setSelected(true);
if(editingStatus!=null){
updateCharCounter();
}
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
publishButton=new Button(getActivity());
publishButton.setText(R.string.publish);
publishButton.setText(editingStatus==null ? R.string.publish : R.string.save);
publishButton.setOnClickListener(this::onPublishClick);
LinearLayout wrap=new LinearLayout(getActivity());
wrap.setOrientation(LinearLayout.HORIZONTAL);
@@ -536,7 +594,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
wrap.addView(publishButton, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
wrap.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(8));
wrap.setClipToPadding(false);
MenuItem item=menu.add(R.string.publish);
MenuItem item=menu.add(editingStatus==null ? R.string.publish : R.string.save);
item.setActionView(wrap);
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
updatePublishButtonState();
@@ -554,7 +612,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
}
@SuppressLint("NewApi")
private void updateCharCounter(CharSequence text){
private void updateCharCounter(){
CharSequence text=mainEditText.getText();
String countableText=TwitterTextEmojiRegex.VALID_EMOJI_PATTERN.matcher(
MENTION_PATTERN.matcher(
URL_PATTERN.matcher(text).replaceAll("$2xxxxxxxxxxxxxxxxxxxxxxx")
@@ -566,6 +626,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
charCount++;
}
if(hasSpoiler){
charCount+=spoilerEdit.length();
}
charCounter.setText(String.valueOf(charLimit-charCount));
trimmedCharCount=text.toString().trim().length();
updatePublishButtonState();
@@ -578,11 +641,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if(opt.edit.length()>0)
nonEmptyPollOptionsCount++;
}
if(publishButton!=null){
publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit
&& uploadingAttachment==null && failedAttachments.isEmpty() && queuedAttachments.isEmpty()
&& (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1));
if(publishButton==null)
return;
int nonDoneAttachmentCount=0;
for(DraftMediaAttachment att:attachments){
if(att.state!=AttachmentUploadState.DONE)
nonDoneAttachmentCount++;
}
publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1));
}
private void onCustomEmojiClick(Emoji emoji){
@@ -638,41 +704,59 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
sendProgress.setVisibility(View.VISIBLE);
sendError.setVisibility(View.GONE);
new CreateStatus(req, uuid)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
wm.removeView(sendingOverlay);
sendingOverlay=null;
E.post(new StatusCreatedEvent(result));
if(replyTo!=null){
replyTo.repliesCount++;
E.post(new StatusCountersUpdatedEvent(replyTo));
}
Nav.finish(ComposeFragment.this);
Callback<Status> resCallback=new Callback<>(){
@Override
public void onSuccess(Status result){
wm.removeView(sendingOverlay);
sendingOverlay=null;
if(editingStatus==null){
E.post(new StatusCreatedEvent(result));
if(replyTo!=null){
replyTo.repliesCount++;
E.post(new StatusCountersUpdatedEvent(replyTo));
}
}else{
E.post(new StatusUpdatedEvent(result));
}
Nav.finish(ComposeFragment.this);
}
@Override
public void onError(ErrorResponse error){
wm.removeView(sendingOverlay);
sendingOverlay=null;
sendProgress.setVisibility(View.GONE);
sendError.setVisibility(View.VISIBLE);
publishButton.setEnabled(true);
error.showToast(getActivity());
}
})
.exec(accountID);
@Override
public void onError(ErrorResponse error){
wm.removeView(sendingOverlay);
sendingOverlay=null;
sendProgress.setVisibility(View.GONE);
sendError.setVisibility(View.VISIBLE);
publishButton.setEnabled(true);
error.showToast(getActivity());
}
};
if(editingStatus!=null){
new EditStatus(req, editingStatus.id)
.setCallback(resCallback)
.exec(accountID);
}else{
new CreateStatus(req, uuid)
.setCallback(resCallback)
.exec(accountID);
}
}
private boolean hasDraft(){
if(getArguments().getBoolean("hasDraft", false)) return true;
if(editingStatus!=null){
if(!mainEditText.getText().toString().equals(initialText))
return true;
List<String> existingMediaIDs=editingStatus.mediaAttachments.stream().map(a->a.id).collect(Collectors.toList());
if(!existingMediaIDs.equals(attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList())))
return true;
return pollChanged;
}
boolean pollFieldsHaveContent=false;
for(DraftPollOption opt:pollOptions)
pollFieldsHaveContent|=opt.edit.length()>0;
return getArguments().getBoolean("hasDraft", false)
|| (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText))
|| !attachments.isEmpty() || uploadingAttachment!=null || !queuedAttachments.isEmpty()
|| !failedAttachments.isEmpty() || pollFieldsHaveContent;
return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !attachments.isEmpty() || pollFieldsHaveContent;
}
@Override
@@ -716,7 +800,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private void confirmDiscardDraftAndFinish(){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.discard_draft)
.setTitle(editingStatus==null ? R.string.discard_draft : R.string.discard_changes)
.setPositiveButton(R.string.discard, (dialog, which)->Nav.finish(this))
.setNegativeButton(R.string.cancel, null)
.show();
@@ -773,7 +857,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
}
if(size>sizeLimit){
float mb=sizeLimit/(float) (1024*1024);
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%f" : "%.2f", mb);
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%.0f" : "%.2f", mb);
showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb));
return false;
}
@@ -782,18 +866,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
pollBtn.setEnabled(false);
DraftMediaAttachment draft=new DraftMediaAttachment();
draft.uri=uri;
draft.mimeType=type;
draft.description=description;
attachmentsView.addView(createMediaAttachmentView(draft));
allAttachments.add(draft);
attachments.add(draft);
attachmentsView.setVisibility(View.VISIBLE);
draft.overlay.setVisibility(View.VISIBLE);
draft.infoBar.setVisibility(View.GONE);
draft.setOverlayVisible(true, false);
if(uploadingAttachment==null){
uploadMediaAttachment(draft);
}else{
queuedAttachments.add(draft);
if(!areThereAnyUploadingAttachments()){
uploadNextQueuedAttachment();
}
updatePublishButtonState();
if(getMediaAttachmentsCount()==MAX_ATTACHMENTS)
@@ -812,25 +894,35 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private View createMediaAttachmentView(DraftMediaAttachment draft){
View thumb=getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false);
ImageView img=thumb.findViewById(R.id.thumb);
ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250)));
if(draft.serverAttachment!=null){
ViewImageLoader.load(img, draft.serverAttachment.blurhashPlaceholder, new UrlImageLoaderRequest(draft.serverAttachment.previewUrl, V.dp(250), V.dp(250)));
}else{
if(draft.mimeType.startsWith("image/")){
ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250)));
}else if(draft.mimeType.startsWith("video/")){
loadVideoThumbIntoView(img, draft.uri);
}
}
TextView fileName=thumb.findViewById(R.id.file_name);
fileName.setText(UiUtils.getFileName(draft.uri));
fileName.setText(UiUtils.getFileName(draft.serverAttachment!=null ? Uri.parse(draft.serverAttachment.url) : draft.uri));
draft.view=thumb;
draft.imageView=img;
draft.progressBar=thumb.findViewById(R.id.progress);
draft.infoBar=thumb.findViewById(R.id.info_bar);
draft.overlay=thumb.findViewById(R.id.overlay);
draft.descriptionView=thumb.findViewById(R.id.description);
draft.uploadStateTitle=thumb.findViewById(R.id.state_title);
draft.uploadStateText=thumb.findViewById(R.id.state_text);
ImageButton btn=thumb.findViewById(R.id.remove_btn);
btn.setTag(draft);
btn.setOnClickListener(this::onRemoveMediaAttachmentClick);
btn=thumb.findViewById(R.id.remove_btn2);
btn.setTag(draft);
btn.setOnClickListener(this::onRemoveMediaAttachmentClick);
Button retry=thumb.findViewById(R.id.retry_upload);
ImageButton retry=thumb.findViewById(R.id.retry_or_cancel_upload);
retry.setTag(draft);
retry.setOnClickListener(this::onRetryMediaUploadClick);
retry.setVisibility(View.GONE);
retry.setOnClickListener(this::onRetryOrCancelMediaUploadClick);
draft.retryButton=retry;
draft.infoBar.setTag(draft);
draft.infoBar.setOnClickListener(this::onEditMediaDescriptionClick);
@@ -838,12 +930,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if(!TextUtils.isEmpty(draft.description))
draft.descriptionView.setText(draft.description);
if(uploadingAttachment!=draft && !queuedAttachments.contains(draft)){
draft.progressBar.setVisibility(View.GONE);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){
draft.overlay.setBackgroundColor(0xA6000000);
}
if(failedAttachments.contains(draft)){
draft.infoBar.setVisibility(View.GONE);
draft.overlay.setVisibility(View.VISIBLE);
if(draft.state==AttachmentUploadState.UPLOADING || draft.state==AttachmentUploadState.PROCESSING || draft.state==AttachmentUploadState.QUEUED){
draft.progressBar.setVisibility(View.GONE);
}else if(draft.state==AttachmentUploadState.ERROR){
draft.setOverlayVisible(true, false);
}
return thumb;
@@ -855,67 +949,92 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
draft.uri=uri;
draft.description=description;
attachmentsView.addView(createMediaAttachmentView(draft));
allAttachments.add(draft);
attachments.add(draft);
attachmentsView.setVisibility(View.VISIBLE);
}
private void uploadMediaAttachment(DraftMediaAttachment attachment){
if(uploadingAttachment!=null)
throw new IllegalStateException("there is already an attachment being uploaded");
uploadingAttachment=attachment;
if(areThereAnyUploadingAttachments()){
throw new IllegalStateException("there is already an attachment being uploaded");
}
attachment.state=AttachmentUploadState.UPLOADING;
attachment.progressBar.setVisibility(View.VISIBLE);
ObjectAnimator rotationAnimator=ObjectAnimator.ofFloat(attachment.progressBar, View.ROTATION, 0f, 360f);
rotationAnimator.setInterpolator(new LinearInterpolator());
rotationAnimator.setDuration(1500);
rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE);
rotationAnimator.start();
attachment.progressBarAnimator=rotationAnimator;
int maxSize=0;
String contentType=getActivity().getContentResolver().getType(attachment.uri);
if(contentType!=null && contentType.startsWith("image/")){
maxSize=2_073_600; // TODO get this from instance configuration when it gets added there
}
attachment.uploadStateTitle.setText("");
attachment.uploadStateText.setText("");
attachment.progressBar.setProgress(0);
attachment.speedTracker.reset();
attachment.speedTracker.addSample(0);
attachment.uploadRequest=(UploadAttachment) new UploadAttachment(attachment.uri, maxSize, attachment.description)
.setProgressListener(new ProgressListener(){
@Override
public void onProgress(long transferred, long total){
if(updateUploadEtaRunnable==null){
UiUtils.runOnUiThread(updateUploadEtaRunnable=ComposeFragment.this::updateUploadETAs, 100);
}
int progress=Math.round(transferred/(float)total*attachment.progressBar.getMax());
if(Build.VERSION.SDK_INT>=24)
attachment.progressBar.setProgress(progress, true);
else
attachment.progressBar.setProgress(progress);
attachment.speedTracker.setTotalBytes(total);
attachment.uploadStateTitle.setText(getString(R.string.file_upload_progress, UiUtils.formatFileSize(getActivity(), transferred, true), UiUtils.formatFileSize(getActivity(), total, true)));
attachment.speedTracker.addSample(transferred);
}
})
.setCallback(new Callback<>(){
@Override
public void onSuccess(Attachment result){
attachment.serverAttachment=result;
attachment.uploadRequest=null;
uploadingAttachment=null;
attachments.add(attachment);
attachment.progressBar.setVisibility(View.GONE);
if(!queuedAttachments.isEmpty())
uploadMediaAttachment(queuedAttachments.remove(0));
updatePublishButtonState();
rotationAnimator.cancel();
V.setVisibilityAnimated(attachment.overlay, View.GONE);
V.setVisibilityAnimated(attachment.infoBar, View.VISIBLE);
if(TextUtils.isEmpty(result.url)){
attachment.state=AttachmentUploadState.PROCESSING;
attachment.processingPollingRunnable=()->pollForMediaAttachmentProcessing(attachment);
if(getActivity()==null)
return;
attachment.uploadStateTitle.setText(R.string.upload_processing);
attachment.uploadStateText.setText("");
UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
if(!areThereAnyUploadingAttachments())
uploadNextQueuedAttachment();
}else{
finishMediaAttachmentUpload(attachment);
}
}
@Override
public void onError(ErrorResponse error){
attachment.uploadRequest=null;
uploadingAttachment=null;
failedAttachments.add(attachment);
// error.showToast(getActivity());
Toast.makeText(getActivity(), R.string.image_upload_failed, Toast.LENGTH_SHORT).show();
attachment.progressBarAnimator=null;
attachment.state=AttachmentUploadState.ERROR;
attachment.uploadStateTitle.setText(R.string.upload_failed);
if(error instanceof MastodonErrorResponse er){
if(er.underlyingException instanceof SocketException || er.underlyingException instanceof UnknownHostException || er.underlyingException instanceof InterruptedIOException)
attachment.uploadStateText.setText(R.string.upload_error_connection_lost);
else
attachment.uploadStateText.setText(er.error);
}else{
attachment.uploadStateText.setText("");
}
attachment.retryButton.setImageResource(R.drawable.ic_fluent_arrow_clockwise_24_filled);
attachment.retryButton.setContentDescription(getString(R.string.retry_upload));
rotationAnimator.cancel();
V.setVisibilityAnimated(attachment.retryButton, View.VISIBLE);
V.setVisibilityAnimated(attachment.progressBar, View.GONE);
if(!queuedAttachments.isEmpty())
uploadMediaAttachment(queuedAttachments.remove(0));
if(!areThereAnyUploadingAttachments())
uploadNextQueuedAttachment();
}
})
.exec(accountID);
@@ -923,37 +1042,109 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private void onRemoveMediaAttachmentClick(View v){
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
if(att==uploadingAttachment){
att.uploadRequest.cancel();
uploadingAttachment=null;
if(!queuedAttachments.isEmpty())
uploadMediaAttachment(queuedAttachments.remove(0));
}else{
attachments.remove(att);
queuedAttachments.remove(att);
failedAttachments.remove(att);
}
allAttachments.remove(att);
if(att.isUploadingOrProcessing())
att.cancelUpload();
attachments.remove(att);
uploadNextQueuedAttachment();
attachmentsView.removeView(att.view);
if(getMediaAttachmentsCount()==0)
attachmentsView.setVisibility(View.GONE);
updatePublishButtonState();
pollBtn.setEnabled(attachments.isEmpty() && queuedAttachments.isEmpty() && failedAttachments.isEmpty() && uploadingAttachment==null);
pollBtn.setEnabled(attachments.isEmpty());
mediaBtn.setEnabled(true);
}
private void onRetryMediaUploadClick(View v){
private void onRetryOrCancelMediaUploadClick(View v){
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
if(failedAttachments.remove(att)){
V.setVisibilityAnimated(att.retryButton, View.GONE);
if(att.state==AttachmentUploadState.ERROR){
att.retryButton.setImageResource(R.drawable.ic_fluent_dismiss_24_filled);
att.retryButton.setContentDescription(getString(R.string.cancel));
V.setVisibilityAnimated(att.progressBar, View.VISIBLE);
if(uploadingAttachment==null)
uploadMediaAttachment(att);
else
queuedAttachments.add(att);
att.state=AttachmentUploadState.QUEUED;
if(!areThereAnyUploadingAttachments()){
uploadNextQueuedAttachment();
}
}else{
onRemoveMediaAttachmentClick(v);
}
}
private void pollForMediaAttachmentProcessing(DraftMediaAttachment attachment){
attachment.processingPollingRequest=(GetAttachmentByID) new GetAttachmentByID(attachment.serverAttachment.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Attachment result){
attachment.processingPollingRequest=null;
if(!TextUtils.isEmpty(result.url)){
attachment.processingPollingRunnable=null;
attachment.serverAttachment=result;
finishMediaAttachmentUpload(attachment);
}else if(getActivity()!=null){
UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
}
}
@Override
public void onError(ErrorResponse error){
attachment.processingPollingRequest=null;
if(getActivity()!=null)
UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
}
})
.exec(accountID);
}
private void finishMediaAttachmentUpload(DraftMediaAttachment attachment){
if(attachment.state!=AttachmentUploadState.PROCESSING && attachment.state!=AttachmentUploadState.UPLOADING)
throw new IllegalStateException("Unexpected state "+attachment.state);
attachment.uploadRequest=null;
attachment.state=AttachmentUploadState.DONE;
attachment.progressBar.setVisibility(View.GONE);
if(!areThereAnyUploadingAttachments())
uploadNextQueuedAttachment();
updatePublishButtonState();
if(attachment.progressBarAnimator!=null){
attachment.progressBarAnimator.cancel();
attachment.progressBarAnimator=null;
}
attachment.setOverlayVisible(false, true);
}
private void uploadNextQueuedAttachment(){
for(DraftMediaAttachment att:attachments){
if(att.state==AttachmentUploadState.QUEUED){
uploadMediaAttachment(att);
return;
}
}
}
private boolean areThereAnyUploadingAttachments(){
for(DraftMediaAttachment att:attachments){
if(att.state==AttachmentUploadState.UPLOADING)
return true;
}
return false;
}
private void updateUploadETAs(){
if(!areThereAnyUploadingAttachments()){
UiUtils.removeCallbacks(updateUploadEtaRunnable);
updateUploadEtaRunnable=null;
return;
}
for(DraftMediaAttachment att:attachments){
if(att.state==AttachmentUploadState.UPLOADING){
long eta=att.speedTracker.updateAndGetETA();
// Log.i(TAG, "onProgress: transfer speed "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getLastSpeed()), false)+" average "+UiUtils.formatFileSize(getActivity(), Math.round(att.speedTracker.getAverageSpeed()), false)+" eta "+eta);
String time=String.format("%d:%02d", eta/60, eta%60);
att.uploadStateText.setText(getString(R.string.file_upload_time_remaining, time));
}
}
UiUtils.runOnUiThread(updateUploadEtaRunnable, 100);
}
private void onEditMediaDescriptionClick(View v){
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
if(att.serverAttachment==null)
@@ -996,7 +1187,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
pollOptionsView.startDragging(option.view);
return true;
});
option.edit.addTextChangedListener(new SimpleTextWatcher(e->updatePublishButtonState()));
option.edit.addTextChangedListener(new SimpleTextWatcher(e->{
if(!creatingView)
pollChanged=true;
updatePublishButtonState();
}));
option.edit.setFilters(new InputFilter[]{new InputFilter.LengthFilter(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxCharactersPerOption>0 ? instance.configuration.polls.maxCharactersPerOption : 50)});
pollOptionsView.addView(option.view);
@@ -1016,6 +1211,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private void onSwapPollOptions(int oldIndex, int newIndex){
pollOptions.add(newIndex, pollOptions.remove(oldIndex));
updatePollOptionHints();
pollChanged=true;
}
private void showPollDurationMenu(){
@@ -1039,6 +1235,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
default -> throw new IllegalStateException("Unexpected value: "+item.getItemId());
};
pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr=item.getTitle().toString()));
pollChanged=true;
return true;
});
menu.show();
@@ -1055,11 +1252,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
spoilerEdit.setText("");
spoilerBtn.setSelected(false);
mainEditText.requestFocus();
updateCharCounter();
}
}
private int getMediaAttachmentsCount(){
return allAttachments.size();
return attachments.size();
}
private void onVisibilityClick(View v){
@@ -1095,6 +1293,47 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
menu.show();
}
private void loadDefaultStatusVisibility(Bundle savedInstanceState) {
if(getArguments().containsKey("replyTo")){
replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo"));
statusVisibility = replyTo.visibility;
}
// A saved privacy setting from a previous compose session wins over the reply visibility
if(savedInstanceState !=null){
statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility");
}
new GetPreferences()
.setCallback(new Callback<>(){
@Override
public void onSuccess(Preferences result){
// Only override the reply visibility if our preference is more private
if (result.postingDefaultVisibility.isLessVisibleThan(statusVisibility)) {
statusVisibility = switch (result.postingDefaultVisibility) {
case PUBLIC -> StatusPrivacy.PUBLIC;
case UNLISTED -> StatusPrivacy.UNLISTED;
case PRIVATE -> StatusPrivacy.PRIVATE;
case DIRECT -> StatusPrivacy.DIRECT;
};
}
// A saved privacy setting from a previous compose session wins over all
if(savedInstanceState !=null){
statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility");
}
updateVisibilityIcon ();
}
@Override
public void onError(ErrorResponse error){
Log.w(TAG, "Unable to get user preferences to set default post privacy");
}
})
.exec(accountID);
}
private void updateVisibilityIcon(){
if(statusVisibility==null){ // TODO find out why this happens
statusVisibility=StatusPrivacy.PUBLIC;
@@ -1109,6 +1348,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void onSelectionChanged(int start, int end){
if(ignoreSelectionChanges)
return;
if(start==end && mainEditText.length()>0){
ComposeAutocompleteSpan[] spans=mainEditText.getText().getSpans(start, end, ComposeAutocompleteSpan.class);
if(spans.length>0){
@@ -1179,6 +1420,30 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
finishAutocomplete();
}
private void loadVideoThumbIntoView(ImageView target, Uri uri){
MastodonAPIController.runInBackground(()->{
Context context=getActivity();
if(context==null)
return;
try{
MediaMetadataRetriever mmr=new MediaMetadataRetriever();
mmr.setDataSource(context, uri);
Bitmap frame=mmr.getFrameAtTime(3_000_000);
mmr.release();
int size=Math.max(frame.getWidth(), frame.getHeight());
int maxSize=V.dp(250);
if(size>maxSize){
float factor=maxSize/(float)size;
frame=Bitmap.createScaledBitmap(frame, Math.round(frame.getWidth()*factor), Math.round(frame.getHeight()*factor), true);
}
Bitmap finalFrame=frame;
target.post(()->target.setImageBitmap(finalFrame));
}catch(Exception x){
Log.w(TAG, "loadVideoThumbIntoView: error getting video frame", x);
}
});
}
@Override
public CharSequence getTitle(){
return getString(R.string.new_post);
@@ -1199,14 +1464,75 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public Attachment serverAttachment;
public Uri uri;
public transient UploadAttachment uploadRequest;
public transient GetAttachmentByID processingPollingRequest;
public String description;
public String mimeType;
public AttachmentUploadState state=AttachmentUploadState.QUEUED;
public transient View view;
public transient ProgressBar progressBar;
public transient TextView descriptionView;
public transient View overlay;
public transient View infoBar;
public transient Button retryButton;
public transient ImageButton retryButton;
public transient ObjectAnimator progressBarAnimator;
public transient Runnable processingPollingRunnable;
public transient ImageView imageView;
public transient TextView uploadStateTitle, uploadStateText;
public transient TransferSpeedTracker speedTracker=new TransferSpeedTracker();
public void cancelUpload(){
switch(state){
case UPLOADING -> {
if(uploadRequest!=null){
uploadRequest.cancel();
uploadRequest=null;
}
}
case PROCESSING -> {
if(processingPollingRunnable!=null){
UiUtils.removeCallbacks(processingPollingRunnable);
processingPollingRunnable=null;
}
if(processingPollingRequest!=null){
processingPollingRequest.cancel();
processingPollingRequest=null;
}
}
default -> throw new IllegalStateException("Unexpected state "+state);
}
}
public boolean isUploadingOrProcessing(){
return state==AttachmentUploadState.UPLOADING || state==AttachmentUploadState.PROCESSING;
}
public void setOverlayVisible(boolean visible, boolean animated){
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){
if(visible){
imageView.setRenderEffect(RenderEffect.createBlurEffect(V.dp(16), V.dp(16), Shader.TileMode.REPEAT));
}else{
imageView.setRenderEffect(null);
}
}
int infoBarVis=visible ? View.GONE : View.VISIBLE;
int overlayVis=visible ? View.VISIBLE : View.GONE;
if(animated){
V.setVisibilityAnimated(infoBar, infoBarVis);
V.setVisibilityAnimated(overlay, overlayVis);
}else{
infoBar.setVisibility(infoBarVis);
overlay.setVisibility(overlayVis);
}
}
}
enum AttachmentUploadState{
QUEUED,
UPLOADING,
PROCESSING,
ERROR,
DONE
}
private static class DraftPollOption{

View File

@@ -2,13 +2,9 @@ package org.joinmastodon.android.fragments;
import android.app.Fragment;
import android.app.NotificationManager;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Outline;
import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -18,20 +14,14 @@ import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.PushNotificationReceiver;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.discover.DiscoverFragment;
import org.joinmastodon.android.fragments.discover.SearchFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.TabBar;
import org.parceler.Parcels;
@@ -41,15 +31,12 @@ import java.util.ArrayList;
import androidx.annotation.IdRes;
import androidx.annotation.Nullable;
import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.fragments.LoaderFragment;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.BottomSheet;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class HomeFragment extends AppKitFragment implements OnBackPressedListener{
@@ -141,7 +128,6 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
}
});
}
}else{
}
return content;

View File

@@ -23,9 +23,11 @@ import android.widget.Toolbar;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.Filter;
@@ -33,6 +35,7 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.Collections;
@@ -101,6 +104,11 @@ public class HomeTimelineFragment extends StatusListFragment{
}
}
});
if(GithubSelfUpdater.needSelfUpdating()){
E.register(this);
updateUpdateState(GithubSelfUpdater.getInstance().getState());
}
}
@Override
@@ -397,4 +405,22 @@ public class HomeTimelineFragment extends StatusListFragment{
scrollToTop();
}
}
@Override
public void onDestroyView(){
super.onDestroyView();
if(GithubSelfUpdater.needSelfUpdating()){
E.unregister(this);
}
}
private void updateUpdateState(GithubSelfUpdater.UpdateState state){
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING)
getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_24_badged);
}
@Subscribe
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
updateUpdateState(ev.state);
}
}

View File

@@ -1,10 +1,6 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Bundle;
import android.view.View;
@@ -16,15 +12,12 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.displayitems.AccountCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
import org.parceler.Parcels;
import java.util.ArrayList;
@@ -33,7 +26,6 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
@@ -160,91 +152,7 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private int bgColor=UiUtils.getThemeColor(getActivity(), android.R.attr.colorBackground);
private int borderColor=UiUtils.getThemeColor(getActivity(), R.attr.colorPollVoted);
private RectF rect=new RectF();
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
int pos=0;
for(int i=0;i<parent.getChildCount();i++){
View child=parent.getChildAt(i);
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
pos=holder.getAbsoluteAdapterPosition();
boolean inset=(holder instanceof StatusDisplayItem.Holder<?> sdi) && sdi.getItem().inset;
if(inset){
if(rect.isEmpty()){
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{
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight());
}
}else if(!rect.isEmpty()){
drawInsetBackground(c);
rect.setEmpty();
}
}
if(!rect.isEmpty()){
if(pos<displayItems.size()-1 && displayItems.get(pos+1).inset){
rect.bottom=parent.getHeight()+V.dp(10);
}
drawInsetBackground(c);
rect.setEmpty();
}
}
private void drawInsetBackground(Canvas c){
paint.setStyle(Paint.Style.FILL);
paint.setColor(bgColor);
rect.left=V.dp(12);
rect.right=list.getWidth()-V.dp(12);
rect.inset(V.dp(4), V.dp(4));
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
paint.setColor(borderColor);
rect.inset(paint.getStrokeWidth()/2f, paint.getStrokeWidth()/2f);
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if(holder instanceof StatusDisplayItem.Holder<?> sdi){
boolean inset=sdi.getItem().inset;
int pos=holder.getAbsoluteAdapterPosition();
if(inset){
boolean topSiblingInset=pos>0 && displayItems.get(pos-1).inset;
boolean bottomSiblingInset=pos<displayItems.size()-1 && displayItems.get(pos+1).inset;
int pad;
if(holder instanceof ImageStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder)
pad=V.dp(16);
else
pad=V.dp(12);
boolean insetLeft=true, insetRight=true;
if(holder instanceof ImageStatusDisplayItem.Holder<?> img){
PhotoLayoutHelper.TiledLayoutResult layout=img.getItem().tiledLayout;
PhotoLayoutHelper.TiledLayoutResult.Tile tile=img.getItem().thisTile;
// only inset those items that are on the edges of the layout
insetLeft=tile.startCol==0;
insetRight=tile.startCol+tile.colSpan==layout.columnSizes.length;
// inset all items in the bottom row
if(tile.startRow+tile.rowSpan==layout.rowSizes.length)
bottomSiblingInset=false;
}
if(insetLeft)
outRect.left=pad;
if(insetRight)
outRect.right=pad;
if(!topSiblingInset)
outRect.top=pad;
if(!bottomSiblingInset)
outRect.bottom=pad;
}
}
}
});
list.addItemDecoration(new InsetStatusItemDecoration(this));
}
private Notification getNotificationByID(String id){
@@ -268,4 +176,5 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
}
}
}
}

View File

@@ -326,7 +326,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
if(postsFragment==null){
postsFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.DEFAULT, true);
postsWithRepliesFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.INCLUDE_REPLIES, false);
pinnedPostsFragment =AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.PINNED, false);
pinnedPostsFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.PINNED, false);
mediaFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.MEDIA, false);
aboutFragment=new ProfileAboutFragment();
aboutFragment.setFields(fields);
@@ -421,9 +421,16 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
HtmlParser.parseCustomEmoji(ssb, account.emojis);
name.setText(ssb);
setTitle(ssb);
boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account);
if(account.locked){
ssb=new SpannableStringBuilder("@");
ssb.append(account.acct);
if(isSelf){
ssb.append('@');
ssb.append(AccountSessionManager.getInstance().getAccount(accountID).domain);
}
ssb.append(" ");
Drawable lock=username.getResources().getDrawable(R.drawable.ic_fluent_lock_closed_20_filled, getActivity().getTheme()).mutate();
lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight());
@@ -431,7 +438,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
ssb.append(getString(R.string.manually_approves_followers), new ImageSpan(lock, ImageSpan.ALIGN_BOTTOM), 0);
username.setText(ssb);
}else{
username.setText('@'+account.acct);
// noinspection SetTextI18n
username.setText('@'+account.acct+(isSelf ? ('@'+AccountSessionManager.getInstance().getAccount(accountID).domain) : ""));
}
CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
if(TextUtils.isEmpty(parsedBio)){

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
@@ -14,15 +15,21 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.animation.LinearInterpolator;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.ProgressBar;
import android.widget.RadioButton;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
@@ -31,11 +38,13 @@ import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.model.PushNotification;
import org.joinmastodon.android.model.PushSubscription;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import java.util.ArrayList;
import java.util.function.Consumer;
@@ -47,7 +56,6 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.imageloader.ImageCache;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
@@ -73,6 +81,14 @@ public class SettingsFragment extends MastodonToolbarFragment{
accountID=getArguments().getString("account");
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
if(GithubSelfUpdater.needSelfUpdating()){
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
GithubSelfUpdater.UpdateState state=updater.getState();
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING){
items.add(new UpdateItem());
}
}
items.add(new HeaderItem(R.string.settings_theme));
items.add(themeItem=new ThemeItem());
items.add(new SwitchItem(R.string.theme_true_black, R.drawable.ic_fluent_dark_theme_24_regular, GlobalUserPreferences.trueBlackTheme, this::onTrueBlackThemeChanged));
@@ -131,7 +147,7 @@ public class SettingsFragment extends MastodonToolbarFragment{
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
// Add 32dp gaps between sections
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if((holder instanceof HeaderViewHolder || holder instanceof FooterViewHolder) && holder.getAbsoluteAdapterPosition()>0)
if((holder instanceof HeaderViewHolder || holder instanceof FooterViewHolder) && holder.getAbsoluteAdapterPosition()>1)
outRect.top=V.dp(32);
}
});
@@ -155,6 +171,20 @@ public class SettingsFragment extends MastodonToolbarFragment{
}
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
if(GithubSelfUpdater.needSelfUpdating())
E.register(this);
}
@Override
public void onDestroyView(){
super.onDestroyView();
if(GithubSelfUpdater.needSelfUpdating())
E.unregister(this);
}
private void onThemePreferenceClick(GlobalUserPreferences.ThemePreference theme){
GlobalUserPreferences.theme=theme;
GlobalUserPreferences.save();
@@ -294,6 +324,16 @@ public class SettingsFragment extends MastodonToolbarFragment{
});
}
@Subscribe
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
if(items.get(0) instanceof UpdateItem item){
RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(0);
if(holder instanceof UpdateViewHolder uvh){
uvh.bind(item);
}
}
}
private static abstract class Item{
public abstract int getViewType();
}
@@ -395,6 +435,14 @@ public class SettingsFragment extends MastodonToolbarFragment{
}
}
private class UpdateItem extends Item{
@Override
public int getViewType(){
return 7;
}
}
private class SettingsAdapter extends RecyclerView.Adapter<BindableViewHolder<Item>>{
@NonNull
@Override
@@ -408,6 +456,7 @@ public class SettingsFragment extends MastodonToolbarFragment{
case 4 -> new TextViewHolder();
case 5 -> new HeaderViewHolder(true);
case 6 -> new FooterViewHolder();
case 7 -> new UpdateViewHolder();
default -> throw new IllegalStateException("Unexpected value: "+viewType);
};
}
@@ -609,4 +658,74 @@ public class SettingsFragment extends MastodonToolbarFragment{
text.setText(item.text);
}
}
private class UpdateViewHolder extends BindableViewHolder<UpdateItem>{
private final TextView text;
private final Button button;
private final ImageButton cancelBtn;
private final ProgressBar progress;
private ObjectAnimator rotationAnimator;
private Runnable progressUpdater=this::updateProgress;
public UpdateViewHolder(){
super(getActivity(), R.layout.item_settings_update, list);
text=findViewById(R.id.text);
button=findViewById(R.id.button);
cancelBtn=findViewById(R.id.cancel_btn);
progress=findViewById(R.id.progress);
button.setOnClickListener(v->{
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
switch(updater.getState()){
case UPDATE_AVAILABLE -> updater.downloadUpdate();
case DOWNLOADED -> updater.installUpdate(getActivity());
}
});
cancelBtn.setOnClickListener(v->GithubSelfUpdater.getInstance().cancelDownload());
rotationAnimator=ObjectAnimator.ofFloat(progress, View.ROTATION, 0f, 360f);
rotationAnimator.setInterpolator(new LinearInterpolator());
rotationAnimator.setDuration(1500);
rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE);
}
@Override
public void onBind(UpdateItem item){
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
GithubSelfUpdater.UpdateInfo info=updater.getUpdateInfo();
GithubSelfUpdater.UpdateState state=updater.getState();
if(state!=GithubSelfUpdater.UpdateState.DOWNLOADED){
text.setText(getString(R.string.update_available, info.version));
button.setText(getString(R.string.download_update, UiUtils.formatFileSize(getActivity(), info.size, false)));
}else{
text.setText(getString(R.string.update_ready, info.version));
button.setText(R.string.install_update);
}
if(state==GithubSelfUpdater.UpdateState.DOWNLOADING){
rotationAnimator.start();
button.setVisibility(View.INVISIBLE);
cancelBtn.setVisibility(View.VISIBLE);
progress.setVisibility(View.VISIBLE);
updateProgress();
}else{
rotationAnimator.cancel();
button.setVisibility(View.VISIBLE);
cancelBtn.setVisibility(View.GONE);
progress.setVisibility(View.GONE);
progress.removeCallbacks(progressUpdater);
}
}
private void updateProgress(){
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
if(updater.getState()!=GithubSelfUpdater.UpdateState.DOWNLOADING)
return;
int value=Math.round(progress.getMax()*updater.getDownloadProgress());
if(Build.VERSION.SDK_INT>=24)
progress.setProgress(value, true);
else
progress.setProgress(value);
progress.postDelayed(progressUpdater, 1000);
}
}
}

View File

@@ -0,0 +1,157 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetStatusEditHistory;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.time.ZoneId;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
public class StatusEditHistoryFragment extends StatusListFragment{
private String id;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
id=getArguments().getString("id");
loadData();
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.edit_history);
}
@Override
protected void doLoadData(int offset, int count){
new GetStatusEditHistory(id)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
Collections.sort(result, Comparator.comparing((Status s)->s.createdAt).reversed());
onDataLoaded(result, false);
}
})
.exec(accountID);
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(Status s){
List<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, true, false);
int idx=data.indexOf(s);
if(idx>=0){
String date=UiUtils.DATE_TIME_FORMATTER.format(s.createdAt.atZone(ZoneId.systemDefault()));
String action="";
if(idx==data.size()-1){
action=getString(R.string.edit_original_post);
}else{
enum StatusEditChangeType{
TEXT_CHANGED,
SPOILER_ADDED,
SPOILER_REMOVED,
SPOILER_CHANGED,
POLL_ADDED,
POLL_REMOVED,
POLL_CHANGED,
MEDIA_ADDED,
MEDIA_REMOVED,
MEDIA_REORDERED,
MARKED_SENSITIVE,
MARKED_NOT_SENSITIVE
}
EnumSet<StatusEditChangeType> changes=EnumSet.noneOf(StatusEditChangeType.class);
Status prev=data.get(idx+1);
if(!Objects.equals(s.content, prev.content)){
changes.add(StatusEditChangeType.TEXT_CHANGED);
}
if(!Objects.equals(s.spoilerText, prev.spoilerText)){
if(s.spoilerText==null){
changes.add(StatusEditChangeType.SPOILER_REMOVED);
}else if(prev.spoilerText==null){
changes.add(StatusEditChangeType.SPOILER_ADDED);
}else{
changes.add(StatusEditChangeType.SPOILER_CHANGED);
}
}
if(s.poll!=null || prev.poll!=null){
if(s.poll==null){
changes.add(StatusEditChangeType.POLL_REMOVED);
}else if(prev.poll==null){
changes.add(StatusEditChangeType.POLL_ADDED);
}else if(!s.poll.id.equals(prev.poll.id)){
changes.add(StatusEditChangeType.POLL_CHANGED);
}
}
List<String> newAttachmentIDs=s.mediaAttachments.stream().map(att->att.id).collect(Collectors.toList());
List<String> prevAttachmentIDs=s.mediaAttachments.stream().map(att->att.id).collect(Collectors.toList());
boolean addedOrRemoved=false;
if(!newAttachmentIDs.containsAll(prevAttachmentIDs)){
changes.add(StatusEditChangeType.MEDIA_REMOVED);
addedOrRemoved=true;
}
if(!prevAttachmentIDs.containsAll(newAttachmentIDs)){
changes.add(StatusEditChangeType.MEDIA_ADDED);
addedOrRemoved=true;
}
if(!addedOrRemoved && !newAttachmentIDs.equals(prevAttachmentIDs)){
changes.add(StatusEditChangeType.MEDIA_REORDERED);
}
if(s.sensitive && !prev.sensitive){
changes.add(StatusEditChangeType.MARKED_SENSITIVE);
}else if(prev.sensitive && !s.sensitive){
changes.add(StatusEditChangeType.MARKED_NOT_SENSITIVE);
}
if(changes.size()==1){
action=getString(switch(changes.iterator().next()){
case TEXT_CHANGED -> R.string.edit_text_edited;
case SPOILER_ADDED -> R.string.edit_spoiler_added;
case SPOILER_REMOVED -> R.string.edit_spoiler_removed;
case SPOILER_CHANGED -> R.string.edit_spoiler_edited;
case POLL_ADDED -> R.string.edit_poll_added;
case POLL_REMOVED -> R.string.edit_poll_removed;
case POLL_CHANGED -> R.string.edit_poll_edited;
case MEDIA_ADDED -> R.string.edit_media_added;
case MEDIA_REMOVED -> R.string.edit_media_removed;
case MEDIA_REORDERED -> R.string.edit_media_reordered;
case MARKED_SENSITIVE -> R.string.edit_marked_sensitive;
case MARKED_NOT_SENSITIVE -> R.string.edit_marked_not_sensitive;
});
}else{
action=getString(R.string.edit_multiple_changed);
}
}
items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" · "+date, Collections.emptyList(), 0));
}
return items;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new InsetStatusItemDecoration(this));
}
@Override
public boolean isItemEnabled(String id){
return false;
}
}

View File

@@ -9,6 +9,7 @@ import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusDeletedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.events.StatusUnpinnedEvent;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
@@ -16,6 +17,7 @@ import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
@@ -61,8 +63,6 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
protected void onStatusCreated(StatusCreatedEvent ev){}
protected void onStatusUnpinned(StatusUnpinnedEvent ev){}
protected Status getContentStatusByID(String id){
Status s=getStatusByID(id);
return s==null ? null : s.getContentStatus();
@@ -138,11 +138,6 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
StatusListFragment.this.onStatusCreated(ev);
}
@Subscribe
public void onStatusUnpinned(StatusUnpinnedEvent ev){
StatusListFragment.this.onStatusUnpinned(ev);
}
@Subscribe
public void onPollUpdated(PollUpdatedEvent ev){
if(!ev.accountID.equals(accountID))

View File

@@ -43,7 +43,7 @@ public class ThreadFragment extends StatusListFragment{
@Override
protected List<StatusDisplayItem> buildDisplayItems(Status s){
List<StatusDisplayItem> items=super.buildDisplayItems(s);
if(s==mainStatus){
if(s.id.equals(mainStatus.id)){
for(StatusDisplayItem item:items){
if(item instanceof TextStatusDisplayItem text)
text.textSelectable=true;

View File

@@ -85,7 +85,7 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetAccountStatuses(reportAccount.id, offset>0 ? getMaxID() : null, null, count, GetAccountStatuses.Filter.NO_REBLOGS)
currentRequest=new GetAccountStatuses(reportAccount.id, offset>0 ? getMaxID() : null, null, count, GetAccountStatuses.Filter.OWN_POSTS_AND_REPLIES)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
@@ -102,6 +102,7 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
else
selectedIDs.add(id);
list.invalidate();
btn.setEnabled(!selectedIDs.isEmpty());
}
@Override

View File

@@ -0,0 +1,12 @@
package org.joinmastodon.android.model;
import com.google.gson.annotations.SerializedName;
public enum ExpandMedia {
@SerializedName("default")
DEFAULT,
@SerializedName("show_all")
SHOW_ALL,
@SerializedName("hide_all")
HIDE_ALL;
}

View File

@@ -0,0 +1,38 @@
package org.joinmastodon.android.model;
import com.google.gson.annotations.SerializedName;
/**
* Preferred common behaviors to be shared across clients.
*/
public class Preferences extends BaseModel {
/**
* Default visibility for new posts
*/
@SerializedName("posting:default:visibility")
public StatusPrivacy postingDefaultVisibility;
/**
* Default sensitivity flag for new posts
*/
@SerializedName("posting:default:sensitive")
public boolean postingDefaultSensitive;
/**
* Default language for new posts
*/
@SerializedName("posting:default:language")
public String postingDefaultLanguage;
/**
* Whether media attachments should be automatically displayed or blurred/hidden.
*/
@SerializedName("reading:expand:media")
public ExpandMedia readingExpandMedia;
/**
* Whether CWs should be expanded by default.
*/
@SerializedName("reading:expand:spoilers")
public boolean readingExpandSpoilers;
}

View File

@@ -37,6 +37,7 @@ public class Status extends BaseModel implements DisplayItemsParent{
public int reblogsCount;
public int favouritesCount;
public int repliesCount;
public Instant editedAt;
public String url;
public String inReplyToId;

View File

@@ -4,11 +4,25 @@ import com.google.gson.annotations.SerializedName;
public enum StatusPrivacy{
@SerializedName("public")
PUBLIC,
PUBLIC(0),
@SerializedName("unlisted")
UNLISTED,
UNLISTED(1),
@SerializedName("private")
PRIVATE,
PRIVATE(2),
@SerializedName("direct")
DIRECT;
DIRECT(3);
private int privacy;
StatusPrivacy(int privacy) {
this.privacy = privacy;
}
public boolean isLessVisibleThan(StatusPrivacy other) {
return privacy > other.getPrivacy();
}
public int getPrivacy() {
return privacy;
}
}

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.ui.displayitems;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
@@ -12,6 +13,7 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.StatusEditHistoryFragment;
import org.joinmastodon.android.fragments.account_list.StatusFavoritesListFragment;
import org.joinmastodon.android.fragments.account_list.StatusReblogsListFragment;
import org.joinmastodon.android.fragments.account_list.StatusRelatedAccountListFragment;
@@ -43,39 +45,35 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
}
public static class Holder extends StatusDisplayItem.Holder<ExtendedFooterStatusDisplayItem>{
private final TextView reblogs, favorites, time;
private final View buttonsView;
private final TextView time, favoritesCount, reblogsCount, lastEditTime;
private final View favorites, reblogs, editHistory;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_extended_footer, parent);
reblogs=findViewById(R.id.reblogs);
favorites=findViewById(R.id.favorites);
editHistory=findViewById(R.id.edit_history);
time=findViewById(R.id.timestamp);
buttonsView=findViewById(R.id.button_bar);
favoritesCount=findViewById(R.id.favorites_count);
reblogsCount=findViewById(R.id.reblogs_count);
lastEditTime=findViewById(R.id.last_edited);
reblogs.setOnClickListener(v->startAccountListFragment(StatusReblogsListFragment.class));
favorites.setOnClickListener(v->startAccountListFragment(StatusFavoritesListFragment.class));
editHistory.setOnClickListener(v->startEditHistoryFragment());
}
@SuppressLint("DefaultLocale")
@Override
public void onBind(ExtendedFooterStatusDisplayItem item){
Status s=item.status;
if(s.favouritesCount>0){
favorites.setVisibility(View.VISIBLE);
favorites.setText(getFormattedPlural(R.plurals.x_favorites, s.favouritesCount));
favoritesCount.setText(String.format("%,d", s.favouritesCount));
reblogsCount.setText(String.format("%,d", s.reblogsCount));
if(s.editedAt!=null){
editHistory.setVisibility(View.VISIBLE);
lastEditTime.setText(item.parentFragment.getString(R.string.last_edit_at_x, UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt)));
}else{
favorites.setVisibility(View.GONE);
}
if(s.reblogsCount>0){
reblogs.setVisibility(View.VISIBLE);
reblogs.setText(getFormattedPlural(R.plurals.x_reblogs, s.reblogsCount));
}else{
reblogs.setVisibility(View.GONE);
}
if(s.favouritesCount==0 && s.reblogsCount==0){
buttonsView.setVisibility(View.GONE);
}else{
buttonsView.setVisibility(View.VISIBLE);
editHistory.setVisibility(View.GONE);
}
String timeStr=TIME_FORMATTER.format(item.status.createdAt.atZone(ZoneId.systemDefault()));
if(item.status.application!=null && !TextUtils.isEmpty(item.status.application.name)){
@@ -108,5 +106,12 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
args.putParcelable("status", Parcels.wrap(item.status));
Nav.go(item.parentFragment.getActivity(), cls, args);
}
private void startEditHistoryFragment(){
Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putString("id", item.status.id);
Nav.go(item.parentFragment.getActivity(), StatusEditHistoryFragment.class, args);
}
}
}

View File

@@ -21,8 +21,10 @@ import android.widget.Toast;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.statuses.GetStatusSourceText;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
@@ -135,7 +137,31 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
optionsMenu.setOnMenuItemClickListener(menuItem->{
Account account=item.user;
int id=menuItem.getItemId();
if(id==R.id.delete){
if(id==R.id.edit){
final Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("editStatus", Parcels.wrap(item.status));
if(TextUtils.isEmpty(item.status.content) && TextUtils.isEmpty(item.status.spoilerText)){
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
}else{
new GetStatusSourceText(item.status.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(GetStatusSourceText.Response result){
args.putString("sourceText", result.text);
args.putString("sourceSpoiler", result.spoilerText);
Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args);
}
@Override
public void onError(ErrorResponse error){
error.showToast(item.parentFragment.getActivity());
}
})
.wrapProgress(item.parentFragment.getActivity(), R.string.loading, true)
.exec(item.parentFragment.getAccountID());
}
}else if(id==R.id.delete){
UiUtils.confirmDeletePost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, s->{});
}else if(id==R.id.delete_and_redraft) {
UiUtils.confirmDeleteAndRedraftPost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, s->{});
@@ -179,7 +205,10 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
public void onBind(HeaderStatusDisplayItem item){
name.setText(item.parsedName);
username.setText('@'+item.user.acct);
timestamp.setText(UiUtils.formatRelativeTimestamp(itemView.getContext(), item.createdAt));
if(item.status==null || item.status.editedAt==null)
timestamp.setText(UiUtils.formatRelativeTimestamp(itemView.getContext(), item.createdAt));
else
timestamp.setText(item.parentFragment.getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(itemView.getContext(), item.status.editedAt)));
visibility.setVisibility(item.hasVisibilityToggle && !item.inset ? View.VISIBLE : View.GONE);
if(item.hasVisibilityToggle){
visibility.setImageResource(item.status.spoilerRevealed ? R.drawable.ic_visibility_off : R.drawable.ic_visibility);
@@ -253,6 +282,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
Account account=item.user;
Menu menu=optionsMenu.getMenu();
boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account);
menu.findItem(R.id.edit).setVisible(item.status!=null && isOwnPost);
menu.findItem(R.id.delete).setVisible(item.status!=null && isOwnPost);
menu.findItem(R.id.delete_and_redraft).setVisible(item.status!=null && isOwnPost);
menu.findItem(R.id.pin).setVisible(item.status!=null && isOwnPost && !item.status.pinned);

View File

@@ -8,6 +8,7 @@ import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.DisplayItemsParent;
@@ -114,7 +115,7 @@ public abstract class StatusDisplayItem{
}
if(addFooter){
items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID));
if(status.hasGapAfter)
if(status.hasGapAfter && !(fragment instanceof ThreadFragment))
items.add(new GapStatusDisplayItem(parentID, fragment));
}
int i=1;

View File

@@ -129,7 +129,16 @@ public class HtmlParser{
}
public static void parseCustomEmoji(SpannableStringBuilder ssb, List<Emoji> emojis){
Map<String, Emoji> emojiByCode=emojis.stream().collect(Collectors.toMap(e->e.shortcode, Function.identity()));
Map<String, Emoji> emojiByCode =
emojis.stream()
.collect(
Collectors.toMap(e->e.shortcode, Function.identity(), (emoji1, emoji2) -> {
// Ignore duplicate shortcodes and just take the first, it will be
// the same emoji anyway
return emoji1;
})
);
Matcher matcher=EMOJI_CODE_PATTERN.matcher(ssb);
int spanCount=0;
CustomEmojiSpan lastSpan=null;

View File

@@ -0,0 +1,116 @@
package org.joinmastodon.android.ui.utils;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.NotificationsListFragment;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.V;
public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{
private final BaseStatusListFragment<?> listFragment;
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private int bgColor;
private int borderColor;
private RectF rect=new RectF();
public InsetStatusItemDecoration(BaseStatusListFragment<?> listFragment){
this.listFragment=listFragment;
bgColor=UiUtils.getThemeColor(listFragment.getActivity(), android.R.attr.colorBackground);
borderColor=UiUtils.getThemeColor(listFragment.getActivity(), R.attr.colorPollVoted);
}
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
List<StatusDisplayItem> displayItems=listFragment.getDisplayItems();
int pos=0;
for(int i=0; i<parent.getChildCount(); i++){
View child=parent.getChildAt(i);
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
pos=holder.getAbsoluteAdapterPosition();
boolean inset=(holder instanceof StatusDisplayItem.Holder<?> sdi) && sdi.getItem().inset;
if(inset){
if(rect.isEmpty()){
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{
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight());
}
}else if(!rect.isEmpty()){
drawInsetBackground(parent, c);
rect.setEmpty();
}
}
if(!rect.isEmpty()){
if(pos<displayItems.size()-1 && displayItems.get(pos+1).inset){
rect.bottom=parent.getHeight()+V.dp(10);
}
drawInsetBackground(parent, c);
rect.setEmpty();
}
}
private void drawInsetBackground(RecyclerView list, Canvas c){
paint.setStyle(Paint.Style.FILL);
paint.setColor(bgColor);
rect.left=V.dp(12);
rect.right=list.getWidth()-V.dp(12);
rect.inset(V.dp(4), V.dp(4));
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
paint.setColor(borderColor);
rect.inset(paint.getStrokeWidth()/2f, paint.getStrokeWidth()/2f);
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
List<StatusDisplayItem> displayItems=listFragment.getDisplayItems();
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if(holder instanceof StatusDisplayItem.Holder<?> sdi){
boolean inset=sdi.getItem().inset;
int pos=holder.getAbsoluteAdapterPosition();
if(inset){
boolean topSiblingInset=pos>0 && displayItems.get(pos-1).inset;
boolean bottomSiblingInset=pos<displayItems.size()-1 && displayItems.get(pos+1).inset;
int pad;
if(holder instanceof ImageStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder)
pad=V.dp(16);
else
pad=V.dp(12);
boolean insetLeft=true, insetRight=true;
if(holder instanceof ImageStatusDisplayItem.Holder<?> img){
PhotoLayoutHelper.TiledLayoutResult layout=img.getItem().tiledLayout;
PhotoLayoutHelper.TiledLayoutResult.Tile tile=img.getItem().thisTile;
// only inset those items that are on the edges of the layout
insetLeft=tile.startCol==0;
insetRight=tile.startCol+tile.colSpan==layout.columnSizes.length;
// inset all items in the bottom row
if(tile.startRow+tile.rowSpan==layout.rowSizes.length)
bottomSiblingInset=false;
}
if(insetLeft)
outRect.left=pad;
if(insetRight)
outRect.right=pad;
if(!topSiblingInset)
outRect.top=pad;
if(!bottomSiblingInset)
outRect.bottom=pad;
}
}
}
}

View File

@@ -0,0 +1,51 @@
package org.joinmastodon.android.ui.utils;
import android.os.SystemClock;
public class TransferSpeedTracker{
private final double SMOOTHING_FACTOR=0.05;
private long lastKnownPos;
private long lastKnownPosTime;
private double lastSpeed;
private double averageSpeed;
private long totalBytes;
public void addSample(long position){
if(lastKnownPosTime==0){
lastKnownPosTime=SystemClock.uptimeMillis();
lastKnownPos=position;
}else{
long time=SystemClock.uptimeMillis();
lastSpeed=(position-lastKnownPos)/((double)(time-lastKnownPosTime)/1000.0);
lastKnownPos=position;
lastKnownPosTime=time;
}
}
public double getLastSpeed(){
return lastSpeed;
}
public double getAverageSpeed(){
return averageSpeed;
}
public long updateAndGetETA(){ // must be called at a constant interval
if(averageSpeed==0.0)
averageSpeed=lastSpeed;
else
averageSpeed=SMOOTHING_FACTOR*lastSpeed+(1.0-SMOOTHING_FACTOR)*averageSpeed;
return Math.round((totalBytes-lastKnownPos)/averageSpeed);
}
public void setTotalBytes(long totalBytes){
this.totalBytes=totalBytes;
}
public void reset(){
lastKnownPos=lastKnownPosTime=0;
lastSpeed=averageSpeed=0.0;
totalBytes=0;
}
}

View File

@@ -69,6 +69,7 @@ import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
@@ -98,6 +99,7 @@ import okhttp3.MediaType;
public class UiUtils{
private static Handler mainHandler=new Handler(Looper.getMainLooper());
private static final DateTimeFormatter DATE_FORMATTER_SHORT_WITH_YEAR=DateTimeFormatter.ofPattern("d MMM uuuu"), DATE_FORMATTER_SHORT=DateTimeFormatter.ofPattern("d MMM");
public static final DateTimeFormatter DATE_TIME_FORMATTER=DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
private UiUtils(){}
@@ -142,6 +144,23 @@ public class UiUtils{
}
}
public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant){
long t=instant.toEpochMilli();
long now=System.currentTimeMillis();
long diff=now-t;
if(diff<1000L){
return context.getString(R.string.time_just_now);
}else if(diff<60_000L){
int secs=(int)(diff/1000L);
return context.getResources().getQuantityString(R.plurals.x_seconds_ago, secs, secs);
}else if(diff<3600_000L){
int mins=(int)(diff/60_000L);
return context.getResources().getQuantityString(R.plurals.x_minutes_ago, mins, mins);
}else{
return DATE_TIME_FORMATTER.format(instant.atZone(ZoneId.systemDefault()));
}
}
public static String formatTimeLeft(Context context, Instant instant){
long t=instant.toEpochMilli();
long now=System.currentTimeMillis();
@@ -195,6 +214,14 @@ public class UiUtils{
mainHandler.post(runnable);
}
public static void runOnUiThread(Runnable runnable, long delay){
mainHandler.postDelayed(runnable, delay);
}
public static void removeCallbacks(Runnable runnable){
mainHandler.removeCallbacks(runnable);
}
/** Linear interpolation between {@code startValue} and {@code endValue} by {@code fraction}. */
public static int lerp(int startValue, int endValue, float fraction) {
return startValue + Math.round(fraction * (endValue - startValue));
@@ -212,6 +239,18 @@ public class UiUtils{
return uri.getLastPathSegment();
}
public static String formatFileSize(Context context, long size, boolean atLeastKB){
if(size<1024 && !atLeastKB){
return context.getString(R.string.file_size_bytes, size);
}else if(size<1024*1024){
return context.getString(R.string.file_size_kb, size/1024.0);
}else if(size<1024*1024*1024){
return context.getString(R.string.file_size_mb, size/(1024.0*1024.0));
}else{
return context.getString(R.string.file_size_gb, size/(1024.0*1024.0*1024.0));
}
}
public static MediaType getFileMediaType(File file){
String name=file.getName();
return MediaType.parse(MimeTypeMap.getSingleton().getMimeTypeFromExtension(name.substring(name.lastIndexOf('.')+1)));

View File

@@ -54,12 +54,13 @@ public class ComposeEditText extends EditText{
// Support receiving images from keyboards
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs){
final var ic = super.onCreateInputConnection(outAttrs);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N_MR1){
outAttrs.contentMimeTypes=selectionListener.onGetAllowedMediaMimeTypes();
inputConnectionWrapper.setTarget(super.onCreateInputConnection(outAttrs));
inputConnectionWrapper.setTarget(ic);
return inputConnectionWrapper;
}
return super.onCreateInputConnection(outAttrs);
return ic;
}
// Support pasting images

View File

@@ -0,0 +1,54 @@
package org.joinmastodon.android.updater;
import android.app.Activity;
import android.content.Intent;
import org.joinmastodon.android.BuildConfig;
public abstract class GithubSelfUpdater{
private static GithubSelfUpdater instance;
public static GithubSelfUpdater getInstance(){
if(instance==null){
try{
Class<?> c=Class.forName("org.joinmastodon.android.updater.GithubSelfUpdaterImpl");
instance=(GithubSelfUpdater) c.newInstance();
}catch(IllegalAccessException|InstantiationException|ClassNotFoundException ignored){
}
}
return instance;
}
public static boolean needSelfUpdating(){
return BuildConfig.BUILD_TYPE.equals("githubRelease");
}
public abstract void maybeCheckForUpdates();
public abstract GithubSelfUpdater.UpdateState getState();
public abstract GithubSelfUpdater.UpdateInfo getUpdateInfo();
public abstract void downloadUpdate();
public abstract void installUpdate(Activity activity);
public abstract float getDownloadProgress();
public abstract void cancelDownload();
public abstract void handleIntentFromInstaller(Intent intent, Activity activity);
public enum UpdateState{
NO_UPDATE,
CHECKING,
UPDATE_AVAILABLE,
DOWNLOADING,
DOWNLOADED
}
public static class UpdateInfo{
public String version;
public long size;
}
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16.7096,17.7682C19.4819,17.4391 21.8955,15.7408 22.199,14.1888C22.6769,11.7442 22.6376,8.2231 22.6376,8.2231C22.6376,3.4504 19.4929,2.0516 19.4929,2.0516C17.9073,1.3274 15.1846,1.023 12.356,1H12.2865C9.4579,1.023 6.7369,1.3274 5.1513,2.0516C5.1513,2.0516 2.0066,3.4504 2.0066,8.2231C2.0066,8.5125 2.0051,8.8169 2.0035,9.1339C1.9991,10.0135 1.9943,10.9896 2.0199,12.0083C2.1341,16.6755 2.8805,21.2752 7.2202,22.4175C9.2213,22.944 10.9392,23.0542 12.323,22.9785C14.832,22.8403 16.2406,22.0883 16.2406,22.0883L16.1577,20.2779C16.1577,20.2779 14.3648,20.8402 12.3511,20.7717C10.356,20.7037 8.2496,20.5577 7.9269,18.1221C7.8972,17.9082 7.8823,17.6794 7.8823,17.4391C7.8823,17.4391 9.8408,17.9152 12.323,18.0283C13.8407,18.0974 15.2639,17.9399 16.7096,17.7682ZM18.8747,14.3719V8.5932C18.8747,7.4121 18.5723,6.4736 17.9648,5.7792C17.3382,5.0849 16.518,4.729 15.4997,4.729C14.3212,4.729 13.4291,5.1792 12.8392,6.0799L12.2657,7.0359L11.692,6.0799C11.1023,5.1792 10.21,4.729 9.0316,4.729C8.0134,4.729 7.193,5.0849 6.5664,5.7792C5.9589,6.4736 5.6565,7.4121 5.6565,8.5932V14.3719H7.959V8.763C7.959,7.5805 8.4594,6.9806 9.4602,6.9806C10.5665,6.9806 11.1211,7.6925 11.1211,9.1001V12.1701H13.4101V9.1001C13.4101,7.6925 13.9647,6.9806 15.071,6.9806C16.0718,6.9806 16.5722,7.5805 16.5722,8.763V14.3719H18.8747Z"
android:fillColor="#fff"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?colorPollVoted"/>
<corners android:radius="4dp"/>
</shape>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="?android:colorForeground">
<item android:id="@android:id/mask">
<shape android:shape="oval">
<solid android:color="#18000000"/>
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="?android:colorBackground"/>
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="@color/highlight_over_dark">
<item>
<shape android:shape="oval">
<solid android:color="@color/gray_600"/>
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,3 @@
<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="M12 4.75c-4.004 0-7.25 3.246-7.25 7.25s3.246 7.25 7.25 7.25 7.25-3.246 7.25-7.25c0-0.286-0.017-0.567-0.049-0.844C19.133 10.568 19.56 10 20.151 10c0.515 0 0.968 0.358 1.03 0.87 0.046 0.37 0.069 0.747 0.069 1.13 0 5.109-4.141 9.25-9.25 9.25S2.75 17.109 2.75 12 6.891 2.75 12 2.75c2.173 0 4.171 0.75 5.75 2.004V4.25c0-0.552 0.448-1 1-1s1 0.448 1 1v2.698L19.784 7H19.75v0.25c0 0.552-0.448 1-1 1h-3c-0.552 0-1-0.448-1-1s0.448-1 1-1h0.666c-1.222-0.94-2.754-1.5-4.416-1.5z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="16dp" android:height="16dp" android:viewportWidth="16" android:viewportHeight="16">
<path android:pathData="M2.397 2.554L2.47 2.47c0.266-0.267 0.683-0.29 0.976-0.073L3.53 2.47 8 6.939l4.47-4.47c0.293-0.292 0.767-0.292 1.06 0 0.293 0.294 0.293 0.768 0 1.061L9.061 8l4.47 4.47c0.266 0.266 0.29 0.683 0.072 0.976L13.53 13.53c-0.266 0.267-0.683 0.29-0.976 0.073L12.47 13.53 8 9.061l-4.47 4.47c-0.293 0.292-0.767 0.292-1.06 0-0.293-0.294-0.293-0.768 0-1.061L6.939 8l-4.47-4.47C2.204 3.264 2.18 2.847 2.398 2.554L2.47 2.47 2.397 2.554z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<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="M4.21 4.387l0.083-0.094c0.36-0.36 0.928-0.388 1.32-0.083l0.094 0.083L12 10.585l6.293-6.292c0.39-0.39 1.024-0.39 1.414 0 0.39 0.39 0.39 1.024 0 1.414L13.415 12l6.292 6.293c0.36 0.36 0.388 0.928 0.083 1.32l-0.083 0.094c-0.36 0.36-0.928 0.388-1.32 0.083l-0.094-0.083L12 13.415l-6.293 6.292c-0.39 0.39-1.024 0.39-1.414 0-0.39-0.39-0.39-1.024 0-1.414L10.585 12 4.293 5.707c-0.36-0.36-0.388-0.928-0.083-1.32l0.083-0.094L4.21 4.387z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<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.03 2.97c1.398 1.397 1.398 3.663 0 5.06L9.062 20c-0.277 0.277-0.621 0.477-0.999 0.58l-5.116 1.395c-0.56 0.153-1.073-0.361-0.92-0.921l1.395-5.116c0.103-0.377 0.302-0.722 0.58-0.999L15.97 2.97c1.397-1.398 3.663-1.398 5.06 0zM15 6.06L5.062 16c-0.092 0.092-0.159 0.207-0.193 0.333l-1.05 3.85 3.85-1.05C7.793 19.096 7.908 19.03 8 18.938L17.94 9 15 6.06zm2.03-2.03L16.06 5 19 7.94l0.97-0.97c0.811-0.812 0.811-2.128 0-2.94-0.812-0.811-2.128-0.811-2.94 0z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_fluent_settings_24_regular" android:left="2dp" android:right="2dp" android:top="2dp" android:bottom="2dp"/>
<item android:width="14dp" android:height="14dp" android:gravity="top|right">
<shape android:shape="oval">
<stroke android:color="?android:colorPrimary" android:width="2dp"/>
<solid android:color="@color/primary_600"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/progress">
<shape
android:innerRadius="16dp"
android:shape="ring"
android:thickness="4dp"
android:useLevel="true">
<solid android:color="?colorSearchHint"/>
</shape>
</item>
</layer-list>

View File

@@ -6,7 +6,7 @@
android:shape="ring"
android:thickness="4dp"
android:useLevel="true">
<solid android:color="?android:colorAccent"/>
<solid android:color="@color/gray_100"/>
</shape>
</item>
</layer-list>

View File

@@ -65,30 +65,68 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#cc000000"
android:backgroundTint="?colorWindowBackground"
android:padding="8dp"
android:clipToPadding="false"
tools:visibility="visible"
android:visibility="gone">
<ProgressBar
android:id="@+id/progress"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="center"
android:progressDrawable="@drawable/upload_progress"
android:max="1000"
android:padding="0dp"
android:indeterminateOnly="false"
android:indeterminate="false"/>
<Button
android:id="@+id/retry_upload"
android:layout_width="wrap_content"
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start|bottom"
style="?secondaryButtonStyle"
android:text="@string/retry_upload"/>
android:layout_gravity="center_vertical">
<ImageButton
android:id="@+id/retry_or_cancel_upload"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_centerHorizontal="true"
android:layout_alignParentTop="true"
android:src="@drawable/ic_fluent_dismiss_24_filled"
android:contentDescription="@string/cancel"
android:tint="@color/gray_100"
android:background="@drawable/bg_upload_progress"/>
<ProgressBar
android:id="@+id/progress"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_centerHorizontal="true"
android:layout_alignParentTop="true"
android:progressDrawable="@drawable/upload_progress"
android:max="1000"
android:padding="0dp"
android:indeterminateOnly="false"
android:indeterminate="false"/>
<TextView
android:id="@+id/state_title"
android:layout_width="match_parent"
android:layout_height="16dp"
android:layout_below="@id/retry_or_cancel_upload"
android:layout_marginTop="16dp"
android:textColor="@color/gray_200"
android:textSize="14dp"
android:gravity="center_horizontal"
android:singleLine="true"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:includeFontPadding="false"
tools:text="Upload failed"/>
<TextView
android:id="@+id/state_text"
android:layout_width="match_parent"
android:layout_height="32dp"
android:layout_below="@id/state_title"
android:includeFontPadding="false"
android:textColor="@color/gray_200"
android:gravity="center_horizontal|top"
android:lines="2"
android:maxLines="2"
android:ellipsize="end"
tools:text="Your device lost connection to the internet"/>
</RelativeLayout>
<ImageButton
android:id="@+id/remove_btn2"
@@ -98,6 +136,7 @@
android:layout_gravity="end|bottom"
android:background="?android:selectableItemBackgroundBorderless"
android:tint="#D92C2C"
android:contentDescription="@string/delete"
android:src="@drawable/ic_fluent_delete_20_regular"/>
</FrameLayout>

View File

@@ -2,49 +2,146 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorBackgroundLightest">
android:layout_height="wrap_content">
<org.joinmastodon.android.ui.views.AutoOrientationLinearLayout
android:id="@+id/button_bar"
<RelativeLayout
android:id="@+id/reblogs"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="64dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:background="?android:selectableItemBackground">
<Button
android:id="@+id/reblogs"
android:layout_width="wrap_content"
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_fluent_arrow_repeat_all_24_regular"
android:tint="?android:textColorSecondary"
android:importantForAccessibility="no"/>
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="8dp"
android:textSize="14sp"
android:minHeight="36dp"
android:textColor="?android:textColorSecondary"
android:background="@drawable/bg_text_button"
android:fontFamily="sans-serif"
tools:text="4 reblogs"/>
android:layout_marginStart="32dp"
android:layout_toEndOf="@id/icon"
android:minHeight="22dp"
android:singleLine="true"
android:text="@string/post_info_reblogs"
android:textAppearance="@style/m3_body_large" />
<Button
android:id="@+id/favorites"
android:layout_width="wrap_content"
<TextView
android:id="@+id/reblogs_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="8dp"
android:textSize="14sp"
android:minHeight="36dp"
android:layout_below="@id/title"
android:layout_alignStart="@id/title"
android:singleLine="true"
android:textAppearance="@style/m3_body_medium"
android:textColor="?android:textColorSecondary"
android:background="@drawable/bg_text_button"
android:fontFamily="sans-serif"
tools:text="12 favorites"/>
tools:text="123 456"/>
</org.joinmastodon.android.ui.views.AutoOrientationLinearLayout>
</RelativeLayout>
<RelativeLayout
android:id="@+id/favorites"
android:layout_width="match_parent"
android:layout_height="64dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:background="?android:selectableItemBackground">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_fluent_star_24_regular"
android:tint="?android:textColorSecondary"
android:importantForAccessibility="no"/>
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_toEndOf="@id/icon"
android:minHeight="22dp"
android:singleLine="true"
android:text="@string/post_info_favorites"
android:textAppearance="@style/m3_body_large" />
<TextView
android:id="@+id/favorites_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/title"
android:layout_alignStart="@id/title"
android:singleLine="true"
android:textAppearance="@style/m3_body_medium"
android:textColor="?android:textColorSecondary"
tools:text="123 456"/>
</RelativeLayout>
<RelativeLayout
android:id="@+id/edit_history"
android:layout_width="match_parent"
android:layout_height="64dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:background="?android:selectableItemBackground">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_fluent_edit_24_regular"
android:tint="?android:textColorSecondary"
android:importantForAccessibility="no"/>
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_toEndOf="@id/icon"
android:minHeight="22dp"
android:singleLine="true"
android:text="@string/edit_history"
android:textAppearance="@style/m3_body_large" />
<TextView
android:id="@+id/last_edited"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/title"
android:layout_alignStart="@id/title"
android:singleLine="true"
android:textAppearance="@style/m3_body_medium"
android:textColor="?android:textColorSecondary"
tools:text="123 456"/>
</RelativeLayout>
<TextView
android:id="@+id/timestamp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_margin="16dp"
android:minHeight="20dp"
android:gravity="center_vertical"
android:textSize="14sp"

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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:padding="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="64dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:background="@drawable/bg_settings_update"
android:orientation="horizontal">
<TextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:textAppearance="@style/m3_body_medium"
tools:text="@string/update_available"/>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:background="?android:selectableItemBackground"
android:textColor="?colorAccentLight"
android:textAllCaps="true"
android:textSize="14dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:stateListAnimator="@null"
tools:text="@string/install_update"/>
<ImageButton
android:id="@+id/cancel_btn"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="16dp"
android:background="@drawable/bg_update_download_progress"
android:tint="?colorSearchHint"
android:contentDescription="@string/cancel"
android:visibility="gone"
android:src="@drawable/ic_fluent_dismiss_16_filled"/>
<ProgressBar
android:id="@+id/progress"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="16dp"
android:progressDrawable="@drawable/update_progress"
android:max="1000"
android:padding="0dp"
android:visibility="gone"
android:indeterminateOnly="false"
android:indeterminate="false"/>
</FrameLayout>
</LinearLayout>
</FrameLayout>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/edit" android:title="@string/edit"/>
<item android:id="@+id/delete" android:title="@string/delete"/>
<item android:id="@+id/delete_and_redraft" android:title="@string/delete_and_redraft"/>
<item android:id="@+id/pin" android:title="@string/pin_post"/>

View File

@@ -2,4 +2,9 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome>
<layer-list>
<item android:drawable="@drawable/ic_launcher_monochrome" android:gravity="center"/>
</layer-list>
</monochrome>
</adaptive-icon>

View File

@@ -2,4 +2,9 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome>
<layer-list>
<item android:drawable="@drawable/ic_launcher_monochrome" android:gravity="center"/>
</layer-list>
</monochrome>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Some files were not shown because too many files have changed in this diff Show More