A fork of the Mastodon Android client with Bluesky/ATProto support.

feat(atproto): init

+4012 -172
+4
.gitignore
··· 10 10 local.properties 11 11 *.jks 12 12 /fastlane/report.xml 13 + .project 14 + .classpath 15 + .settings/ 16 + bin/
+59
README.md
··· 1 + # MastodonAT (working title) 2 + A fork of the Mastodon Android client with Bluesky/ATProto support. Currently work-in-progress. 3 + 4 + ## What? 5 + Yes, really. This is a fork of the Mastodon Android client with added support for Bluesky and AT Protocol. 6 + 7 + ## Why?? 8 + yeah i just like the mastodon android client more since the react native-based bluesky client is near unusable on my phone but i don't really like the fediverse much sooooooooooo 9 + 10 + ## How??? 11 + This uses the [Ozone](https://github.com/christiandeange/ozone) Kotlin library (not to be confused with the Bluesky moderation tool of the same name) with a small Kotlin-to-Java wrapper to interact with the PDS's XRPC API. ATProto-specific code using the wrapper are written on calls that normally route to the Mastodon API. Unimplemented features will still try to use the Mastodon API on the PDS however though it'll fail every time in most cases. 12 + 13 + ## Can I still use my Mastodon account on this? 14 + Yes. I intended this to still support regular ActivityPub/Mastodon accounts. Bluesky/ATProto is just an added extra. 15 + 16 + ## Can I use this as a daily-driver? 17 + While technically has most of the features of the official Bluesky client, you probably shouldn't using this as your main client yet. It's missing feeds so you're basically stuck with the chronological Following feed, and refresh token aren't implemented yet meaning you have to constantly log out and re-log in every time you want to use it. 18 + 19 + ## Implemented features 20 + - [X] Logging in (password-based right now) 21 + - [X] Viewing the timeline 22 + - [X] Viewing posts 23 + - [X] Sending posts 24 + - [X] Like, repost, reply, quote, and pin 25 + - [X] Search 26 + - [ ] Mute, block, and report 27 + - [ ] Bookmarks 28 + - [ ] Lists 29 + - [ ] Filters 30 + - [ ] Trending posts 31 + - [ ] Live feed (using Firehose) 32 + - [ ] Notifications 33 + - [ ] Bluesky-specific features 34 + - [ ] Feeds 35 + - [ ] Chats 36 + - [ ] Labels 37 + - [ ] Tenor GIFs 38 + - [ ] Starter packs 39 + - [ ] Moderation stuff 40 + - [ ] Post interaction settings (equivalent to post visibility settings in Mastodon) 41 + - [ ] Multi-language posts (Mastodon only allows one languages on a post) 42 + - [ ] Detaching quotes 43 + - [ ] Mastodon-specific features (implemented through custom lexicons) 44 + - [ ] Polls (probably using [Red Dwarf](https://tangled.org/whey.party/red-dwarf)'s implementation) 45 + - [ ] Custom emojis (using [Bluemoji](https://github.com/aendra-rinisland/bluemoji)) 46 + - [ ] Content warnings 47 + - [ ] Video attachments coexisting with images (and also more than one videos) 48 + - [ ] Client indicators (maybe) 49 + - [ ] Miscellaneous stuff 50 + - [ ] Refresh tokens (i've written code for it but i couldn't figure out how to make it work) 51 + - [ ] Custom appview servers 52 + - [ ] (or alternatively) Fetch from [Constellation](https://constellation.microcosm.blue) and the PDS directly instead of relaying on the appview (ala ActivityPub, probably could reference Red Dwarf's implementation) 53 + - [ ] [Moshidon](https://github.com/LucasGGamerM/moshidon)-specific features (Moshidon is currently based on the pre-UI change Mastodon though it's being rewritten, basing it off on post-UI change Mastodon) 54 + - [ ] [Wafrn](https://wafrn.net)-specific features (Markdown posts, bites, etc.) 55 + - [ ] Support for [Bridgy Fed](https://fed.brid.gy)-specific metadata 56 + - [ ] Proper support for other ActivityPub-based servers (Misskey, Pleroma, etc) and their specific features 57 + 58 + Original readme: 59 + 1 60 Mastodon for Android 2 61 ====================== 3 62
+7 -3
build.gradle
··· 1 1 // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 2 buildscript { 3 - repositories { 3 + ext { 4 + kotlin_version = '2.3.0' 5 + } 6 + repositories { 4 7 google() 5 8 mavenCentral() 6 9 } 7 10 dependencies { 8 11 classpath "com.android.tools.build:gradle:8.2.2" 9 12 classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1" 10 - // NOTE: Do not place your application dependencies here; they belong 13 + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 14 + // NOTE: Do not place your application dependencies here; they belong 11 15 // in the individual module build.gradle files 12 16 } 13 17 } 14 18 15 19 task clean(type: Delete) { 16 20 delete rootProject.buildDir 17 - } 21 + }
+4
mastodon/.gitignore
··· 3 3 /debug 4 4 *.apk 5 5 output-metadata.json 6 + .project 7 + .classpath 8 + .settings/ 9 + bin/
+10 -3
mastodon/build.gradle
··· 1 1 plugins { 2 2 id 'com.android.application' 3 + id 'org.jetbrains.kotlin.android' 3 4 id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' 4 5 } 5 6 ··· 10 11 11 12 compileSdk 35 12 13 defaultConfig { 13 - applicationId "org.joinmastodon.android" 14 + applicationId "org.joinmastodon.android.atproto" 14 15 minSdk 23 15 16 targetSdk 35 16 17 versionCode 158 ··· 78 79 includeInBundle false 79 80 } 80 81 namespace 'org.joinmastodon.android' 82 + kotlinOptions { 83 + jvmTarget = '17' 84 + } 81 85 } 82 86 83 87 dependencies { ··· 98 102 implementation 'com.google.zxing:core:3.5.3' 99 103 implementation 'org.microg:safe-parcel:1.5.0' 100 104 implementation 'org.parceler:parceler-api:1.1.12' 101 - annotationProcessor 'org.parceler:parceler:1.1.12' 105 + implementation 'sh.christian.ozone:bluesky:0.3.3' 106 + implementation 'io.ktor:ktor-client-okhttp:3.4.0' 107 + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2' 108 + annotationProcessor 'org.parceler:parceler:1.1.12' 102 109 coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' 103 110 104 111 def appCenterSdkVersion = "5.0.4" ··· 111 118 androidTestImplementation 'androidx.test.ext:junit:1.1.5' 112 119 androidTestImplementation 'androidx.test:runner:1.5.2' 113 120 androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' 114 - } 121 + }
+118
mastodon/src/main/java/org/joinmastodon/android/MainActivity.java
··· 14 14 import android.widget.Toast; 15 15 16 16 import org.joinmastodon.android.api.ObjectValidationException; 17 + import org.joinmastodon.android.api.MastodonAPIController; 18 + import org.joinmastodon.android.api.atproto.AtProtoClient; 19 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 17 20 import org.joinmastodon.android.api.requests.search.GetSearchResults; 18 21 import org.joinmastodon.android.api.session.AccountSession; 19 22 import org.joinmastodon.android.api.session.AccountSessionManager; 20 23 import org.joinmastodon.android.fragments.AssistContentProviderFragment; 21 24 import org.joinmastodon.android.fragments.ComposeFragment; 25 + import org.joinmastodon.android.fragments.HashtagTimelineFragment; 22 26 import org.joinmastodon.android.fragments.HomeFragment; 23 27 import org.joinmastodon.android.fragments.ProfileFragment; 24 28 import org.joinmastodon.android.fragments.SplashFragment; 25 29 import org.joinmastodon.android.fragments.ThreadFragment; 26 30 import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment; 27 31 import org.joinmastodon.android.model.Notification; 32 + import org.joinmastodon.android.model.Protocol; 28 33 import org.joinmastodon.android.model.SearchResults; 34 + import org.joinmastodon.android.model.Status; 29 35 import org.joinmastodon.android.ui.utils.UiUtils; 30 36 import org.joinmastodon.android.updater.GithubSelfUpdater; 31 37 import org.parceler.Parcels; 32 38 39 + import java.util.Collections; 40 + import java.util.List; 33 41 import java.lang.reflect.InvocationTargetException; 42 + import java.util.stream.Collectors; 34 43 35 44 import androidx.annotation.Nullable; 45 + import app.bsky.actor.ProfileViewDetailed; 46 + import app.bsky.feed.GetPostsResponse; 47 + import app.bsky.feed.SearchPostsResponse; 36 48 import me.grishka.appkit.FragmentStackActivity; 37 49 import me.grishka.appkit.Nav; 38 50 import me.grishka.appkit.api.Callback; 39 51 import me.grishka.appkit.api.ErrorResponse; 52 + import sh.christian.ozone.api.response.AtpResponse; 40 53 41 54 public class MainActivity extends FragmentStackActivity{ 42 55 private static final String TAG="MainActivity"; ··· 111 124 } 112 125 113 126 public void openSearchQuery(String q, String accountID, int progressText, boolean fromSearch, GetSearchResults.Type type){ 127 + if(AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO){ 128 + openSearchQueryATProto(q, accountID, progressText, fromSearch, type); 129 + return; 130 + } 114 131 new GetSearchResults(q, type, true, null, 0, 0) 115 132 .setCallback(new Callback<>(){ 116 133 @Override ··· 135 152 }) 136 153 .wrapProgress(this, progressText, true) 137 154 .exec(accountID); 155 + } 156 + 157 + private void openSearchQueryATProto(String q, String accountID, int progressText, boolean fromSearch, GetSearchResults.Type type){ 158 + if(type==null){ 159 + if(q.contains("/post/")) type=GetSearchResults.Type.STATUSES; 160 + else if(q.contains("/hashtag/") || q.startsWith("#")) type=GetSearchResults.Type.HASHTAGS; 161 + else if(q.contains("/profile/") || q.startsWith("@")) type=GetSearchResults.Type.ACCOUNTS; 162 + else type=GetSearchResults.Type.STATUSES; 163 + } 164 + 165 + if(type==GetSearchResults.Type.ACCOUNTS){ 166 + String handle=q; 167 + if(handle.contains("/profile/")){ 168 + handle=handle.substring(handle.indexOf("/profile/")+9); 169 + if(handle.contains("/")) handle=handle.substring(0, handle.indexOf("/")); 170 + }else if(handle.startsWith("@")){ 171 + handle=handle.substring(1); 172 + } 173 + final String fHandle=handle; 174 + MastodonAPIController.runInBackground(()->{ 175 + try{ 176 + var resp=AccountSessionManager.get(accountID).executeBluesky(client->AtProtoClient.getProfile(client, AtProtoClient.createGetProfileQueryParams(fHandle))); 177 + runOnUiThread(()->{ 178 + if(resp instanceof AtpResponse.Success success){ 179 + Bundle args=new Bundle(); 180 + args.putString("account", accountID); 181 + args.putParcelable("profileAccount", Parcels.wrap(AtProtoMapper.mapAccount((ProfileViewDetailed)success.getResponse()))); 182 + Nav.go(MainActivity.this, ProfileFragment.class, args); 183 + }else{ 184 + Toast.makeText(MainActivity.this, R.string.no_search_results, Toast.LENGTH_SHORT).show(); 185 + } 186 + }); 187 + }catch(Exception x){ 188 + runOnUiThread(()->Toast.makeText(MainActivity.this, x.getLocalizedMessage(), Toast.LENGTH_SHORT).show()); 189 + } 190 + }); 191 + }else if(type==GetSearchResults.Type.HASHTAGS){ 192 + String hashtag=q; 193 + if(hashtag.contains("/hashtag/")){ 194 + hashtag=hashtag.substring(hashtag.indexOf("/hashtag/")+9); 195 + if(hashtag.contains("/")) hashtag=hashtag.substring(0, hashtag.indexOf("/")); 196 + }else if(hashtag.startsWith("#")){ 197 + hashtag=hashtag.substring(1); 198 + } 199 + Bundle args=new Bundle(); 200 + args.putString("account", accountID); 201 + args.putString("hashtagName", hashtag); 202 + Nav.go(MainActivity.this, HashtagTimelineFragment.class, args); 203 + }else if(type==GetSearchResults.Type.STATUSES){ 204 + final String fQ=q; 205 + final boolean isUrl=q.contains("/post/") || q.contains("bsky.app"); 206 + MastodonAPIController.runInBackground(()->{ 207 + try{ 208 + AtpResponse resp; 209 + if(isUrl && fQ.startsWith("http")){ 210 + String atUri=fQ; 211 + try{ 212 + Uri uri=Uri.parse(fQ); 213 + List<String> segments=uri.getPathSegments(); 214 + if(segments.size()>=4 && "profile".equals(segments.get(0)) && "post".equals(segments.get(2))){ 215 + atUri="at://"+segments.get(1)+"/app.bsky.feed.post/"+segments.get(3); 216 + } 217 + }catch(Exception ignore){} 218 + final String fAtUri=atUri; 219 + resp=AccountSessionManager.get(accountID).executeBluesky(client->AtProtoClient.getPosts(client, Collections.singletonList(fAtUri))); 220 + }else{ 221 + resp=AccountSessionManager.get(accountID).executeBluesky(client->{ 222 + var params=AtProtoClient.createSearchPostsQueryParams(fQ, 1L, null); 223 + return AtProtoClient.searchPosts(client, params); 224 + }); 225 + } 226 + 227 + runOnUiThread(()->{ 228 + if(resp instanceof AtpResponse.Success success){ 229 + Object result=success.getResponse(); 230 + List<Status> posts=null; 231 + if(result instanceof GetPostsResponse){ 232 + posts=((GetPostsResponse)result).getPosts().stream().map(AtProtoMapper::mapPost).collect(Collectors.toList()); 233 + }else if(result instanceof SearchPostsResponse){ 234 + posts=AtProtoMapper.mapSearchPosts((SearchPostsResponse)result); 235 + } 236 + 237 + if(posts!=null && !posts.isEmpty()){ 238 + Bundle args=new Bundle(); 239 + args.putString("account", accountID); 240 + args.putParcelable("status", Parcels.wrap(posts.get(0))); 241 + Nav.go(MainActivity.this, ThreadFragment.class, args); 242 + }else{ 243 + Toast.makeText(MainActivity.this, fromSearch ? R.string.no_search_results : R.string.link_not_supported, Toast.LENGTH_SHORT).show(); 244 + } 245 + }else{ 246 + Toast.makeText(MainActivity.this, R.string.no_search_results, Toast.LENGTH_SHORT).show(); 247 + } 248 + }); 249 + }catch(Exception x){ 250 + runOnUiThread(()->Toast.makeText(MainActivity.this, x.getLocalizedMessage(), Toast.LENGTH_SHORT).show()); 251 + } 252 + }); 253 + }else{ 254 + Toast.makeText(MainActivity.this, fromSearch ? R.string.no_search_results : R.string.link_not_supported, Toast.LENGTH_SHORT).show(); 255 + } 138 256 } 139 257 140 258 private void showFragmentForNotification(Notification notification, String accountID){
+134 -3
mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java
··· 7 7 import android.database.sqlite.SQLiteOpenHelper; 8 8 import android.os.Handler; 9 9 import android.os.Looper; 10 + import android.text.TextUtils; 10 11 import android.util.Log; 11 12 12 13 import com.google.gson.reflect.TypeToken; 13 14 14 15 import org.joinmastodon.android.BuildConfig; 15 16 import org.joinmastodon.android.MastodonApp; 17 + import org.joinmastodon.android.api.atproto.AtProtoClient; 18 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 16 19 import org.joinmastodon.android.api.requests.lists.GetLists; 17 20 import org.joinmastodon.android.api.requests.notifications.GetNotificationsV1; 18 21 import org.joinmastodon.android.api.requests.notifications.GetNotificationsV2; 19 22 import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; 23 + import org.joinmastodon.android.api.session.AccountSession; 20 24 import org.joinmastodon.android.api.session.AccountSessionManager; 21 25 import org.joinmastodon.android.model.Account; 22 26 import org.joinmastodon.android.model.CacheablePaginatedResponse; ··· 26 30 import org.joinmastodon.android.model.NotificationGroup; 27 31 import org.joinmastodon.android.model.NotificationType; 28 32 import org.joinmastodon.android.model.PaginatedResponse; 33 + import org.joinmastodon.android.model.Protocol; 29 34 import org.joinmastodon.android.model.SearchResult; 30 35 import org.joinmastodon.android.model.Status; 31 36 import org.joinmastodon.android.model.viewmodel.NotificationViewModel; ··· 40 45 import java.util.List; 41 46 import java.util.Map; 42 47 import java.util.Objects; 48 + import java.util.Set; 43 49 import java.util.function.Consumer; 44 50 import java.util.function.Function; 45 51 import java.util.stream.Collectors; 46 52 53 + import app.bsky.feed.GetPostsResponse; 54 + import app.bsky.feed.GetTimelineQueryParams; 55 + import app.bsky.feed.GetTimelineResponse; 56 + import app.bsky.feed.PostView; 57 + import app.bsky.notification.ListNotificationsNotification; 58 + import app.bsky.notification.ListNotificationsQueryParams; 59 + import app.bsky.notification.ListNotificationsResponse; 47 60 import me.grishka.appkit.api.Callback; 48 61 import me.grishka.appkit.api.ErrorResponse; 49 62 import me.grishka.appkit.utils.WorkerThread; 63 + import sh.christian.ozone.api.response.AtpResponse; 50 64 51 65 public class CacheController{ 52 66 private static final String TAG="CacheController"; ··· 75 89 cancelDelayedClose(); 76 90 databaseThread.postRunnable(()->{ 77 91 try{ 92 + AccountSession session=AccountSessionManager.get(accountID); 78 93 if(!forceReload){ 79 94 SQLiteDatabase db=getOrOpenDatabase(); 80 95 try(Cursor cursor=db.query("home_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`time` DESC", count+"")){ ··· 91 106 result.add(status); 92 107 }while(cursor.moveToNext()); 93 108 String _newMaxID=newMaxID; 94 - AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME); 109 + session.filterStatuses(result, FilterContext.HOME); 95 110 uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true))); 96 111 return; 97 112 } ··· 99 114 Log.w(TAG, "getHomeTimeline: corrupted status object in database", x); 100 115 } 101 116 } 117 + if(session.protocol==Protocol.ATPROTO){ 118 + getHomeTimelineATProto(session, maxID, count, callback); 119 + return; 120 + } 102 121 new GetHomeTimeline(maxID, null, count, null) 103 122 .setCallback(new Callback<>(){ 104 123 @Override ··· 124 143 }, 0); 125 144 } 126 145 146 + private void getHomeTimelineATProto(AccountSession session, String maxID, int count, Callback<CacheablePaginatedResponse<List<Status>>> callback){ 147 + try{ 148 + AtpResponse<GetTimelineResponse> resp = session.executeBluesky((client) -> { 149 + GetTimelineQueryParams params = AtProtoClient.createGetTimelineQueryParams(null, (long)count, maxID); 150 + return AtProtoClient.getTimeline(client, params); 151 + }); 152 + 153 + if (resp instanceof AtpResponse.Success<GetTimelineResponse> success) { 154 + GetTimelineResponse data = success.getResponse(); 155 + List<Status> result=AtProtoMapper.mapTimeline(data); 156 + String nextCursor = AtProtoClient.getCursor(data); 157 + session.filterStatuses(result, FilterContext.HOME); 158 + uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, nextCursor, false))); 159 + putHomeTimeline(result, maxID==null); 160 + } else { 161 + throw new Exception("Failed to fetch timeline: " + resp); 162 + } 163 + }catch(Exception x){ 164 + uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 165 + } 166 + } 167 + 127 168 public void putHomeTimeline(List<Status> posts, boolean clear){ 128 169 runOnDbThread((db)->{ 129 170 if(clear) ··· 226 267 } 227 268 if(!onlyMentions) 228 269 loadingNotifications=true; 229 - if(AccountSessionManager.get(accountID).getInstanceInfo().getApiVersion()>=2){ 270 + AccountSession session=AccountSessionManager.get(accountID); 271 + if(session.protocol==Protocol.ATPROTO){ 272 + getNotificationsATProto(session, maxID, count, onlyMentions, callback); 273 + return; 274 + } 275 + if(session.getInstanceInfo().getApiVersion()>=2){ 230 276 new GetNotificationsV2(maxID, count, onlyMentions ? EnumSet.of(NotificationType.MENTION): EnumSet.allOf(NotificationType.class), NotificationType.getGroupableTypes()) 231 277 .setCallback(new Callback<>(){ 232 278 @Override ··· 332 378 }, 0); 333 379 } 334 380 381 + private void getNotificationsATProto(AccountSession session, String maxID, int count, boolean onlyMentions, Callback<PaginatedResponse<List<NotificationViewModel>>> callback){ 382 + MastodonAPIController.runInBackground(()->{ 383 + try{ 384 + AtpResponse<ListNotificationsResponse> resp=session.executeBluesky((client)->{ 385 + ListNotificationsQueryParams params=AtProtoClient.createListNotificationsQueryParams((long)count, maxID, null); 386 + return AtProtoClient.listNotifications(client, params); 387 + }); 388 + 389 + if(resp instanceof AtpResponse.Success<ListNotificationsResponse> success){ 390 + ListNotificationsResponse data=success.getResponse(); 391 + List<ListNotificationsNotification> bskyNotifications=data.getNotifications(); 392 + if(onlyMentions){ 393 + bskyNotifications=bskyNotifications.stream() 394 + .filter(n->{ 395 + String r=n.getReason().toString(); 396 + return "mention".equalsIgnoreCase(r) || "reply".equalsIgnoreCase(r) || "quote".equalsIgnoreCase(r); 397 + }) 398 + .collect(Collectors.toList()); 399 + } 400 + Set<String> postUris=new HashSet<>(); 401 + for(ListNotificationsNotification n:bskyNotifications){ 402 + String reason=n.getReason().toString(); 403 + if("like".equalsIgnoreCase(reason) || "repost".equalsIgnoreCase(reason)){ 404 + String subject=AtProtoClient.getReasonSubject(n); 405 + if(subject != null) 406 + postUris.add(subject); 407 + }else if("mention".equalsIgnoreCase(reason) || "reply".equalsIgnoreCase(reason) || "quote".equalsIgnoreCase(reason)){ 408 + postUris.add(AtProtoClient.getNotificationUri(n)); 409 + } 410 + } 411 + 412 + Map<String,Status> statuses=new HashMap<>(); 413 + if(!postUris.isEmpty()){ 414 + AtpResponse<GetPostsResponse> postsResp=session.executeBluesky((client)->AtProtoClient.getPosts(client, new ArrayList<>(postUris))); 415 + if(postsResp instanceof AtpResponse.Success<GetPostsResponse> postsSuccess){ 416 + List<PostView> posts=postsSuccess.getResponse().getPosts(); 417 + for(PostView postView:posts){ 418 + Status s=AtProtoMapper.mapPost(postView); 419 + statuses.put(s.uri, s); 420 + } 421 + } 422 + } 423 + 424 + List<NotificationViewModel> notifications=AtProtoMapper.mapNotifications(bskyNotifications, statuses, accountID); 425 + PaginatedResponse<List<NotificationViewModel>> res=new PaginatedResponse<>(notifications, data.getCursor()); 426 + 427 + uiHandler.post(()->{ 428 + callback.onSuccess(res); 429 + if(!onlyMentions){ 430 + loadingNotifications=false; 431 + synchronized(pendingNotificationsCallbacks){ 432 + for(Callback<PaginatedResponse<List<NotificationViewModel>>> cb:pendingNotificationsCallbacks){ 433 + cb.onSuccess(res); 434 + } 435 + pendingNotificationsCallbacks.clear(); 436 + } 437 + } 438 + }); 439 + 440 + databaseThread.postRunnable(()->{ 441 + List<Account> accounts=notifications.stream().flatMap(nvm->nvm.accounts.stream()).collect(Collectors.toList()); 442 + List<Status> statusList=notifications.stream().map(nvm->nvm.status).filter(Objects::nonNull).collect(Collectors.toList()); 443 + putNotifications(notifications.stream().map(nvm->nvm.notification).collect(Collectors.toList()), accounts, statusList, onlyMentions, maxID==null); 444 + },0); 445 + }else{ 446 + throw new Exception("Failed to fetch notifications: "+resp); 447 + } 448 + 449 + }catch(Exception e){ 450 + uiHandler.post(()->{ 451 + callback.onError(new MastodonErrorResponse(e.getLocalizedMessage(), 0, e)); 452 + if(!onlyMentions){ 453 + loadingNotifications=false; 454 + synchronized(pendingNotificationsCallbacks){ 455 + for(Callback<PaginatedResponse<List<NotificationViewModel>>> cb:pendingNotificationsCallbacks) { 456 + cb.onError(new MastodonErrorResponse(e.getLocalizedMessage(), 0, e)); 457 + } 458 + pendingNotificationsCallbacks.clear(); 459 + } 460 + } 461 + }); 462 + } 463 + }); 464 + } 465 + 335 466 private void putNotifications(List<NotificationGroup> notifications, List<Account> accounts, List<Status> statuses, boolean onlyMentions, boolean clear){ 336 467 runOnDbThread((db)->{ 337 468 String suffix=onlyMentions ? "mentions" : "all"; ··· 622 753 )"""); 623 754 } 624 755 } 625 - } 756 + }
+11 -8
mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java
··· 162 162 } 163 163 } 164 164 try(ResponseBody body=response.body()){ 165 - Reader reader=body.charStream(); 166 165 if(response.isSuccessful()){ 167 166 T respObj; 168 167 try{ 169 168 if(BuildConfig.DEBUG){ 170 - JsonElement respJson=JsonParser.parseReader(reader); 169 + String bodyStr=body.string(); 170 + JsonElement respJson=JsonParser.parseString(bodyStr); 171 171 Log.d(TAG, logTag(session)+"response body: "+respJson); 172 172 if(req.respTypeToken!=null) 173 173 respObj=gson.fromJson(respJson, req.respTypeToken.getType()); ··· 177 177 respObj=null; 178 178 }else{ 179 179 if(req.respTypeToken!=null) 180 - respObj=gson.fromJson(reader, req.respTypeToken.getType()); 180 + respObj=gson.fromJson(body.charStream(), req.respTypeToken.getType()); 181 181 else if(req.respClass!=null) 182 - respObj=gson.fromJson(reader, req.respClass); 182 + respObj=gson.fromJson(body.charStream(), req.respClass); 183 183 else 184 184 respObj=null; 185 185 } ··· 204 204 205 205 req.onSuccess(respObj); 206 206 }else{ 207 + String bodyStr=body.string(); 207 208 try{ 208 - JsonObject error=JsonParser.parseReader(reader).getAsJsonObject(); 209 + JsonObject error=JsonParser.parseString(bodyStr).getAsJsonObject(); 209 210 Log.w(TAG, logTag(session)+response+" received error: "+error); 210 211 if(error.has("details")){ 211 - MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.get("error").getAsString(), response.code(), null); 212 + MastodonDetailedErrorResponse err=new MastodonDetailedErrorResponse(error.has("error") ? error.get("error").getAsString() : response.message(), response.code(), null); 212 213 HashMap<String, List<MastodonDetailedErrorResponse.FieldError>> details=new HashMap<>(); 213 214 JsonObject errorDetails=error.getAsJsonObject("details"); 214 215 for(String key:errorDetails.keySet()){ ··· 225 226 err.detailedErrors=details; 226 227 req.onError(err); 227 228 }else{ 228 - req.onError(error.get("error").getAsString(), response.code(), null); 229 + req.onError(error.has("error") ? error.get("error").getAsString() : response.message(), response.code(), null); 229 230 } 230 - }catch(JsonIOException|JsonSyntaxException x){ 231 + }catch(JsonIOException|JsonSyntaxException|IllegalStateException x){ 232 + Log.w(TAG, logTag(session)+response+" error parsing error body: "+bodyStr, x); 231 233 req.onError(response.code()+" "+response.message(), response.code(), x); 232 234 }catch(Exception x){ 235 + Log.e(TAG, logTag(session)+response+" Error parsing an API error. Body: "+bodyStr, x); 233 236 req.onError("Error parsing an API error", response.code(), x); 234 237 } 235 238 }
+190 -1
mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java
··· 1 1 package org.joinmastodon.android.api; 2 2 3 + import android.os.Handler; 3 4 import android.os.Looper; 5 + import android.widget.Toast; 6 + 7 + import com.atproto.repo.CreateRecordRequest; 8 + import com.atproto.repo.CreateRecordResponse; 9 + import com.atproto.repo.DeleteRecordRequest; 10 + import com.atproto.repo.GetRecordQueryParams; 11 + import com.atproto.repo.GetRecordResponse; 12 + import com.atproto.repo.PutRecordRequest; 13 + import com.atproto.repo.StrongRef; 4 14 5 15 import org.joinmastodon.android.E; 6 16 import org.joinmastodon.android.MastodonApp; 17 + import org.joinmastodon.android.api.atproto.AtProtoClient; 7 18 import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked; 8 19 import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited; 20 + import org.joinmastodon.android.api.requests.statuses.SetStatusPinned; 9 21 import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged; 22 + import org.joinmastodon.android.api.session.AccountSession; 23 + import org.joinmastodon.android.api.session.AccountSessionManager; 10 24 import org.joinmastodon.android.events.StatusCountersUpdatedEvent; 25 + import org.joinmastodon.android.events.StatusUpdatedEvent; 26 + import org.joinmastodon.android.model.Protocol; 11 27 import org.joinmastodon.android.model.Status; 12 28 29 + import java.time.Instant; 13 30 import java.util.HashMap; 14 31 32 + import app.bsky.actor.Profile; 33 + import app.bsky.feed.Like; 34 + import app.bsky.feed.Repost; 35 + import kotlinx.datetime.Clock; 15 36 import me.grishka.appkit.api.Callback; 16 37 import me.grishka.appkit.api.ErrorResponse; 38 + import sh.christian.ozone.api.response.AtpResponse; 17 39 18 40 public class StatusInteractionController{ 19 41 private final String accountID; ··· 29 51 if(!Looper.getMainLooper().isCurrentThread()) 30 52 throw new IllegalStateException("Can only be called from main thread"); 31 53 54 + AccountSession session=AccountSessionManager.get(accountID); 55 + if(session.protocol==Protocol.ATPROTO){ 56 + setFavoritedAtProto(session, status, favorited); 57 + return; 58 + } 59 + 32 60 SetStatusFavorited current=runningFavoriteRequests.remove(status.id); 33 61 if(current!=null){ 34 62 current.cancel(); ··· 66 94 public void setReblogged(Status status, boolean reblogged){ 67 95 if(!Looper.getMainLooper().isCurrentThread()) 68 96 throw new IllegalStateException("Can only be called from main thread"); 97 + 98 + AccountSession session=AccountSessionManager.get(accountID); 99 + if(session.protocol==Protocol.ATPROTO){ 100 + setRebloggedAtProto(session, status, reblogged); 101 + return; 102 + } 69 103 70 104 SetStatusReblogged current=runningReblogRequests.remove(status.id); 71 105 if(current!=null){ ··· 130 164 status.bookmarked=bookmarked; 131 165 E.post(new StatusCountersUpdatedEvent(status, StatusCountersUpdatedEvent.CounterType.BOOKMARKS)); 132 166 } 133 - } 167 + 168 + public void setPinned(Status status, boolean pinned){ 169 + if(!Looper.getMainLooper().isCurrentThread()) 170 + throw new IllegalStateException("Can only be called from main thread"); 171 + 172 + AccountSession session=AccountSessionManager.get(accountID); 173 + if(session.protocol==Protocol.ATPROTO){ 174 + setPinnedAtProto(session, status, pinned); 175 + return; 176 + } 177 + 178 + new SetStatusPinned(status.id, pinned) 179 + .setCallback(new Callback<>(){ 180 + @Override 181 + public void onSuccess(Status result){ 182 + status.pinned=pinned; 183 + StatusUpdatedEvent ev=new StatusUpdatedEvent(status); 184 + E.post(ev); 185 + } 186 + 187 + @Override 188 + public void onError(ErrorResponse error){ 189 + error.showToast(MastodonApp.context); 190 + } 191 + }) 192 + .exec(accountID); 193 + } 194 + 195 + private void setFavoritedAtProto(AccountSession session, Status status, boolean favorited){ 196 + status.favourited=favorited; 197 + if(favorited) 198 + status.favouritesCount++; 199 + else 200 + status.favouritesCount--; 201 + E.post(new StatusCountersUpdatedEvent(status, StatusCountersUpdatedEvent.CounterType.FAVORITES)); 202 + 203 + MastodonAPIController.runInBackground(()->{ 204 + try{ 205 + if(favorited){ 206 + AtpResponse<CreateRecordResponse> resp=session.executeBluesky((client)->{ 207 + StrongRef ref=AtProtoClient.createStrongRef(status.uri, status.cid); 208 + Like record=new Like(ref, Clock.System.INSTANCE.now()); 209 + CreateRecordRequest request=AtProtoClient.createCreateRecordRequest(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.feed.like", AtProtoClient.toLikeJson(record)); 210 + return AtProtoClient.createRecord(client, request); 211 + }); 212 + if(resp instanceof AtpResponse.Success<CreateRecordResponse> success){ 213 + status.atProtoLikeUri=AtProtoClient.getUri(success.getResponse()); 214 + }else{ 215 + throw new Exception("Failed to like: "+resp); 216 + } 217 + }else{ 218 + if(status.atProtoLikeUri!=null){ 219 + session.executeBluesky((client)->{ 220 + DeleteRecordRequest request=AtProtoClient.createDeleteRecordRequest(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.feed.like", AtProtoClient.getRkey(AtProtoClient.createAtUri(status.atProtoLikeUri))); 221 + return AtProtoClient.deleteRecord(client, request); 222 + }); 223 + status.atProtoLikeUri=null; 224 + } 225 + } 226 + }catch(Exception e){ 227 + Handler uiHandler=new Handler(Looper.getMainLooper()); 228 + uiHandler.post(()->{ 229 + status.favourited=!favorited; 230 + if(favorited) 231 + status.favouritesCount--; 232 + else 233 + status.favouritesCount++; 234 + E.post(new StatusCountersUpdatedEvent(status, StatusCountersUpdatedEvent.CounterType.FAVORITES)); 235 + Toast.makeText(MastodonApp.context, "Action failed: "+e.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); 236 + }); 237 + } 238 + }); 239 + } 240 + 241 + private void setRebloggedAtProto(AccountSession session, Status status, boolean reblogged){ 242 + status.reblogged=reblogged; 243 + if(reblogged) 244 + status.reblogsCount++; 245 + else 246 + status.reblogsCount--; 247 + E.post(new StatusCountersUpdatedEvent(status, StatusCountersUpdatedEvent.CounterType.REBLOGS)); 248 + 249 + MastodonAPIController.runInBackground(()->{ 250 + try{ 251 + if(reblogged){ 252 + AtpResponse<CreateRecordResponse> resp=session.executeBluesky((client)->{ 253 + StrongRef ref=AtProtoClient.createStrongRef(status.uri, status.cid); 254 + Repost record = new Repost(ref, Clock.System.INSTANCE.now()); 255 + CreateRecordRequest request = AtProtoClient.createCreateRecordRequest(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.feed.repost", AtProtoClient.toRepostJson(record)); 256 + return AtProtoClient.createRecord(client, request); 257 + }); 258 + if (resp instanceof AtpResponse.Success<CreateRecordResponse> success) { 259 + status.atProtoRepostUri=AtProtoClient.getUri(success.getResponse()); 260 + } else { 261 + throw new Exception("Failed to repost: " + resp); 262 + } 263 + }else{ 264 + if(status.atProtoRepostUri!=null){ 265 + session.executeBluesky((client)->{ 266 + DeleteRecordRequest request = AtProtoClient.createDeleteRecordRequest(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.feed.repost", AtProtoClient.getRkey(AtProtoClient.createAtUri(status.atProtoRepostUri))); 267 + return AtProtoClient.deleteRecord(client, request); 268 + }); 269 + status.atProtoRepostUri=null; 270 + } 271 + } 272 + }catch(Exception e){ 273 + Handler uiHandler=new Handler(Looper.getMainLooper()); 274 + uiHandler.post(()->{ 275 + status.reblogged=!reblogged; 276 + if(reblogged) 277 + status.reblogsCount--; 278 + else 279 + status.reblogsCount++; 280 + E.post(new StatusCountersUpdatedEvent(status, StatusCountersUpdatedEvent.CounterType.REBLOGS)); 281 + Toast.makeText(MastodonApp.context, "Action failed: "+e.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); 282 + }); 283 + } 284 + }); 285 + } 286 + 287 + private void setPinnedAtProto(AccountSession session, Status status, boolean pinned){ 288 + status.pinned=pinned; 289 + E.post(new StatusUpdatedEvent(status)); 290 + 291 + MastodonAPIController.runInBackground(()->{ 292 + try{ 293 + AtpResponse<GetRecordResponse> getResp = session.executeBluesky((client)-> { 294 + GetRecordQueryParams params = AtProtoClient.createGetRecordQueryParams(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.actor.profile", "self"); 295 + return AtProtoClient.getRecord(client, params); 296 + }); 297 + 298 + if (getResp instanceof AtpResponse.Success<GetRecordResponse> success) { 299 + Profile record = AtProtoClient.getProfileRecord(success.getResponse()); 300 + StrongRef pinnedPost = pinned ? AtProtoClient.createStrongRef(status.uri, status.cid) : null; 301 + 302 + Profile newRecord = AtProtoClient.createProfile(record.getDisplayName(), record.getDescription(), record.getAvatar(), record.getBanner(), record.getLabels(), pinnedPost, record.getCreatedAt()); 303 + 304 + session.executeBluesky((client)-> { 305 + PutRecordRequest params = AtProtoClient.createPutRecordRequest(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.actor.profile", "self", AtProtoClient.toProfileJson(newRecord)); 306 + return AtProtoClient.putRecord(client, params); 307 + }); 308 + 309 + session.self.pinnedPostUri=pinned ? status.uri : null; 310 + } else { 311 + throw new Exception("Failed to get profile record: " + getResp); 312 + } 313 + }catch(Exception e){ 314 + new Handler(Looper.getMainLooper()).post(()->{ 315 + status.pinned=!pinned; 316 + E.post(new StatusUpdatedEvent(status)); 317 + Toast.makeText(MastodonApp.context, "Action failed: "+e.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); 318 + }); 319 + } 320 + }); 321 + } 322 + }
+528
mastodon/src/main/java/org/joinmastodon/android/api/atproto/AtProtoClient.kt
··· 1 + package org.joinmastodon.android.api.atproto 2 + 3 + import io.ktor.client.HttpClient 4 + import io.ktor.client.engine.okhttp.OkHttp 5 + import io.ktor.client.plugins.DefaultRequest 6 + import io.ktor.http.takeFrom 7 + import sh.christian.ozone.BlueskyJson 8 + import sh.christian.ozone.XrpcBlueskyApi 9 + import sh.christian.ozone.api.AuthenticatedXrpcBlueskyApi 10 + import sh.christian.ozone.api.BlueskyAuthPlugin 11 + import sh.christian.ozone.api.AtUri 12 + import sh.christian.ozone.api.Did 13 + import sh.christian.ozone.api.Handle 14 + import sh.christian.ozone.api.Language 15 + import sh.christian.ozone.api.Nsid 16 + import sh.christian.ozone.api.Cid 17 + import sh.christian.ozone.api.Uri 18 + import sh.christian.ozone.api.RKey 19 + import sh.christian.ozone.api.AtIdentifier 20 + import sh.christian.ozone.api.response.AtpResponse 21 + import sh.christian.ozone.api.model.Blob 22 + import sh.christian.ozone.api.model.JsonContent 23 + import com.atproto.repo.* 24 + import com.atproto.server.* 25 + import app.bsky.actor.* 26 + import app.bsky.embed.* 27 + import app.bsky.feed.* 28 + import app.bsky.graph.* 29 + import app.bsky.notification.* 30 + import app.bsky.richtext.* 31 + import kotlinx.datetime.Instant 32 + import kotlinx.coroutines.runBlocking 33 + import kotlinx.datetime.Clock 34 + 35 + object AtProtoClient { 36 + @JvmStatic 37 + fun createHttpClient(host: String): HttpClient = HttpClient(OkHttp) { 38 + install(DefaultRequest) { 39 + url.takeFrom(if (host.startsWith("http")) host else "https://$host") 40 + } 41 + } 42 + 43 + @JvmStatic 44 + fun createXrpcBlueskyApi(client: HttpClient): XrpcBlueskyApi = XrpcBlueskyApi(client) 45 + 46 + @JvmStatic 47 + fun createAuthenticatedXrpcBlueskyApi( 48 + client: HttpClient, 49 + accessToken: String, 50 + refreshToken: String 51 + ): AuthenticatedXrpcBlueskyApi { 52 + val tokens = BlueskyAuthPlugin.Tokens(accessToken, refreshToken) 53 + return AuthenticatedXrpcBlueskyApi(client, tokens) 54 + } 55 + 56 + @JvmStatic 57 + fun createAtUri(uri: String): String = AtUri(uri).atUri 58 + 59 + @JvmStatic 60 + fun getAtUri(uri: Any): String = when (uri) { 61 + is AtUri -> uri.atUri 62 + is String -> uri 63 + else -> uri.toString() 64 + } 65 + 66 + @JvmStatic 67 + fun getRkey(uri: String): String = AtUri(uri).atUri.substringAfterLast('/') 68 + 69 + @JvmStatic 70 + fun createAtIdentifier(id: String): AtIdentifier = if (id.startsWith("did:")) { 71 + Did(id) 72 + } else { 73 + Handle(id) 74 + } 75 + 76 + @JvmStatic 77 + fun createStrongRef(uri: String, cid: String): StrongRef = StrongRef(AtUri(uri), Cid(cid)) 78 + 79 + @JvmStatic 80 + fun createFacetLink(uri: String): FacetLink = FacetLink(Uri(uri)) 81 + 82 + @JvmStatic 83 + fun createFacetTag(tag: String): FacetTag = FacetTag(tag) 84 + 85 + @JvmStatic 86 + fun createFacetMention(did: String): FacetMention = FacetMention(Did(did)) 87 + 88 + @JvmStatic 89 + fun createFacetFeatureLink(link: FacetLink): FacetFeatureUnion = FacetFeatureUnion.Link(link) 90 + 91 + @JvmStatic 92 + fun createFacetFeatureTag(tag: FacetTag): FacetFeatureUnion = FacetFeatureUnion.Tag(tag) 93 + 94 + @JvmStatic 95 + fun createFacetFeatureMention(mention: FacetMention): FacetFeatureUnion = FacetFeatureUnion.Mention(mention) 96 + 97 + @JvmStatic 98 + fun createFollow(subject: String, createdAt: Instant): Follow = Follow( 99 + subject = Did(subject), 100 + createdAt = createdAt 101 + ) 102 + 103 + @JvmStatic 104 + fun getDid(profile: ProfileViewDetailed): String = profile.did.did 105 + 106 + @JvmStatic 107 + fun getHandle(profile: ProfileViewDetailed): String = profile.handle.handle 108 + 109 + @JvmStatic 110 + fun getAvatar(profile: ProfileViewDetailed): String? = profile.avatar?.uri 111 + 112 + @JvmStatic 113 + fun getBanner(profile: ProfileViewDetailed): String? = profile.banner?.uri 114 + 115 + @JvmStatic 116 + fun getFollowersCount(profile: ProfileViewDetailed): Long = profile.followersCount ?: 0 117 + 118 + @JvmStatic 119 + fun getFollowsCount(profile: ProfileViewDetailed): Long = profile.followsCount ?: 0 120 + 121 + @JvmStatic 122 + fun getPostsCount(profile: ProfileViewDetailed): Long = profile.postsCount ?: 0 123 + 124 + @JvmStatic 125 + fun getDescription(profile: ProfileViewDetailed): String? = profile.description 126 + 127 + @JvmStatic 128 + fun getViewer(profile: ProfileViewDetailed): app.bsky.actor.ViewerState? = profile.viewer 129 + 130 + @JvmStatic 131 + fun getFollowing(viewer: app.bsky.actor.ViewerState): String? = viewer.following?.atUri 132 + 133 + @JvmStatic 134 + fun getFollowedBy(viewer: app.bsky.actor.ViewerState): String? = viewer.followedBy?.atUri 135 + 136 + @JvmStatic 137 + fun getBlocking(viewer: app.bsky.actor.ViewerState): String? = viewer.blocking?.atUri 138 + 139 + @JvmStatic 140 + fun getMuted(viewer: app.bsky.actor.ViewerState): Boolean = viewer.muted ?: false 141 + 142 + @JvmStatic 143 + fun getUri(response: CreateRecordResponse): String = response.uri.atUri 144 + 145 + @JvmStatic 146 + fun getCid(response: CreateRecordResponse): String = response.cid.cid 147 + 148 + @JvmStatic 149 + fun getDid(view: ProfileView): String = view.did.did 150 + 151 + @JvmStatic 152 + fun getHandle(view: ProfileView): String = view.handle.handle 153 + 154 + @JvmStatic 155 + fun getDid(view: ProfileViewBasic): String = view.did.did 156 + 157 + @JvmStatic 158 + fun getHandle(view: ProfileViewBasic): String = view.handle.handle 159 + 160 + @JvmStatic 161 + fun getReasonSubject(n: ListNotificationsNotification): String? = n.reasonSubject?.atUri 162 + 163 + @JvmStatic 164 + fun getNotificationUri(n: ListNotificationsNotification): String = n.uri.atUri 165 + 166 + @JvmStatic 167 + fun getAccessJwt(response: CreateSessionResponse): String = response.accessJwt 168 + 169 + @JvmStatic 170 + fun getRefreshJwt(response: CreateSessionResponse): String = response.refreshJwt 171 + 172 + @JvmStatic 173 + fun getDid(response: CreateSessionResponse): String = response.did.did 174 + 175 + @JvmStatic 176 + fun getHandle(response: CreateSessionResponse): String = response.handle.handle 177 + 178 + @JvmStatic 179 + fun getMessage(failure: AtpResponse.Failure<*>): String? = failure.error?.message 180 + 181 + @JvmStatic 182 + fun getBlob(response: UploadBlobResponse): Blob = response.blob 183 + 184 + @JvmStatic 185 + fun getBlobLink(blob: Blob): String? = when (blob) { 186 + is Blob.StandardBlob -> blob.ref.link.cid 187 + is Blob.LegacyBlob -> blob.cid 188 + } 189 + 190 + @JvmStatic 191 + fun getPrivacyPolicy(links: DescribeServerLinks): String? = links.privacyPolicy?.uri 192 + 193 + @JvmStatic 194 + fun getTermsOfService(links: DescribeServerLinks): String? = links.termsOfService?.uri 195 + 196 + @JvmStatic 197 + fun getProfileRecord(response: GetRecordResponse): Profile = BlueskyJson.decodeFromJsonElement(Profile.serializer(), response.value.value) 198 + 199 + @JvmStatic 200 + fun createProfile( 201 + displayName: String?, 202 + description: String?, 203 + avatar: Blob?, 204 + banner: Blob?, 205 + labels: ProfileLabelsUnion?, 206 + pinnedPost: StrongRef?, 207 + createdAt: Instant 208 + ): Profile = Profile( 209 + displayName = displayName, 210 + description = description, 211 + avatar = avatar, 212 + banner = banner, 213 + labels = labels, 214 + pinnedPost = pinnedPost, 215 + createdAt = createdAt 216 + ) 217 + 218 + @JvmStatic 219 + @JvmOverloads 220 + fun createPost( 221 + text: String, 222 + facets: List<Facet>? = null, 223 + reply: PostReplyRef? = null, 224 + embed: PostEmbedUnion? = null, 225 + langs: List<String>? = null, 226 + createdAt: Instant = Clock.System.now(), 227 + ): Post = Post( 228 + text = text, 229 + facets = facets ?: emptyList(), 230 + reply = reply, 231 + embed = embed, 232 + langs = langs?.map { Language(it) } ?: emptyList(), 233 + createdAt = createdAt 234 + ) 235 + 236 + @JvmStatic 237 + fun createPostReplyRef(root: StrongRef, parent: StrongRef): PostReplyRef = PostReplyRef(root, parent) 238 + 239 + @JvmStatic 240 + fun createAspectRatio(width: Long, height: Long) = AspectRatio(width, height) 241 + 242 + @JvmStatic 243 + fun createPostEmbedImages(images: Images): PostEmbedUnion = PostEmbedUnion.Images(images) 244 + 245 + @JvmStatic 246 + fun createPostEmbedVideo(video: Video): PostEmbedUnion = PostEmbedUnion.Video(video) 247 + 248 + @JvmStatic 249 + fun createPostEmbedRecord(record: Record): PostEmbedUnion = PostEmbedUnion.Record(record) 250 + 251 + @JvmStatic 252 + fun createPostEmbedRecordWithMedia(recordWithMedia: RecordWithMedia): PostEmbedUnion = PostEmbedUnion.RecordWithMedia(recordWithMedia) 253 + 254 + @JvmStatic 255 + fun createEmbedImages(images: List<ImagesImage>): Images = Images(images) 256 + 257 + @JvmStatic 258 + fun createEmbedImagesImage(blob: Blob, alt: String?, aspectRatio: AspectRatio?): ImagesImage = ImagesImage(blob, alt ?: "", aspectRatio) 259 + 260 + @JvmStatic 261 + fun createEmbedVideo(blob: Blob, alt: String?, aspectRatio: AspectRatio?): Video = Video(blob, emptyList(), alt, aspectRatio) 262 + 263 + @JvmStatic 264 + fun createEmbedRecord(ref: StrongRef): Record = Record(ref) 265 + 266 + @JvmStatic 267 + fun createEmbedRecordWithMedia(record: Record, media: RecordWithMediaMediaUnion): RecordWithMedia = RecordWithMedia(record, media) 268 + 269 + @JvmStatic 270 + fun createEmbedRecordWithMediaMediaImages(images: Images): RecordWithMediaMediaUnion = RecordWithMediaMediaUnion.Images(images) 271 + 272 + @JvmStatic 273 + fun createEmbedRecordWithMediaMediaVideo(video: Video): RecordWithMediaMediaUnion = RecordWithMediaMediaUnion.Video(video) 274 + 275 + @JvmStatic 276 + fun uploadBlob(client: AuthenticatedXrpcBlueskyApi, bytes: ByteArray): AtpResponse<UploadBlobResponse> = runBlocking { 277 + client.uploadBlob(bytes) 278 + } 279 + 280 + @JvmStatic 281 + fun toLikeJson(like: Like): JsonContent = JsonContent(BlueskyJson.encodeToJsonElement(Like.serializer(), like), BlueskyJson) 282 + 283 + @JvmStatic 284 + fun toRepostJson(repost: Repost): JsonContent = JsonContent(BlueskyJson.encodeToJsonElement(Repost.serializer(), repost), BlueskyJson) 285 + 286 + @JvmStatic 287 + fun toFollowJson(follow: Follow): JsonContent = JsonContent(BlueskyJson.encodeToJsonElement(Follow.serializer(), follow), BlueskyJson) 288 + 289 + @JvmStatic 290 + fun toProfileJson(profile: Profile): JsonContent = JsonContent(BlueskyJson.encodeToJsonElement(Profile.serializer(), profile), BlueskyJson) 291 + 292 + @JvmStatic 293 + fun toPostJson(post: Post): JsonContent = JsonContent(BlueskyJson.encodeToJsonElement(Post.serializer(), post), BlueskyJson) 294 + 295 + @JvmStatic 296 + fun createGetRecordQueryParams(repo: AtIdentifier, collection: String, rkey: String): GetRecordQueryParams = GetRecordQueryParams(repo, Nsid(collection), RKey(rkey)) 297 + 298 + @JvmStatic 299 + fun createPutRecordRequest(repo: AtIdentifier, collection: String, rkey: String, record: JsonContent): PutRecordRequest = PutRecordRequest( 300 + repo = repo, 301 + collection = Nsid(collection), 302 + rkey = RKey(rkey), 303 + record = record 304 + ) 305 + 306 + @JvmStatic 307 + fun createCreateRecordRequest(repo: AtIdentifier, collection: String, record: JsonContent): CreateRecordRequest =CreateRecordRequest( 308 + repo = repo, 309 + collection = Nsid(collection), 310 + record = record 311 + ) 312 + 313 + @JvmStatic 314 + fun createDeleteRecordRequest(repo: AtIdentifier, collection: String, rkey: String): DeleteRecordRequest = DeleteRecordRequest(repo, Nsid(collection), RKey(rkey)) 315 + 316 + @JvmStatic 317 + fun createGetPostThreadQueryParams(uri: String, depth: Long?, parentHeight: Long?): GetPostThreadQueryParams = GetPostThreadQueryParams(AtUri(uri), depth, parentHeight) 318 + 319 + @JvmStatic 320 + fun createGetAuthorFeedQueryParams(actor: String, limit: Long?, cursor: String?, filter: String?, includePins: Boolean?): GetAuthorFeedQueryParams { 321 + val f = when (filter) { 322 + "posts_with_replies" -> GetAuthorFeedFilter.PostsWithReplies 323 + "posts_no_replies" -> GetAuthorFeedFilter.PostsNoReplies 324 + "posts_with_media" -> GetAuthorFeedFilter.PostsWithMedia 325 + "posts_with_video" -> GetAuthorFeedFilter.PostsWithVideo 326 + "posts_and_author_threads" -> GetAuthorFeedFilter.PostsAndAuthorThreads 327 + else -> null 328 + } 329 + return GetAuthorFeedQueryParams(createAtIdentifier(actor), limit, cursor, f, includePins) 330 + } 331 + 332 + @JvmStatic 333 + fun createGetTimelineQueryParams(algorithm: String?, limit: Long?, cursor: String?): GetTimelineQueryParams = GetTimelineQueryParams(algorithm, limit, cursor) 334 + 335 + @JvmStatic 336 + fun createListNotificationsQueryParams(limit: Long?, cursor: String?, seenAt: Instant?): ListNotificationsQueryParams = ListNotificationsQueryParams( 337 + reasons = emptyList(), 338 + limit = limit, 339 + priority = null, 340 + cursor = cursor, 341 + seenAt = seenAt 342 + ) 343 + 344 + @JvmStatic 345 + fun createGetFollowersQueryParams(actor: String, limit: Long?, cursor: String?): GetFollowersQueryParams = GetFollowersQueryParams(createAtIdentifier(actor), limit, cursor) 346 + 347 + @JvmStatic 348 + fun createGetFollowsQueryParams(actor: String, limit: Long?, cursor: String?): GetFollowsQueryParams = GetFollowsQueryParams(createAtIdentifier(actor), limit, cursor) 349 + 350 + @JvmStatic 351 + fun createGetProfileQueryParams(actor: String): GetProfileQueryParams = GetProfileQueryParams(createAtIdentifier(actor)) 352 + 353 + @JvmStatic 354 + fun createGetLikesQueryParams(uri: String, cid: String?, limit: Long?, cursor: String?): GetLikesQueryParams = GetLikesQueryParams(AtUri(uri), cid?.let { Cid(it) }, limit, cursor) 355 + 356 + @JvmStatic 357 + fun createGetRepostedByQueryParams(uri: String, cid: String?, limit: Long?, cursor: String?): GetRepostedByQueryParams = GetRepostedByQueryParams(AtUri(uri), cid?.let { Cid(it) }, limit, cursor) 358 + 359 + @JvmStatic 360 + fun createGetQuotesQueryParams(uri: String, cid: String?, limit: Long?, cursor: String?): GetQuotesQueryParams = GetQuotesQueryParams(AtUri(uri), cid?.let { Cid(it) }, limit, cursor) 361 + 362 + @JvmStatic 363 + fun getCursor(response: GetTimelineResponse): String? = response.cursor 364 + 365 + @JvmStatic 366 + fun getCursor(response: GetAuthorFeedResponse): String? = response.cursor 367 + 368 + @JvmStatic 369 + fun getCursor(response: ListNotificationsResponse): String? = response.cursor 370 + 371 + @JvmStatic 372 + fun getCursor(response: GetFollowersResponse): String? = response.cursor 373 + 374 + @JvmStatic 375 + fun getCursor(response: GetFollowsResponse): String? = response.cursor 376 + 377 + @JvmStatic 378 + fun getCursor(response: GetLikesResponse): String? = response.cursor 379 + 380 + @JvmStatic 381 + fun getCursor(response: GetRepostedByResponse): String? = response.cursor 382 + 383 + @JvmStatic 384 + fun getCursor(response: GetQuotesResponse): String? = response.cursor 385 + 386 + // Suspend function wrappers for Java 387 + 388 + @JvmStatic 389 + fun createRecord(client: AuthenticatedXrpcBlueskyApi, request: CreateRecordRequest): AtpResponse<CreateRecordResponse> = runBlocking { 390 + client.createRecord(request) 391 + } 392 + 393 + @JvmStatic 394 + fun deleteRecord(client: AuthenticatedXrpcBlueskyApi, request: DeleteRecordRequest): AtpResponse<DeleteRecordResponse> = runBlocking { 395 + client.deleteRecord(request) 396 + } 397 + 398 + @JvmStatic 399 + fun getRecord(client: AuthenticatedXrpcBlueskyApi, params: GetRecordQueryParams): AtpResponse<GetRecordResponse> = runBlocking { 400 + client.getRecord(params) 401 + } 402 + 403 + @JvmStatic 404 + fun putRecord(client: AuthenticatedXrpcBlueskyApi, request: PutRecordRequest): AtpResponse<PutRecordResponse> = runBlocking { 405 + client.putRecord(request) 406 + } 407 + 408 + @JvmStatic 409 + fun getProfile(client: AuthenticatedXrpcBlueskyApi, params: GetProfileQueryParams): AtpResponse<ProfileViewDetailed> = runBlocking { 410 + client.getProfile(params) 411 + } 412 + 413 + @JvmStatic 414 + fun getAuthorFeed(client: AuthenticatedXrpcBlueskyApi, params: GetAuthorFeedQueryParams): AtpResponse<GetAuthorFeedResponse> = runBlocking { 415 + client.getAuthorFeed(params) 416 + } 417 + 418 + @JvmStatic 419 + fun getTimeline(client: AuthenticatedXrpcBlueskyApi, params: GetTimelineQueryParams): AtpResponse<GetTimelineResponse> = runBlocking { 420 + client.getTimeline(params) 421 + } 422 + 423 + @JvmStatic 424 + fun getPostThread(client: AuthenticatedXrpcBlueskyApi, params: GetPostThreadQueryParams): AtpResponse<GetPostThreadResponse> = runBlocking { 425 + client.getPostThread(params) 426 + } 427 + 428 + @JvmStatic 429 + fun getPosts(client: AuthenticatedXrpcBlueskyApi, params: GetPostsQueryParams): AtpResponse<GetPostsResponse> = runBlocking { 430 + client.getPosts(params) 431 + } 432 + 433 + @JvmStatic 434 + fun getPosts(client: AuthenticatedXrpcBlueskyApi, uris: List<String>): AtpResponse<GetPostsResponse> = runBlocking { 435 + client.getPosts(GetPostsQueryParams(uris.map { AtUri(it) })) 436 + } 437 + 438 + @JvmStatic 439 + fun getPostsList(response: GetPostsResponse): List<PostView> = response.posts 440 + 441 + @JvmStatic 442 + fun getFollowers(client: AuthenticatedXrpcBlueskyApi, params: GetFollowersQueryParams): AtpResponse<GetFollowersResponse> = runBlocking { 443 + client.getFollowers(params) 444 + } 445 + 446 + @JvmStatic 447 + fun getFollows(client: AuthenticatedXrpcBlueskyApi, params: GetFollowsQueryParams): AtpResponse<GetFollowsResponse> = runBlocking { 448 + client.getFollows(params) 449 + } 450 + 451 + @JvmStatic 452 + fun getLikes(client: AuthenticatedXrpcBlueskyApi, params: GetLikesQueryParams): AtpResponse<GetLikesResponse> = runBlocking { 453 + client.getLikes(params) 454 + } 455 + 456 + @JvmStatic 457 + fun getRepostedBy(client: AuthenticatedXrpcBlueskyApi, params: GetRepostedByQueryParams): AtpResponse<GetRepostedByResponse> = runBlocking { 458 + client.getRepostedBy(params) 459 + } 460 + 461 + @JvmStatic 462 + fun getQuotes(client: AuthenticatedXrpcBlueskyApi, params: GetQuotesQueryParams): AtpResponse<GetQuotesResponse> = runBlocking { 463 + client.getQuotes(params) 464 + } 465 + 466 + @JvmStatic 467 + fun getUnreadCount(client: AuthenticatedXrpcBlueskyApi, params: GetUnreadCountQueryParams): AtpResponse<GetUnreadCountResponse> = runBlocking { 468 + client.getUnreadCount(params) 469 + } 470 + 471 + @JvmStatic 472 + fun updateSeen(client: AuthenticatedXrpcBlueskyApi, request: UpdateSeenRequest): AtpResponse<Unit> = runBlocking { 473 + client.updateSeen(request) 474 + } 475 + 476 + @JvmStatic 477 + fun listNotifications(client: AuthenticatedXrpcBlueskyApi, params: ListNotificationsQueryParams): AtpResponse<ListNotificationsResponse> = runBlocking { 478 + client.listNotifications(params) 479 + } 480 + 481 + @JvmStatic 482 + fun describeServer(client: AuthenticatedXrpcBlueskyApi): AtpResponse<DescribeServerResponse> = runBlocking { 483 + client.describeServer() 484 + } 485 + 486 + @JvmStatic 487 + fun searchActors(client: AuthenticatedXrpcBlueskyApi, params: SearchActorsQueryParams): AtpResponse<SearchActorsResponse> = runBlocking { 488 + client.searchActors(params) 489 + } 490 + 491 + @JvmStatic 492 + fun searchActorsTypeahead(client: AuthenticatedXrpcBlueskyApi, params: SearchActorsTypeaheadQueryParams): AtpResponse<SearchActorsTypeaheadResponse> = runBlocking { 493 + client.searchActorsTypeahead(params) 494 + } 495 + 496 + @JvmStatic 497 + fun searchPosts(client: AuthenticatedXrpcBlueskyApi, params: SearchPostsQueryParams): AtpResponse<SearchPostsResponse> = runBlocking { 498 + client.searchPosts(params) 499 + } 500 + 501 + @JvmStatic 502 + fun createSearchActorsQueryParams(q: String, limit: Long?, cursor: String?): SearchActorsQueryParams = SearchActorsQueryParams(null, q, limit, cursor) 503 + 504 + @JvmStatic 505 + fun createSearchActorsTypeaheadQueryParams(q: String?, limit: Long?): SearchActorsTypeaheadQueryParams = SearchActorsTypeaheadQueryParams(null, q, limit) 506 + 507 + @JvmStatic 508 + fun createSearchPostsQueryParams(q: String, limit: Long?, cursor: String?): SearchPostsQueryParams = SearchPostsQueryParams( 509 + q = q, 510 + sort = null, 511 + since = null, 512 + until = null, 513 + mentions = null, 514 + author = null, 515 + lang = null, 516 + domain = null, 517 + url = null, 518 + tag = emptyList<String>(), 519 + limit = limit, 520 + cursor = cursor 521 + ) 522 + 523 + @JvmStatic 524 + fun getCursor(response: SearchActorsResponse): String? = response.cursor 525 + 526 + @JvmStatic 527 + fun getCursor(response: SearchPostsResponse): String? = response.cursor 528 + }
+738
mastodon/src/main/java/org/joinmastodon/android/api/atproto/AtProtoMapper.kt
··· 1 + package org.joinmastodon.android.api.atproto 2 + 3 + import android.text.TextUtils 4 + import app.bsky.actor.GetProfileQueryParams 5 + import app.bsky.actor.ProfileView 6 + import app.bsky.actor.ProfileViewBasic 7 + import app.bsky.actor.ProfileViewDetailed 8 + import app.bsky.actor.SearchActorsResponse 9 + import app.bsky.actor.SearchActorsTypeaheadResponse 10 + import app.bsky.embed.ExternalView 11 + import app.bsky.embed.ImagesView 12 + import app.bsky.embed.RecordView 13 + import app.bsky.embed.RecordViewRecordEmbedUnion 14 + import app.bsky.embed.RecordViewRecordUnion 15 + import app.bsky.embed.RecordWithMediaView 16 + import app.bsky.embed.RecordWithMediaViewMediaUnion 17 + import app.bsky.embed.VideoView 18 + import app.bsky.feed.FeedViewPost 19 + import app.bsky.feed.FeedViewPostReasonUnion 20 + import app.bsky.feed.GetAuthorFeedResponse 21 + import app.bsky.feed.GetLikesResponse 22 + import app.bsky.feed.GetPostThreadResponseThreadUnion 23 + import app.bsky.feed.GetQuotesResponse 24 + import app.bsky.feed.GetRepostedByResponse 25 + import app.bsky.feed.GetTimelineResponse 26 + import app.bsky.feed.Post 27 + import app.bsky.feed.PostView 28 + import app.bsky.feed.PostViewEmbedUnion 29 + import app.bsky.feed.ReplyRefParentUnion 30 + import app.bsky.feed.SearchPostsResponse 31 + import app.bsky.feed.ThreadViewPost 32 + import app.bsky.feed.ThreadViewPostParentUnion 33 + import app.bsky.feed.ThreadViewPostReplieUnion 34 + import app.bsky.notification.ListNotificationsNotification 35 + import app.bsky.richtext.Facet 36 + import app.bsky.richtext.FacetByteSlice 37 + import app.bsky.richtext.FacetFeatureUnion 38 + import app.bsky.richtext.FacetLink 39 + import app.bsky.richtext.FacetMention 40 + import app.bsky.richtext.FacetTag 41 + import com.atproto.repo.CreateRecordResponse 42 + import kotlinx.coroutines.runBlocking 43 + import kotlinx.serialization.json.decodeFromJsonElement 44 + import org.joinmastodon.android.model.Account 45 + import org.joinmastodon.android.model.Attachment 46 + import org.joinmastodon.android.model.Card 47 + import org.joinmastodon.android.model.Hashtag 48 + import org.joinmastodon.android.model.Mention 49 + import org.joinmastodon.android.model.NotificationGroup 50 + import org.joinmastodon.android.model.NotificationType 51 + import org.joinmastodon.android.model.Quote 52 + import org.joinmastodon.android.model.QuoteApproval 53 + import org.joinmastodon.android.model.Status 54 + import org.joinmastodon.android.model.StatusContext 55 + import org.joinmastodon.android.model.StatusPrivacy 56 + import org.joinmastodon.android.model.viewmodel.NotificationViewModel 57 + import org.joinmastodon.android.ui.text.HtmlParser 58 + import sh.christian.ozone.BlueskyJson 59 + import sh.christian.ozone.api.Did 60 + import sh.christian.ozone.api.Uri 61 + import java.nio.charset.StandardCharsets 62 + import java.time.Instant 63 + import java.util.Collections 64 + import java.util.EnumSet 65 + import java.util.regex.Pattern 66 + 67 + object AtProtoMapper { 68 + 69 + @JvmStatic 70 + fun mapNotifications( 71 + notifications: List<ListNotificationsNotification>, 72 + bskyStatuses: Map<String, Status>, 73 + accountID: String 74 + ): List<NotificationViewModel> { 75 + val result = mutableListOf<NotificationViewModel>() 76 + val myDid = accountID.substring(accountID.lastIndexOf('_') + 1) 77 + val groups = mutableMapOf<String, NotificationViewModel>() 78 + 79 + for (n in notifications) { 80 + val reason = n.reason.toString() 81 + var subjectUri: String? 82 + var type: NotificationType? 83 + 84 + when (reason) { 85 + "like" -> { 86 + type = NotificationType.FAVORITE 87 + subjectUri = n.reasonSubject?.atUri 88 + } 89 + 90 + "repost" -> { 91 + type = NotificationType.REBLOG 92 + subjectUri = n.reasonSubject?.atUri 93 + } 94 + 95 + "follow" -> { 96 + type = NotificationType.FOLLOW 97 + subjectUri = "follow" 98 + } 99 + 100 + "mention", "reply" -> { 101 + type = NotificationType.MENTION 102 + subjectUri = n.uri.atUri 103 + } 104 + 105 + "quote" -> { 106 + type = NotificationType.QUOTE 107 + subjectUri = n.uri.atUri 108 + } 109 + 110 + else -> continue 111 + } 112 + 113 + if (subjectUri == null) continue 114 + 115 + val groupKey = "${type.name}-$subjectUri" 116 + var nvm = groups[groupKey] 117 + 118 + if (nvm == null) { 119 + val group = NotificationGroup() 120 + group.type = type 121 + group.groupKey = "atproto-$groupKey" 122 + group.notificationsCount = 0 123 + group.pageMaxId = n.uri.atUri 124 + group.pageMinId = n.uri.atUri 125 + group.latestPageNotificationAt = Instant.ofEpochMilli(n.indexedAt.toEpochMilliseconds()) 126 + 127 + nvm = NotificationViewModel() 128 + nvm.notification = group 129 + nvm.accounts = mutableListOf<Account>() 130 + group.sampleAccountIds = mutableListOf<String>() 131 + 132 + if (type == NotificationType.FAVORITE || type == NotificationType.REBLOG || type == NotificationType.MENTION || type == NotificationType.QUOTE) { 133 + val statusUri = if (reason == "like" || reason == "repost") n.reasonSubject?.atUri else n.uri.atUri 134 + val s = bskyStatuses[statusUri] 135 + if (s != null) { 136 + nvm.status = s 137 + group.statusId = s.id 138 + if (reason == "reply") s.inReplyToAccountId = myDid 139 + } 140 + } 141 + 142 + groups[groupKey] = nvm 143 + result.add(nvm) 144 + } 145 + 146 + nvm.notification.notificationsCount++ 147 + val author = mapAccount(n.author) 148 + nvm.accounts.add(author) 149 + nvm.notification.sampleAccountIds.add(author.id) 150 + nvm.notification.pageMinId = n.uri.atUri 151 + } 152 + return result 153 + } 154 + 155 + @JvmStatic 156 + fun mapLikes(data: GetLikesResponse): List<Account> { 157 + return data.likes.map { mapAccount(it.actor) } 158 + } 159 + 160 + @JvmStatic 161 + fun mapRepostedBy(data: GetRepostedByResponse): List<Account> { 162 + return data.repostedBy.map { mapAccount(it) } 163 + } 164 + 165 + @JvmStatic 166 + fun mapQuotesList(data: GetQuotesResponse): List<Status> { 167 + return data.posts.map { mapPost(it) } 168 + } 169 + 170 + @JvmStatic 171 + fun mapSearchPosts(data: SearchPostsResponse): List<Status> { 172 + return data.posts.map { mapPost(it) } 173 + } 174 + 175 + @JvmStatic 176 + fun mapSearchActors(data: SearchActorsResponse): List<Account> { 177 + return data.actors.map { mapAccount(it) } 178 + } 179 + 180 + @JvmStatic 181 + fun mapSearchActorsTypeahead(data: SearchActorsTypeaheadResponse): List<Account> { 182 + return data.actors.map { mapAccount(it) } 183 + } 184 + 185 + @JvmStatic 186 + fun mapTimeline(data: GetTimelineResponse): List<Status> { 187 + return mapFeed(data.feed) 188 + } 189 + 190 + @JvmStatic 191 + fun mapTimeline(data: GetAuthorFeedResponse): List<Status> { 192 + return mapFeed(data.feed) 193 + } 194 + 195 + @JvmStatic 196 + @JvmOverloads 197 + fun mapFeed(feed: List<FeedViewPost>, pinnedPostUri: String? = null): List<Status> { 198 + val statuses = mutableListOf<Status>() 199 + for (item in feed) { 200 + val reply = item.reply 201 + var parentPost: PostView? = null 202 + if (reply != null) { 203 + val parent = reply.parent 204 + if (parent is ReplyRefParentUnion.PostView) { 205 + parentPost = parent.value 206 + } 207 + } 208 + val s = mapPost(item.post, parentPost, item.reason) 209 + if (pinnedPostUri != null && s.uri == pinnedPostUri) 210 + s.pinned = true 211 + statuses.add(s) 212 + } 213 + return statuses 214 + } 215 + 216 + @JvmStatic 217 + fun mapThread(thread: GetPostThreadResponseThreadUnion?, targetUri: String): StatusContext { 218 + val context = StatusContext() 219 + context.ancestors = mutableListOf() 220 + context.descendants = mutableListOf() 221 + 222 + if (thread == null) return context 223 + 224 + if (thread is GetPostThreadResponseThreadUnion.ThreadViewPost) { 225 + val viewPost = thread.value 226 + val targetNode = findNodeByUri(viewPost, targetUri) ?: viewPost 227 + 228 + collectAncestors(targetNode.parent, context.ancestors) 229 + Collections.reverse(context.ancestors) 230 + 231 + targetNode.replies.forEach { reply -> 232 + if (reply is ThreadViewPostReplieUnion.ThreadViewPost) { 233 + collectDescendants(reply.value, context.descendants) 234 + } 235 + } 236 + } 237 + 238 + return context 239 + } 240 + 241 + private fun findNodeByUri(current: ThreadViewPost, uri: String): ThreadViewPost? { 242 + if (uri == current.post.uri.atUri) return current 243 + current.replies.forEach { reply -> 244 + if (reply is ThreadViewPostReplieUnion.ThreadViewPost) { 245 + findNodeByUri(reply.value, uri)?.let { return it } 246 + } 247 + } 248 + return null 249 + } 250 + 251 + private fun collectAncestors( 252 + current: ThreadViewPostParentUnion?, 253 + ancestors: MutableList<Status> 254 + ) { 255 + if (current is ThreadViewPostParentUnion.ThreadViewPost) { 256 + val viewPost = current.value 257 + val parent = viewPost.parent 258 + var parentPost: PostView? = null 259 + if (parent is ThreadViewPostParentUnion.ThreadViewPost) { 260 + parentPost = parent.value.post 261 + } 262 + ancestors.add(mapPost(viewPost.post, parentPost)) 263 + collectAncestors(viewPost.parent, ancestors) 264 + } 265 + } 266 + 267 + private fun collectDescendants( 268 + current: ThreadViewPost, 269 + descendants: MutableList<Status> 270 + ) { 271 + val parent = current.parent 272 + var parentPost: PostView? = null 273 + if (parent is ThreadViewPostParentUnion.ThreadViewPost) { 274 + parentPost = parent.value.post 275 + } 276 + descendants.add(mapPost(current.post, parentPost)) 277 + current.replies.forEach { reply -> 278 + if (reply is ThreadViewPostReplieUnion.ThreadViewPost) { 279 + collectDescendants(reply.value, descendants) 280 + } 281 + } 282 + } 283 + 284 + @JvmStatic 285 + @JvmOverloads 286 + fun mapPost( 287 + post: PostView, 288 + reply: PostView? = null, 289 + reason: FeedViewPostReasonUnion? = null 290 + ): Status { 291 + val status = Status() 292 + status.id = post.uri.atUri 293 + status.uri = status.id 294 + status.cid = post.cid.cid 295 + val author = post.author 296 + status.url = "https://bsky.app/profile/${author.handle.handle}/post/${status.uri.substring(status.uri.lastIndexOf("/") + 1)}" 297 + status.account = mapAccount(author) 298 + status.visibility = StatusPrivacy.PUBLIC 299 + status.spoilerText = "" 300 + status.pinned = false 301 + 302 + val record = try { 303 + BlueskyJson.decodeFromJsonElement<Post>(post.record.value) 304 + } catch (_: Exception) { 305 + null 306 + } 307 + 308 + if (record != null) { 309 + processFacets(status, record) 310 + status.createdAt = Instant.ofEpochMilli(record.createdAt.toEpochMilliseconds()) 311 + 312 + record.reply?.let { postReply -> 313 + val parentUri = postReply.parent.uri.atUri 314 + status.inReplyToId = parentUri 315 + if (parentUri.startsWith("at://")) { 316 + val firstSlash = parentUri.indexOf("/", 5) 317 + if (firstSlash > 5) { 318 + status.inReplyToAccountId = parentUri.substring(5, firstSlash) 319 + } 320 + } 321 + } 322 + } else { 323 + status.content = "" 324 + status.createdAt = Instant.EPOCH 325 + status.mentions = mutableListOf() 326 + status.tags = mutableListOf() 327 + status.emojis = emptyList() 328 + } 329 + 330 + status.mediaAttachments = mutableListOf() 331 + post.embed?.let { handleEmbed(status, it) } 332 + 333 + status.repliesCount = post.replyCount ?: 0 334 + status.reblogsCount = post.repostCount ?: 0 335 + status.quotesCount = post.quoteCount ?: 0 336 + status.favouritesCount = post.likeCount ?: 0 337 + 338 + if (reply != null) { 339 + status.inReplyToId = reply.uri.atUri 340 + status.inReplyToAccountId = reply.author.did.did 341 + status.inReplyToAccount = mapAccount(reply.author) 342 + } 343 + 344 + post.viewer?.let { viewer -> 345 + status.favourited = viewer.like != null 346 + status.reblogged = viewer.repost != null 347 + status.atProtoLikeUri = viewer.like?.atUri 348 + status.atProtoRepostUri = viewer.repost?.atUri 349 + 350 + status.quoteApproval = QuoteApproval() 351 + if (viewer.embeddingDisabled == false) { 352 + status.quoteApproval.currentUser = QuoteApproval.CurrentUserPolicy.AUTOMATIC 353 + status.quoteApproval.automatic = EnumSet.noneOf(QuoteApproval.Policy::class.java) 354 + status.quoteApproval.automatic.add(QuoteApproval.Policy.PUBLIC) 355 + status.quoteApproval.manual = EnumSet.noneOf(QuoteApproval.Policy::class.java) 356 + } else { 357 + status.quoteApproval.currentUser = QuoteApproval.CurrentUserPolicy.DENIED 358 + } 359 + } 360 + 361 + if (reason is FeedViewPostReasonUnion.ReasonRepost) { 362 + val reblog = Status() 363 + reblog.account = mapAccount(reason.value.by) 364 + reblog.id = "repost_${status.id}_${reblog.account.id}" 365 + reblog.uri = status.uri 366 + reblog.cid = status.cid 367 + reblog.reblog = status 368 + reblog.visibility = status.visibility 369 + reblog.createdAt = Instant.ofEpochMilli(reason.value.indexedAt.toEpochMilliseconds()) 370 + return reblog 371 + } 372 + 373 + return status 374 + } 375 + 376 + private fun handleEmbed(status: Status, embeds: List<RecordViewRecordEmbedUnion>?) { 377 + embeds?.forEach { handleEmbed(status, it) } 378 + } 379 + 380 + private fun handleEmbed(status: Status, embed: RecordViewRecordEmbedUnion) { 381 + when (embed) { 382 + is RecordViewRecordEmbedUnion.ImagesView -> mapImages(status, embed.value) 383 + is RecordViewRecordEmbedUnion.VideoView -> mapVideo(status, embed.value) 384 + is RecordViewRecordEmbedUnion.ExternalView -> status.card = mapCard(embed.value) 385 + is RecordViewRecordEmbedUnion.RecordView -> status.quote = mapQuote(embed.value) 386 + is RecordViewRecordEmbedUnion.RecordWithMediaView -> handleRecordWithMedia(status, embed.value) 387 + else -> {} 388 + } 389 + } 390 + 391 + private fun handleEmbed(status: Status, embed: PostViewEmbedUnion) { 392 + when (embed) { 393 + is PostViewEmbedUnion.ImagesView -> mapImages(status, embed.value) 394 + is PostViewEmbedUnion.VideoView -> mapVideo(status, embed.value) 395 + is PostViewEmbedUnion.ExternalView -> status.card = mapCard(embed.value) 396 + is PostViewEmbedUnion.RecordView -> status.quote = mapQuote(embed.value) 397 + is PostViewEmbedUnion.RecordWithMediaView -> handleRecordWithMedia(status, embed.value) 398 + else -> {} 399 + } 400 + } 401 + 402 + private fun handleRecordWithMedia(status: Status, rm: RecordWithMediaView) { 403 + rm.media.let { 404 + when (it) { 405 + is RecordWithMediaViewMediaUnion.ImagesView -> mapImages(status, it.value) 406 + is RecordWithMediaViewMediaUnion.VideoView -> mapVideo(status, it.value) 407 + is RecordWithMediaViewMediaUnion.ExternalView -> status.card = mapCard(it.value) 408 + else -> {} 409 + } 410 + } 411 + status.quote = mapQuote(rm.record) 412 + } 413 + 414 + private fun mapImages(status: Status, images: ImagesView) { 415 + for (img in images.images) { 416 + val att = Attachment() 417 + att.id = img.fullsize.uri 418 + att.type = Attachment.Type.IMAGE 419 + att.url = att.id 420 + att.previewUrl = img.thumb.uri 421 + att.description = img.alt 422 + img.aspectRatio?.let { 423 + att.meta = Attachment.Metadata() 424 + att.meta.width = it.width.toInt() 425 + att.meta.height = it.height.toInt() 426 + } 427 + status.mediaAttachments.add(att) 428 + } 429 + } 430 + 431 + private fun mapVideo(status: Status, video: VideoView) { 432 + val att = Attachment() 433 + att.id = video.cid.cid 434 + att.type = Attachment.Type.VIDEO 435 + att.url = video.playlist.uri 436 + att.previewUrl = video.thumbnail?.uri ?: "" 437 + att.description = video.alt 438 + video.aspectRatio?.let { 439 + att.meta = Attachment.Metadata() 440 + att.meta.width = it.width.toInt() 441 + att.meta.height = it.height.toInt() 442 + } 443 + status.mediaAttachments.add(att) 444 + } 445 + 446 + private fun mapCard(external: ExternalView): Card { 447 + val ext = external.external 448 + val card = Card() 449 + card.url = ext.uri.uri 450 + card.title = ext.title 451 + card.description = ext.description 452 + card.image = ext.thumb?.uri ?: "" 453 + card.type = Card.Type.LINK 454 + return card 455 + } 456 + 457 + private fun mapQuote(recordView: RecordView): Quote? { 458 + val record = recordView.record 459 + if (record is RecordViewRecordUnion.ViewRecord) { 460 + val rec = record.value 461 + val quote = Quote() 462 + quote.state = Quote.State.ACCEPTED 463 + quote.quotedStatusId = rec.uri.atUri 464 + 465 + val quotedStatus = Status() 466 + quotedStatus.id = quote.quotedStatusId 467 + quotedStatus.uri = quote.quotedStatusId 468 + quotedStatus.cid = rec.cid.cid 469 + quotedStatus.account = mapAccount(rec.author) 470 + quotedStatus.visibility = StatusPrivacy.PUBLIC 471 + quotedStatus.spoilerText = "" 472 + quotedStatus.mediaAttachments = mutableListOf() 473 + quotedStatus.mentions = mutableListOf() 474 + quotedStatus.tags = mutableListOf() 475 + quotedStatus.emojis = emptyList() 476 + quotedStatus.createdAt = Instant.ofEpochMilli(rec.indexedAt.toEpochMilliseconds()) 477 + 478 + quotedStatus.mediaAttachments = mutableListOf() 479 + handleEmbed(quotedStatus, rec.embeds) 480 + 481 + val value = BlueskyJson.decodeFromJsonElement<Post>(rec.value.value) 482 + processFacets(quotedStatus, value) 483 + 484 + quotedStatus.repliesCount = rec.replyCount ?: 0 485 + quotedStatus.reblogsCount = rec.repostCount ?: 0 486 + quotedStatus.quotesCount = rec.quoteCount ?: 0 487 + quotedStatus.favouritesCount = rec.likeCount ?: 0 488 + 489 + quote.quotedStatus = quotedStatus 490 + return quote 491 + } 492 + return null 493 + } 494 + 495 + private fun processFacets(status: Status, record: Post) { 496 + val text = record.text 497 + if (TextUtils.isEmpty(text)) { 498 + status.content = "" 499 + status.mentions = mutableListOf() 500 + status.tags = mutableListOf() 501 + status.emojis = emptyList() 502 + return 503 + } 504 + 505 + val mentions = mutableListOf<Mention>() 506 + val tags = mutableListOf<Hashtag>() 507 + 508 + val facets = record.facets 509 + if (facets.isEmpty()) { 510 + status.content = text.replace("\n", "<br>") 511 + status.mentions = mutableListOf() 512 + status.tags = mutableListOf() 513 + status.emojis = emptyList() 514 + return 515 + } 516 + 517 + val bytes = text.toByteArray(StandardCharsets.UTF_8) 518 + val html = StringBuilder() 519 + var lastBytePos = 0 520 + 521 + facets.sortedBy { it.index.byteStart }.forEach { facet -> 522 + val start = facet.index.byteStart.toInt() 523 + val end = facet.index.byteEnd.toInt() 524 + 525 + if (start > lastBytePos) { 526 + html.append( 527 + String(bytes, lastBytePos, start - lastBytePos, StandardCharsets.UTF_8).replace( 528 + "\n", 529 + "<br>" 530 + ) 531 + ) 532 + } 533 + 534 + val facetText = String(bytes, start, end - start, StandardCharsets.UTF_8) 535 + var handled = false 536 + 537 + facet.features.forEach { feature -> 538 + when (feature) { 539 + is FacetFeatureUnion.Mention -> { 540 + val did = feature.value.did.did 541 + html.append("<a href=\"https://bsky.app/profile/$did\">$facetText</a>") 542 + val m = Mention() 543 + m.id = did 544 + m.username = if (facetText.startsWith("@")) facetText.substring(1) else facetText 545 + m.acct = m.username 546 + m.url = "https://bsky.app/profile/$did" 547 + mentions.add(m) 548 + handled = true 549 + } 550 + 551 + is FacetFeatureUnion.Link -> { 552 + html.append("<a href=\"${feature.value.uri.uri}\">$facetText</a>") 553 + handled = true 554 + } 555 + 556 + is FacetFeatureUnion.Tag -> { 557 + val tag = feature.value.tag 558 + html.append("<a href=\"https://bsky.app/hashtag/$tag\">$facetText</a>") 559 + val h = Hashtag() 560 + h.name = tag 561 + h.url = "https://bsky.app/hashtag/$tag" 562 + tags.add(h) 563 + handled = true 564 + } 565 + 566 + else -> {} 567 + } 568 + if (handled) return@forEach 569 + } 570 + 571 + if (!handled) { 572 + html.append(facetText) 573 + } 574 + lastBytePos = end 575 + } 576 + 577 + if (lastBytePos < bytes.size) { 578 + html.append( 579 + String(bytes, lastBytePos, bytes.size - lastBytePos, StandardCharsets.UTF_8).replace( 580 + "\n", 581 + "<br>" 582 + ) 583 + ) 584 + } 585 + 586 + status.content = html.toString() 587 + status.mentions = mentions 588 + status.tags = tags 589 + status.emojis = emptyList() 590 + status.language = record.langs.isNotEmpty().let { if (it) record.langs[0].tag else "en" } 591 + } 592 + 593 + @JvmStatic 594 + fun mapAccount(author: ProfileViewBasic?): Account { 595 + val account = Account() 596 + if (author == null) { 597 + account.id = "unknown" 598 + account.username = "unknown" 599 + account.acct = "unknown" 600 + account.displayName = "Unknown User" 601 + account.avatar = "" 602 + account.avatarStatic = "" 603 + account.header = "" 604 + account.headerStatic = "" 605 + account.note = "" 606 + account.url = "" 607 + account.createdAt = Instant.EPOCH 608 + account.emojis = emptyList() 609 + account.fields = emptyList() 610 + return account 611 + } 612 + account.id = author.did.did 613 + account.username = author.handle.handle 614 + account.acct = account.username 615 + account.displayName = author.displayName ?: account.username 616 + account.avatar = author.avatar?.uri ?: "" 617 + account.avatarStatic = account.avatar 618 + account.header = "" 619 + account.headerStatic = "" 620 + account.note = "" 621 + account.url = "https://bsky.app/profile/${account.username}" 622 + account.createdAt = author.createdAt?.let { Instant.ofEpochMilli(it.toEpochMilliseconds()) } ?: Instant.EPOCH 623 + account.emojis = emptyList() 624 + account.fields = emptyList() 625 + 626 + return account 627 + } 628 + 629 + @JvmStatic 630 + fun mapAccount(author: ProfileViewDetailed): Account { 631 + val account = mapAccountBasic(author.did.did, author.handle.handle, author.displayName, author.avatar?.uri) 632 + account.header = author.banner?.uri ?: "" 633 + account.headerStatic = account.header 634 + account.note = author.description ?: "" 635 + account.followersCount = author.followersCount ?: 0 636 + account.followingCount = author.followsCount ?: 0 637 + account.statusesCount = author.postsCount ?: 0 638 + account.createdAt = author.createdAt?.let { Instant.ofEpochMilli(it.toEpochMilliseconds()) } ?: Instant.EPOCH 639 + account.pinnedPostUri = author.pinnedPost?.uri?.atUri ?: "" 640 + return account 641 + } 642 + 643 + @JvmStatic 644 + fun mapAccount(author: ProfileView): Account { 645 + return mapAccountBasic(author.did.did, author.handle.handle, author.displayName, author.avatar?.uri) 646 + } 647 + 648 + private fun mapAccountBasic(did: String, handle: String, displayName: String?, avatar: String?): Account { 649 + val account = Account() 650 + account.id = did 651 + account.username = handle 652 + account.acct = handle 653 + account.displayName = displayName ?: handle 654 + account.avatar = avatar ?: "" 655 + account.avatarStatic = account.avatar 656 + account.header = "" 657 + account.headerStatic = "" 658 + account.note = "" 659 + account.url = "https://bsky.app/profile/$handle" 660 + account.createdAt = Instant.EPOCH 661 + account.emojis = emptyList() 662 + account.fields = emptyList() 663 + return account 664 + } 665 + 666 + @JvmStatic 667 + fun createStatusFromResponse( 668 + response: CreateRecordResponse, 669 + post: Post, 670 + self: Account 671 + ): Status { 672 + val status = Status() 673 + status.id = response.uri.atUri 674 + status.uri = status.id 675 + status.cid = response.cid.cid 676 + status.url = "https://bsky.app/profile/${self.username}/post/${status.uri.substring(status.uri.lastIndexOf("/") + 1)}" 677 + status.account = self 678 + processFacets(status, post) 679 + status.createdAt = Instant.ofEpochMilli(post.createdAt.toEpochMilliseconds()) 680 + status.visibility = StatusPrivacy.PUBLIC 681 + status.mediaAttachments = mutableListOf() 682 + status.emojis = emptyList() 683 + status.spoilerText = "" 684 + return status 685 + } 686 + 687 + @JvmStatic 688 + @JvmOverloads 689 + fun generateFacets(text: String, client: sh.christian.ozone.api.AuthenticatedXrpcBlueskyApi? = null): List<Facet> { 690 + val facets = mutableListOf<Facet>() 691 + if (TextUtils.isEmpty(text)) return facets 692 + 693 + val urlMatcher = HtmlParser.URL_PATTERN.matcher(text) 694 + while (urlMatcher.find()) { 695 + val index = FacetByteSlice( 696 + byteStart = text.take(urlMatcher.start()).toByteArray(StandardCharsets.UTF_8).size.toLong(), 697 + byteEnd = text.take(urlMatcher.end()).toByteArray(StandardCharsets.UTF_8).size.toLong() 698 + ) 699 + val link = FacetLink(Uri(urlMatcher.group())) 700 + 701 + facets.add(Facet(index, listOf(FacetFeatureUnion.Link(link)))) 702 + } 703 + 704 + val tagMatcher = Pattern.compile("#\\w+").matcher(text) 705 + while (tagMatcher.find()) { 706 + val index = FacetByteSlice( 707 + byteStart = text.take(tagMatcher.start()).toByteArray(StandardCharsets.UTF_8).size.toLong(), 708 + byteEnd = text.take(tagMatcher.end()).toByteArray(StandardCharsets.UTF_8).size.toLong() 709 + ) 710 + val tag = FacetTag(tagMatcher.group().substring(1)) 711 + 712 + facets.add(Facet(index, listOf(FacetFeatureUnion.Tag(tag)))) 713 + } 714 + 715 + if (client != null) { 716 + val mentionMatcher = Pattern.compile("(?<!\\w)@([a-zA-Z0-9-]{1,63}(\\.[a-zA-Z0-9-]{1,63})*)").matcher(text) 717 + while (mentionMatcher.find()) { 718 + val handle = mentionMatcher.group(1) ?: continue 719 + val params = GetProfileQueryParams(sh.christian.ozone.api.Handle(handle)) 720 + val profileResp = runBlocking { client.getProfile(params) } 721 + if (profileResp is sh.christian.ozone.api.response.AtpResponse.Success) { 722 + val profile = profileResp.response 723 + val did = profile.did.did 724 + val index = FacetByteSlice( 725 + byteStart = text.take(mentionMatcher.start()) 726 + .toByteArray(StandardCharsets.UTF_8).size.toLong(), 727 + byteEnd = text.take(mentionMatcher.end()) 728 + .toByteArray(StandardCharsets.UTF_8).size.toLong() 729 + ) 730 + val mention = FacetMention(Did(did)) 731 + facets.add(Facet(index, listOf(FacetFeatureUnion.Mention(mention)))) 732 + } 733 + } 734 + } 735 + 736 + return facets 737 + } 738 + }
+77 -4
mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java
··· 7 7 import android.text.TextUtils; 8 8 import android.util.Log; 9 9 10 + import com.atproto.server.RefreshSessionResponse; 10 11 import com.google.gson.JsonObject; 11 12 import com.google.gson.JsonParser; 12 13 import com.google.gson.annotations.SerializedName; ··· 19 20 import org.joinmastodon.android.api.MastodonAPIController; 20 21 import org.joinmastodon.android.api.PushSubscriptionManager; 21 22 import org.joinmastodon.android.api.StatusInteractionController; 23 + import org.joinmastodon.android.api.atproto.AtProtoClient; 22 24 import org.joinmastodon.android.api.gson.JsonObjectBuilder; 23 25 import org.joinmastodon.android.api.requests.accounts.GetPreferences; 24 26 import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentialsPreferences; ··· 34 36 import org.joinmastodon.android.model.Instance; 35 37 import org.joinmastodon.android.model.LegacyFilter; 36 38 import org.joinmastodon.android.model.Preferences; 39 + import org.joinmastodon.android.model.Protocol; 37 40 import org.joinmastodon.android.model.PushSubscription; 38 41 import org.joinmastodon.android.model.Role; 39 42 import org.joinmastodon.android.model.Status; ··· 41 44 import org.joinmastodon.android.model.Token; 42 45 import org.joinmastodon.android.utils.ObjectIdComparator; 43 46 47 + import java.lang.reflect.Constructor; 44 48 import java.time.Instant; 45 49 import java.time.temporal.ChronoUnit; 46 50 import java.util.ArrayList; ··· 48 52 import java.util.function.Consumer; 49 53 import java.util.function.Function; 50 54 55 + import io.ktor.client.HttpClient; 56 + import io.ktor.client.HttpClientKt; 57 + import io.ktor.client.engine.okhttp.OkHttp; 58 + import kotlin.coroutines.EmptyCoroutineContext; 59 + import kotlinx.coroutines.BuildersKt; 60 + import kotlinx.coroutines.flow.MutableStateFlow; 51 61 import me.grishka.appkit.api.Callback; 52 62 import me.grishka.appkit.api.ErrorResponse; 63 + import sh.christian.ozone.api.AuthenticatedXrpcBlueskyApi; 64 + import sh.christian.ozone.api.BlueskyAuthPlugin; 65 + import sh.christian.ozone.api.response.AtpResponse; 53 66 54 67 public class AccountSession{ 55 68 private static final String TAG="AccountSession"; ··· 92 105 @SerializedName(value="preferences", alternate="p") 93 106 public Preferences preferences; 94 107 public boolean needReRegisterForPush; 108 + @SerializedName(value="protocol", alternate="q") 109 + public Protocol protocol=Protocol.MASTODON; 95 110 private transient MastodonAPIController apiController; 96 111 private transient StatusInteractionController statusInteractionController; 97 112 private transient CacheController cacheController; ··· 99 114 private transient SharedPreferences prefs; 100 115 private transient boolean preferencesNeedSaving; 101 116 private transient AccountLocalPreferences localPreferences; 117 + private transient AuthenticatedXrpcBlueskyApi bluesky; 102 118 103 - AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){ 119 + public AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){ 120 + this(token, self, app, domain, activated, activationInfo, Protocol.MASTODON); 121 + } 122 + 123 + public AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo, Protocol protocol){ 104 124 this.token=token; 105 125 this.self=self; 106 126 this.domain=domain; 107 127 this.app=app; 108 128 this.activated=activated; 109 129 this.activationInfo=activationInfo; 130 + this.protocol=protocol; 110 131 infoLastUpdated=System.currentTimeMillis(); 111 132 } 112 133 113 - AccountSession(){} 134 + public AccountSession(){} 114 135 115 - AccountSession(ContentValues values){ 136 + public AccountSession(ContentValues values){ 116 137 domain=values.getAsString("domain"); 117 138 self=MastodonAPIController.gson.fromJson(values.getAsString("account_obj"), Account.class); 118 139 token=MastodonAPIController.gson.fromJson(values.getAsString("token"), Token.class); ··· 135 156 pushAccountID=values.getAsString("push_id"); 136 157 activationInfo=MastodonAPIController.gson.fromJson(values.getAsString("activation_info"), AccountActivationInfo.class); 137 158 preferences=MastodonAPIController.gson.fromJson(values.getAsString("preferences"), Preferences.class); 159 + if(values.containsKey("protocol")) 160 + protocol=Protocol.valueOf(values.getAsString("protocol")); 138 161 } 139 162 140 163 public void toContentValues(ContentValues values){ ··· 160 183 values.put("push_id", pushAccountID); 161 184 values.put("activation_info", MastodonAPIController.gson.toJson(activationInfo)); 162 185 values.put("preferences", MastodonAPIController.gson.toJson(preferences)); 186 + values.put("protocol", protocol.name()); 163 187 } 164 188 165 189 public long getFlagsForDatabase(){ ··· 183 207 return apiController; 184 208 } 185 209 210 + public AuthenticatedXrpcBlueskyApi getBluesky(){ 211 + if(bluesky==null){ 212 + String host=domain; 213 + if(!host.startsWith("http")) 214 + host="https://"+host; 215 + HttpClient httpClient=AtProtoClient.createHttpClient(host); 216 + bluesky=AtProtoClient.createAuthenticatedXrpcBlueskyApi(httpClient, token.accessToken, token.refreshToken); 217 + } 218 + return bluesky; 219 + } 220 + 221 + public interface BlueskyAction<T>{ 222 + AtpResponse<T> run(AuthenticatedXrpcBlueskyApi client) throws Exception; 223 + } 224 + 225 + // this is unnecessary but this was written when i initially wrote support for refreshing tokens so uhhhh 226 + public <T> AtpResponse<T> executeBluesky(BlueskyAction<T> action) throws Exception{ 227 + return action.run(getBluesky()); 228 + } 229 + 230 + /*public synchronized boolean refreshBlueskyTokens(){ 231 + try{ 232 + AtpResponse<RefreshSessionResponse> resp=BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, (scope, continuation)->getBluesky().refreshSession(continuation)); 233 + if(resp instanceof AtpResponse.Success<RefreshSessionResponse> success){ 234 + RefreshSessionResponse data=success.getResponse(); 235 + token.accessToken=data.getAccessJwt(); 236 + token.refreshToken=data.getRefreshJwt(); 237 + Log.d(TAG, "Successfully refreshed Bluesky tokens for "+getID()); 238 + AccountSessionManager.getInstance().updateAccountTokens(getID(), token); 239 + ((MutableStateFlow<BlueskyAuthPlugin.Tokens>)getBluesky().getAuthTokens()).setValue(new BlueskyAuthPlugin.Tokens(token.accessToken, token.refreshToken)); 240 + return true; 241 + }else{ 242 + Log.e(TAG, "Failed to refresh tokens for " + getID() + ": " + resp); 243 + return false; 244 + } 245 + }catch(Exception x){ 246 + Log.e(TAG, "Failed to refresh tokens for " + getID(), x); 247 + return false; 248 + } 249 + }*/ 250 + 186 251 public StatusInteractionController getStatusInteractionController(){ 187 252 if(statusInteractionController==null) 188 253 statusInteractionController=new StatusInteractionController(getID()); ··· 202 267 } 203 268 204 269 public String getFullUsername(){ 270 + if(protocol==Protocol.ATPROTO) 271 + return '@'+self.username; 205 272 return '@'+self.username+'@'+domain; 206 273 } 207 274 208 275 public void reloadPreferences(Consumer<Preferences> callback){ 276 + if(protocol==Protocol.ATPROTO) 277 + return; 209 278 new GetPreferences() 210 279 .setCallback(new Callback<>(){ 211 280 @Override ··· 231 300 } 232 301 233 302 public void reloadNotificationsMarker(Consumer<String> callback){ 303 + if(protocol==Protocol.ATPROTO) 304 + return; 234 305 new GetMarkers() 235 306 .setCallback(new Callback<>(){ 236 307 @Override ··· 385 456 } 386 457 387 458 public boolean canAccessLocalTimeline(){ 459 + if(protocol==Protocol.ATPROTO) 460 + return false; 388 461 if(self.source!=null && self.source.role!=null && ((self.source.role.permissions & Role.PERMISSION_VIEW_LIVE_AND_TOPIC_FEEDS)!=0 || (self.source.role.permissions & Role.PERMISSION_ADMINISTRATOR)!=0)) 389 462 return true; 390 463 Instance instance=getInstanceInfo(); 391 464 return instance==null || instance.configuration==null || instance.configuration.timelineAccess==null 392 465 || instance.configuration.timelineAccess.liveFeeds==null || instance.configuration.timelineAccess.liveFeeds.local!=Instance.TimelineAccessValue.DISABLED; 393 466 } 394 - } 467 + }
+127 -8
mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java
··· 22 22 import android.util.Log; 23 23 import android.widget.Toast; 24 24 25 + import com.atproto.server.DescribeServerContact; 26 + import com.atproto.server.DescribeServerLinks; 27 + import com.atproto.server.DescribeServerResponse; 25 28 import com.google.gson.JsonArray; 26 29 import com.google.gson.JsonElement; 27 30 import com.google.gson.JsonObject; ··· 40 43 import org.joinmastodon.android.api.MastodonErrorResponse; 41 44 import org.joinmastodon.android.api.PushSubscriptionManager; 42 45 import org.joinmastodon.android.api.WrapperRequest; 46 + import org.joinmastodon.android.api.atproto.AtProtoClient; 43 47 import org.joinmastodon.android.api.gson.JsonObjectBuilder; 44 48 import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; 45 49 import org.joinmastodon.android.api.requests.filters.GetLegacyFilters; ··· 51 55 import org.joinmastodon.android.events.EmojiUpdatedEvent; 52 56 import org.joinmastodon.android.model.Account; 53 57 import org.joinmastodon.android.model.Application; 58 + import org.joinmastodon.android.model.AtProtoInstance; 54 59 import org.joinmastodon.android.model.Emoji; 55 60 import org.joinmastodon.android.model.EmojiCategory; 56 61 import org.joinmastodon.android.model.Instance; ··· 58 63 import org.joinmastodon.android.model.InstanceV2; 59 64 import org.joinmastodon.android.model.LegacyFilter; 60 65 import org.joinmastodon.android.model.Preferences; 66 + import org.joinmastodon.android.model.Protocol; 61 67 import org.joinmastodon.android.model.Token; 62 68 import org.joinmastodon.android.ui.utils.UiUtils; 63 69 ··· 78 84 import androidx.annotation.NonNull; 79 85 import androidx.annotation.Nullable; 80 86 import androidx.browser.customtabs.CustomTabsIntent; 87 + import app.bsky.actor.GetProfileQueryParams; 88 + import app.bsky.actor.ProfileViewDetailed; 81 89 import me.grishka.appkit.api.APIRequest; 82 90 import me.grishka.appkit.api.Callback; 83 91 import me.grishka.appkit.api.ErrorResponse; 92 + import sh.christian.ozone.api.BlueskyAuthPlugin; 93 + import sh.christian.ozone.api.response.AtpResponse; 84 94 85 95 public class AccountSessionManager{ 86 96 private static final String TAG="AccountSessionManager"; 87 97 public static final String SCOPE="read write follow push"; 88 98 public static final String REDIRECT_URI="mastodon-android-auth://callback"; 89 - private static final int DB_VERSION=3; 99 + private static final int DB_VERSION=4; 90 100 91 101 private static final AccountSessionManager instance=new AccountSessionManager(); 92 102 ··· 127 137 maybeUpdateShortcuts(); 128 138 } 129 139 130 - public void addAccount(Instance instance, Token token, Account self, Application app, AccountActivationInfo activationInfo){ 140 + public void addInstance(Instance instance){ 131 141 instances.put(instance.getDomain(), instance); 132 142 runOnDbThread(db->insertInstanceIntoDatabase(db, instance.getDomain(), instance, null, 0)); 143 + } 144 + 145 + public void addAccount(Instance instance, Token token, Account self, Application app, AccountActivationInfo activationInfo){ 146 + addInstance(instance); 133 147 AccountSession session=new AccountSession(token, self, app, instance.getDomain(), activationInfo==null, activationInfo); 148 + addAccount(session); 149 + } 150 + 151 + public void addAccount(AccountSession session){ 134 152 sessions.put(session.getID(), session); 135 153 lastActiveAccountID=session.getID(); 136 154 prefs.edit().putString("lastActiveAccount", lastActiveAccountID).apply(); ··· 139 157 session.toContentValues(values); 140 158 db.insertWithOnConflict("accounts", null, values, SQLiteDatabase.CONFLICT_REPLACE); 141 159 }); 142 - updateInstanceEmojis(instance, instance.getDomain()); 143 - if(PushSubscriptionManager.arePushNotificationsAvailable()){ 144 - session.getPushSubscriptionManager().registerAccountForPush(null); 160 + if(session.protocol==Protocol.MASTODON){ 161 + Instance instance=getInstanceInfo(session.domain); 162 + updateInstanceEmojis(instance, instance.getDomain()); 163 + if(PushSubscriptionManager.arePushNotificationsAvailable()){ 164 + session.getPushSubscriptionManager().registerAccountForPush(null); 165 + } 145 166 } 146 167 maybeUpdateShortcuts(); 147 168 } ··· 314 335 } 315 336 316 337 /*package*/ void updateSessionLocalInfo(AccountSession session){ 338 + if(session.protocol==Protocol.ATPROTO){ 339 + updateSessionLocalInfoATProto(session); 340 + return; 341 + } 317 342 new GetOwnAccount() 318 343 .setCallback(new Callback<>(){ 319 344 @Override ··· 336 361 .exec(session.getID()); 337 362 } 338 363 364 + private void updateSessionLocalInfoATProto(AccountSession session){ 365 + MastodonAPIController.runInBackground(()->{ 366 + try{ 367 + AtpResponse<ProfileViewDetailed> resp=session.executeBluesky((client)->{ 368 + GetProfileQueryParams params=AtProtoClient.createGetProfileQueryParams(session.self.username); 369 + return AtProtoClient.getProfile(client, params); 370 + }); 371 + 372 + if(resp instanceof AtpResponse.Success<ProfileViewDetailed> success){ 373 + ProfileViewDetailed data=success.getResponse(); 374 + Account self=session.self; 375 + self.displayName=data.getDisplayName(); 376 + self.note=data.getDescription(); 377 + self.avatar=self.avatarStatic=AtProtoClient.getAvatar(data); 378 + self.header=self.headerStatic=AtProtoClient.getBanner(data); 379 + self.followersCount=data.getFollowersCount()!=null ? data.getFollowersCount() : 0; 380 + self.followingCount=data.getFollowsCount()!=null ? data.getFollowsCount() : 0; 381 + self.statusesCount=data.getPostsCount()!=null ? data.getPostsCount() : 0; 382 + self.emojis=new ArrayList<>(); 383 + self.fields=new ArrayList<>(); 384 + 385 + session.infoLastUpdated=System.currentTimeMillis(); 386 + updateAccountInfo(session.getID(), self); 387 + } 388 + }catch(Exception ignore){} 389 + }); 390 + } 391 + 339 392 private void updateSessionWordFilters(AccountSession session){ 340 393 new GetLegacyFilters() 341 394 .setCallback(new Callback<>(){ ··· 368 421 public void onSuccess(Instance instance){ 369 422 instances.put(domain, instance); 370 423 runOnDbThread(db->insertInstanceIntoDatabase(db, domain, instance, null, 0)); 371 - updateInstanceEmojis(instance, domain); 424 + if (!(instance instanceof AtProtoInstance)) 425 + updateInstanceEmojis(instance, domain); 372 426 } 373 427 374 428 @Override ··· 414 468 Instance instance=MastodonAPIController.gson.fromJson(values.getAsString("instance_obj"), switch(version){ 415 469 case 1 -> InstanceV1.class; 416 470 case 2 -> InstanceV2.class; 471 + case 3 -> AtProtoInstance.class; 417 472 default -> throw new IllegalStateException("Unexpected value: "+version); 418 473 }); 419 474 instances.put(domain, instance); ··· 470 525 Instance i=instances.get(domain); 471 526 if(i!=null) 472 527 return i; 528 + AccountSession session=findAnySessionForDomain(domain); 529 + if(session!=null && session.protocol==Protocol.ATPROTO) { 530 + AtProtoInstance api = new AtProtoInstance(); 531 + api.domain=domain; 532 + return api; 533 + } 473 534 Log.e(TAG, "Instance info for "+domain+" was not found. This should normally never happen. Returning fake instance object"); 474 535 if(BuildConfig.DEBUG) 475 536 throw new IllegalStateException("Instance info for "+domain+" missing"); ··· 488 549 ContentValues values=new ContentValues(); 489 550 values.put("account_obj", MastodonAPIController.gson.toJson(account)); 490 551 values.put("info_last_updated", session.infoLastUpdated); 552 + db.update("accounts", values, "`id`=?", new String[]{session.getID()}); 553 + }); 554 + } 555 + 556 + public void updateAccountTokens(String id, Token token){ 557 + AccountSession session=getAccount(id); 558 + session.token=token; 559 + runWithDatabase(db->{ 560 + ContentValues values=new ContentValues(); 561 + values.put("token", MastodonAPIController.gson.toJson(token)); 562 + db.update("accounts", values, "`id`=?", new String[]{session.getID()}); 563 + }); 564 + } 565 + 566 + public void updateAccountTokens(String id, BlueskyAuthPlugin.Tokens ozoneTokens){ 567 + AccountSession session=getAccount(id); 568 + session.token.accessToken=ozoneTokens.getAuth(); 569 + session.token.refreshToken=ozoneTokens.getRefresh(); 570 + runWithDatabase(db->{ 571 + ContentValues values=new ContentValues(); 572 + values.put("token", MastodonAPIController.gson.toJson(session.token)); 491 573 db.update("accounts", values, "`id`=?", new String[]{session.getID()}); 492 574 }); 493 575 } ··· 666 748 public static APIRequest<Instance> loadInstanceInfo(String domain, Callback<Instance> callback){ 667 749 final WrapperRequest<Instance> wrapper=new WrapperRequest<>(); 668 750 AccountSession session=findAnySessionForDomain(domain); 751 + if(session!=null && session.protocol==Protocol.ATPROTO){ 752 + MastodonAPIController.runInBackground(()->{ 753 + try{ 754 + AtpResponse<DescribeServerResponse> resp=session.executeBluesky(AtProtoClient::describeServer); 755 + 756 + AtProtoInstance api=new AtProtoInstance(); 757 + api.domain=domain; 758 + if(resp instanceof AtpResponse.Success<DescribeServerResponse> success){ 759 + DescribeServerResponse data=success.getResponse(); 760 + api.inviteCodeRequired=data.getInviteCodeRequired()!=null && data.getInviteCodeRequired(); 761 + api.availableUserDomains=data.getAvailableUserDomains(); 762 + DescribeServerLinks links=data.getLinks(); 763 + if(links != null){ 764 + api.configuration.urls.privacyPolicy=AtProtoClient.getPrivacyPolicy(links); 765 + api.configuration.urls.termsOfService=AtProtoClient.getTermsOfService(links); 766 + } 767 + DescribeServerContact contact=data.getContact(); 768 + if(contact != null){ 769 + api.contactEmail=contact.getEmail(); 770 + } 771 + } 772 + 773 + UiUtils.runOnUiThread(()->callback.onSuccess(api)); 774 + }catch(Exception e){ 775 + // Fallback to default AtProtoInstance if describeServer fails 776 + AtProtoInstance api=new AtProtoInstance(); 777 + api.domain=domain; 778 + UiUtils.runOnUiThread(()->callback.onSuccess(api)); 779 + } 780 + }); 781 + return wrapper; 782 + } 669 783 MastodonAPIRequest<?> req=new GetInstanceV2() 670 784 .setCallback(new Callback<>(){ 671 785 @Override ··· 744 858 `domain` text PRIMARY KEY, 745 859 `instance_obj` text, 746 860 `emojis` text, 747 - `last_updated` bigint 861 + `last_updated` bigint, 862 + `version` integer NOT NULL DEFAULT 1 748 863 )"""); 749 864 maybeMigrateAccounts(db); 750 865 } 751 866 if(oldVersion<3){ 752 867 db.execSQL("ALTER TABLE `instances` ADD `version` integer NOT NULL DEFAULT 1"); 868 + } 869 + if(oldVersion<4){ 870 + db.execSQL("ALTER TABLE `accounts` ADD `protocol` text DEFAULT 'MASTODON'"); 753 871 } 754 872 } 755 873 ··· 768 886 `legacy_filters` text DEFAULT NULL, 769 887 `push_id` text, 770 888 `activation_info` text, 771 - `preferences` text 889 + `preferences` text, 890 + `protocol` text DEFAULT 'MASTODON' 772 891 )"""); 773 892 } 774 893
+59 -3
mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java
··· 2 2 3 3 import android.app.Activity; 4 4 import android.os.Bundle; 5 + import android.text.TextUtils; 5 6 import android.view.View; 6 7 import android.widget.HorizontalScrollView; 7 8 import android.widget.LinearLayout; 8 9 9 10 import org.joinmastodon.android.R; 11 + import org.joinmastodon.android.api.MastodonAPIController; 12 + import org.joinmastodon.android.api.MastodonErrorResponse; 13 + import org.joinmastodon.android.api.atproto.AtProtoClient; 14 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 10 15 import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; 16 + import org.joinmastodon.android.api.session.AccountSession; 11 17 import org.joinmastodon.android.api.session.AccountSessionManager; 12 18 import org.joinmastodon.android.events.RemoveAccountPostsEvent; 13 19 import org.joinmastodon.android.model.Account; 14 20 import org.joinmastodon.android.model.FilterContext; 21 + import org.joinmastodon.android.model.Protocol; 15 22 import org.joinmastodon.android.model.Status; 16 23 import org.joinmastodon.android.ui.drawables.EmptyDrawable; 17 24 import org.joinmastodon.android.ui.views.FilterChipView; ··· 21 28 import java.util.List; 22 29 23 30 import androidx.recyclerview.widget.RecyclerView; 31 + import app.bsky.feed.GetAuthorFeedQueryParams; 32 + import app.bsky.feed.GetAuthorFeedResponse; 24 33 import me.grishka.appkit.api.SimpleCallback; 25 34 import me.grishka.appkit.utils.MergeRecyclerAdapter; 26 35 import me.grishka.appkit.utils.SingleViewRecyclerAdapter; 27 36 import me.grishka.appkit.utils.V; 37 + import sh.christian.ozone.api.response.AtpResponse; 28 38 29 39 public class AccountTimelineFragment extends StatusListFragment{ 30 40 private Account user; ··· 48 58 return f; 49 59 } 50 60 61 + public void setProfileAccount(Account account){ 62 + this.user=account; 63 + getArguments().putParcelable("profileAccount", Parcels.wrap(account)); 64 + if(!loaded && !dataLoading && isAdded() && !getArguments().getBoolean("noAutoLoad")){ 65 + loadData(); 66 + } 67 + } 68 + 51 69 @Override 52 70 public void onAttach(Activity activity){ 53 71 user=Parcels.unwrap(getArguments().getParcelable("profileAccount")); ··· 57 75 58 76 @Override 59 77 protected void doLoadData(int offset, int count){ 78 + AccountSession session=AccountSessionManager.get(accountID); 79 + if(session.protocol==Protocol.ATPROTO){ 80 + if(user==null || TextUtils.isEmpty(user.id)){ 81 + onDataLoaded(Collections.emptyList(),false); 82 + return; 83 + } 84 + doLoadDataATProto(session, offset, count); 85 + return; 86 + } 60 87 currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter, null) 61 88 .setCallback(new SimpleCallback<>(this){ 62 89 @Override 63 90 public void onSuccess(List<Status> result){ 64 - if(getActivity()==null) 65 - return; 66 91 boolean empty=result.isEmpty(); 67 92 AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.ACCOUNT); 68 93 onDataLoaded(result, !empty); ··· 71 96 .exec(accountID); 72 97 } 73 98 99 + private void doLoadDataATProto(AccountSession session, int offset, int count){ 100 + MastodonAPIController.runInBackground(()->{ 101 + try{ 102 + String filterVal=switch(filter){ 103 + case DEFAULT->"posts_no_replies"; 104 + case INCLUDE_REPLIES->"posts_with_replies"; 105 + case MEDIA->"posts_with_media"; 106 + default->null; 107 + }; 108 + AtpResponse<GetAuthorFeedResponse> resp=session.executeBluesky((client)->{ 109 + GetAuthorFeedQueryParams params=AtProtoClient.createGetAuthorFeedQueryParams(user.username, (long)count, offset>0 ? getMaxID() : null, filterVal, false); 110 + return AtProtoClient.getAuthorFeed(client,params); 111 + }); 112 + if(resp instanceof AtpResponse.Success<GetAuthorFeedResponse> success){ 113 + GetAuthorFeedResponse data=success.getResponse(); 114 + List<Status> result=AtProtoMapper.mapFeed(data.getFeed()); 115 + String nextCursor=data.getCursor(); 116 + 117 + getActivity().runOnUiThread(()->{ 118 + session.filterStatuses(result, FilterContext.ACCOUNT); 119 + onDataLoaded(result, nextCursor!=null); 120 + }); 121 + }else{ 122 + throw new Exception("Failed to load author feed: "+resp); 123 + } 124 + }catch(Exception x){ 125 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 126 + } 127 + }); 128 + } 129 + 74 130 @Override 75 131 public void onViewCreated(View view, Bundle savedInstanceState){ 76 132 super.onViewCreated(view, savedInstanceState); ··· 170 226 dataLoading=true; 171 227 doLoadData(); 172 228 } 173 - } 229 + }
+150 -5
mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java
··· 50 50 import android.widget.ProgressBar; 51 51 import android.widget.TextView; 52 52 53 + import com.atproto.repo.CreateRecordRequest; 54 + import com.atproto.repo.CreateRecordResponse; 55 + import com.atproto.repo.StrongRef; 53 56 import com.twitter.twittertext.TwitterTextEmojiRegex; 54 57 55 58 import org.joinmastodon.android.E; 56 59 import org.joinmastodon.android.GlobalUserPreferences; 57 60 import org.joinmastodon.android.R; 61 + import org.joinmastodon.android.api.MastodonAPIController; 58 62 import org.joinmastodon.android.api.MastodonErrorResponse; 63 + import org.joinmastodon.android.api.atproto.AtProtoClient; 64 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 59 65 import org.joinmastodon.android.api.requests.statuses.CreateStatus; 60 66 import org.joinmastodon.android.api.requests.statuses.EditStatus; 61 67 import org.joinmastodon.android.api.session.AccountSession; ··· 70 76 import org.joinmastodon.android.model.Instance; 71 77 import org.joinmastodon.android.model.Mention; 72 78 import org.joinmastodon.android.model.Preferences; 79 + import org.joinmastodon.android.model.Protocol; 80 + import org.joinmastodon.android.model.Quote; 73 81 import org.joinmastodon.android.model.Status; 74 82 import org.joinmastodon.android.model.StatusPrivacy; 75 83 import org.joinmastodon.android.model.StatusQuotePolicy; ··· 104 112 import org.parceler.Parcels; 105 113 106 114 import java.util.ArrayList; 115 + import java.util.Collections; 107 116 import java.util.List; 108 117 import java.util.Map; 109 118 import java.util.UUID; ··· 113 122 import java.util.stream.Collectors; 114 123 115 124 import androidx.annotation.NonNull; 125 + import app.bsky.embed.Images; 126 + import app.bsky.embed.ImagesImage; 127 + import app.bsky.embed.Record; 128 + import app.bsky.embed.Video; 129 + import app.bsky.feed.Post; 130 + import app.bsky.feed.PostReplyRef; 131 + import app.bsky.feed.PostEmbedUnion; 132 + import app.bsky.richtext.Facet; 116 133 import me.grishka.appkit.Nav; 117 134 import me.grishka.appkit.api.Callback; 118 135 import me.grishka.appkit.api.ErrorResponse; ··· 121 138 import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; 122 139 import me.grishka.appkit.utils.CubicBezierInterpolator; 123 140 import me.grishka.appkit.utils.V; 141 + import sh.christian.ozone.api.Language; 142 + import sh.christian.ozone.api.model.Blob; 143 + import sh.christian.ozone.api.response.AtpResponse; 124 144 125 145 public class ComposeFragment extends MastodonToolbarFragment implements ComposeEditText.SelectionListener, CustomTransitionsFragment{ 126 146 ··· 129 149 private static final int AUTOCOMPLETE_ACCOUNT_RESULT=779; 130 150 private static final String TAG="ComposeFragment"; 131 151 132 - private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE); 152 + private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+|([a-z0-9\\.\\-]+[a-z0-9]+))", Pattern.CASE_INSENSITIVE); 133 153 134 154 // from https://github.com/mastodon/mastodon-ios/blob/main/Mastodon/Helper/MastodonRegex.swift 135 - private static final Pattern AUTO_COMPLETE_PATTERN=Pattern.compile("(?<!\\w)(?:@([a-z0-9_]+)(@[a-z0-9_\\.\\-]*)?|#([^\\s.]+)|:([a-z0-9_]+))", Pattern.CASE_INSENSITIVE); 136 - private static final Pattern HIGHLIGHT_PATTERN=Pattern.compile("(?<!\\w)(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))"); 155 + private static final Pattern AUTO_COMPLETE_PATTERN=Pattern.compile("(?<!\\w)(?:@([a-z0-9_\\.\\-]+)(@[a-z0-9_\\.\\-]*)?|#([^\\s.]+)|:([a-z0-9_]+))", Pattern.CASE_INSENSITIVE); 156 + private static final Pattern HIGHLIGHT_PATTERN=Pattern.compile("(?<!\\w)(?:@([a-zA-Z0-9_\\.\\-]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))"); 137 157 138 158 @SuppressLint("NewApi") // this class actually exists on 6.0 139 159 private final BreakIterator breakIterator=BreakIterator.getCharacterInstance(); ··· 235 255 236 256 if(getArguments().containsKey("quote")) 237 257 quotedStatus=Parcels.unwrap(getArguments().getParcelable("quote")); 258 + 259 + if(getArguments().containsKey("replyTo")) 260 + replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo")); 238 261 } 239 262 240 263 @Override ··· 830 853 } 831 854 832 855 private void actuallyPublish(){ 856 + AccountSession session=AccountSessionManager.get(accountID); 857 + if(session.protocol==Protocol.ATPROTO){ 858 + actuallyPublishATProto(session); 859 + return; 860 + } 833 861 String text=mainEditText.getText().toString(); 834 862 CreateStatus.Request req=new CreateStatus.Request(); 835 863 req.status=text; ··· 899 927 } 900 928 } 901 929 930 + private void actuallyPublishATProto(AccountSession session){ 931 + String text=mainEditText.getText().toString(); 932 + MastodonAPIController.runInBackground(()->{ 933 + try{ 934 + final Post[] recordRef=new Post[1]; 935 + List<ComposeMediaViewController.DraftMediaAttachment> attachments=mediaViewController.attachments; 936 + Images imagesEmbed=null; 937 + Video videoEmbed=null; 938 + if(!attachments.isEmpty()){ 939 + List<ImagesImage> images=new ArrayList<>(); 940 + for(ComposeMediaViewController.DraftMediaAttachment att:attachments){ 941 + if(att.serverAttachment!=null && att.serverAttachment.atProtoBlob!=null){ 942 + Blob blob=att.serverAttachment.atProtoBlob; 943 + app.bsky.embed.AspectRatio aspectRatio=null; 944 + if(att.serverAttachment.meta!=null){ 945 + aspectRatio=AtProtoClient.createAspectRatio(att.serverAttachment.meta.width, att.serverAttachment.meta.height); 946 + } 947 + if(att.mimeType.startsWith("video/")){ 948 + videoEmbed=AtProtoClient.createEmbedVideo(blob, att.description, aspectRatio); 949 + break; 950 + }else{ 951 + images.add(AtProtoClient.createEmbedImagesImage(blob, att.description, aspectRatio)); 952 + } 953 + } 954 + } 955 + if(!images.isEmpty()){ 956 + imagesEmbed=AtProtoClient.createEmbedImages(images); 957 + } 958 + } 959 + 960 + Record quoteEmbed=null; 961 + if(quotedStatus!=null){ 962 + quoteEmbed=AtProtoClient.createEmbedRecord(AtProtoClient.createStrongRef(quotedStatus.uri, quotedStatus.cid)); 963 + } 964 + 965 + PostEmbedUnion embed=null; 966 + if(videoEmbed!=null && quoteEmbed!=null){ 967 + embed=AtProtoClient.createPostEmbedRecordWithMedia(AtProtoClient.createEmbedRecordWithMedia(quoteEmbed,AtProtoClient.createEmbedRecordWithMediaMediaVideo(videoEmbed))); 968 + }else if(videoEmbed!=null){ 969 + embed=AtProtoClient.createPostEmbedVideo(videoEmbed); 970 + }else if(imagesEmbed!=null && quoteEmbed!=null){ 971 + embed=AtProtoClient.createPostEmbedRecordWithMedia(AtProtoClient.createEmbedRecordWithMedia(quoteEmbed,AtProtoClient.createEmbedRecordWithMediaMediaImages(imagesEmbed))); 972 + }else if(imagesEmbed!=null){ 973 + embed=AtProtoClient.createPostEmbedImages(imagesEmbed); 974 + }else if(quoteEmbed!=null){ 975 + embed=AtProtoClient.createPostEmbedRecord(quoteEmbed); 976 + } 977 + 978 + PostReplyRef reply=null; 979 + if(replyTo!=null && !TextUtils.isEmpty(replyTo.cid)){ 980 + StrongRef ref=AtProtoClient.createStrongRef(replyTo.uri,replyTo.cid); 981 + reply=AtProtoClient.createPostReplyRef(ref,ref); 982 + } 983 + 984 + final PostReplyRef replyRef=reply; 985 + final PostEmbedUnion embedRef=embed; 986 + 987 + AtpResponse<CreateRecordResponse> resp=session.executeBluesky((client)->{ 988 + List<Facet> facets=AtProtoMapper.generateFacets(text, client); 989 + List<String> langs=new ArrayList<>(); 990 + if(postLang!=null){ 991 + langs.add(postLang.locale.toLanguageTag()); 992 + } 993 + Post record=AtProtoClient.createPost(text, facets, replyRef, embedRef, langs); 994 + recordRef[0]=record; 995 + 996 + CreateRecordRequest req=AtProtoClient.createCreateRecordRequest( 997 + AtProtoClient.createAtIdentifier(session.self.id), 998 + "app.bsky.feed.post", 999 + AtProtoClient.toPostJson(record) 1000 + ); 1001 + return AtProtoClient.createRecord(client, req); 1002 + }); 1003 + 1004 + getActivity().runOnUiThread(()->{ 1005 + wm.removeView(sendingOverlay); 1006 + sendingOverlay=null; 1007 + removeBackCallback(sendingBackButtonBlocker); 1008 + removeBackCallback(discardConfirmationCallback); 1009 + removeBackCallback(emojiKeyboardHider); 1010 + 1011 + if(resp instanceof AtpResponse.Success<CreateRecordResponse> success){ 1012 + Status result=AtProtoMapper.createStatusFromResponse(success.getResponse(), recordRef[0], self); 1013 + for(ComposeMediaViewController.DraftMediaAttachment att:attachments){ 1014 + if(att.serverAttachment!=null) 1015 + result.mediaAttachments.add(att.serverAttachment); 1016 + } 1017 + if(replyTo!=null){ 1018 + result.inReplyToId=replyTo.id; 1019 + result.inReplyToAccountId=replyTo.account.id; 1020 + } 1021 + if(quotedStatus!=null){ 1022 + result.quote=new Quote(); 1023 + result.quote.quotedStatus=quotedStatus; 1024 + result.quote.quotedStatusId=quotedStatus.id; 1025 + result.quote.state=Quote.State.ACCEPTED; 1026 + } 1027 + E.post(new StatusCreatedEvent(result, accountID)); 1028 + if(replyTo!=null){ 1029 + replyTo.repliesCount++; 1030 + E.post(new StatusCountersUpdatedEvent(replyTo, StatusCountersUpdatedEvent.CounterType.REPLIES)); 1031 + } 1032 + Nav.finish(ComposeFragment.this); 1033 + }else{ 1034 + handlePublishError((AtpResponse.Failure<?>)resp); 1035 + } 1036 + }); 1037 + }catch(Exception x){ 1038 + getActivity().runOnUiThread(()->handlePublishError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 1039 + } 1040 + }); 1041 + } 1042 + 1043 + private void handlePublishError(AtpResponse.Failure<?> failure){ 1044 + handlePublishError(new MastodonErrorResponse(AtProtoClient.getMessage(failure),0,null)); 1045 + } 1046 + 902 1047 private void handlePublishError(ErrorResponse error){ 903 1048 if(sendingOverlay.isAttachedToWindow()) 904 1049 wm.removeView(sendingOverlay); ··· 1057 1202 1058 1203 public void updateMediaPollStates(){ 1059 1204 pollBtn.setSelected(pollViewController.isShown()); 1060 - mediaBtn.setEnabled(!pollViewController.isShown() && mediaViewController.canAddMoreAttachments() && quotedStatus==null); 1061 - pollBtn.setEnabled(mediaViewController.isEmpty() && quotedStatus==null); 1205 + mediaBtn.setEnabled(!pollViewController.isShown() && mediaViewController.canAddMoreAttachments()); 1206 + pollBtn.setEnabled(mediaViewController.isEmpty() && quotedStatus==null && AccountSessionManager.get(accountID).protocol!=Protocol.ATPROTO); 1062 1207 } 1063 1208 1064 1209 private void togglePoll(){
+62 -17
mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java
··· 18 18 import org.joinmastodon.android.api.requests.tags.GetTag; 19 19 import org.joinmastodon.android.api.requests.tags.SetTagFollowed; 20 20 import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline; 21 + import org.joinmastodon.android.api.session.AccountSession; 21 22 import org.joinmastodon.android.api.session.AccountSessionManager; 22 23 import org.joinmastodon.android.model.FilterContext; 23 24 import org.joinmastodon.android.model.Hashtag; ··· 30 31 31 32 import androidx.annotation.NonNull; 32 33 import androidx.recyclerview.widget.RecyclerView; 34 + import app.bsky.feed.SearchPostsResponse; 33 35 import me.grishka.appkit.Nav; 34 36 import me.grishka.appkit.api.Callback; 35 37 import me.grishka.appkit.api.ErrorResponse; ··· 37 39 import me.grishka.appkit.utils.MergeRecyclerAdapter; 38 40 import me.grishka.appkit.utils.SingleViewRecyclerAdapter; 39 41 import me.grishka.appkit.utils.V; 42 + import org.joinmastodon.android.api.MastodonAPIController; 43 + import org.joinmastodon.android.api.atproto.AtProtoClient; 44 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 45 + import org.joinmastodon.android.model.Protocol; 46 + import sh.christian.ozone.api.response.AtpResponse; 40 47 41 48 public class HashtagTimelineFragment extends StatusListFragment{ 42 49 private Hashtag hashtag; ··· 69 76 70 77 @Override 71 78 protected void doLoadData(int offset, int count){ 79 + if(AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO){ 80 + doLoadDataATProto(offset, count); 81 + return; 82 + } 72 83 currentRequest=new GetHashtagTimeline(hashtagName, offset==0 ? null : maxID, null, count) 73 84 .setCallback(new SimpleCallback<>(this){ 74 85 @Override ··· 82 93 .exec(accountID); 83 94 } 84 95 96 + private void doLoadDataATProto(int offset, int count){ 97 + AccountSession session=AccountSessionManager.get(accountID); 98 + MastodonAPIController.runInBackground(()->{ 99 + try{ 100 + AtpResponse<SearchPostsResponse> resp=session.executeBluesky(client->{ 101 + var params=AtProtoClient.createSearchPostsQueryParams("#"+hashtagName, (long)count, offset>0 ? maxID : null); 102 + return AtProtoClient.searchPosts(client, params); 103 + }); 104 + if(resp instanceof AtpResponse.Success<SearchPostsResponse> success){ 105 + var result=success.getResponse(); 106 + var posts=AtProtoMapper.mapSearchPosts(result); 107 + maxID=AtProtoClient.getCursor(result); 108 + getActivity().runOnUiThread(()->onDataLoaded(posts, maxID!=null)); 109 + }else{ 110 + throw new Exception("Hashtag search failed: "+resp); 111 + } 112 + }catch(Exception x){ 113 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 114 + } 115 + }); 116 + } 117 + 85 118 @Override 86 119 protected void onShown(){ 87 120 super.onShown(); ··· 91 124 92 125 @Override 93 126 public void loadData(){ 94 - reloadTag(); 127 + if(AccountSessionManager.get(accountID).protocol!=Protocol.ATPROTO) 128 + reloadTag(); 95 129 super.loadData(); 96 130 } 97 131 ··· 101 135 fab=view.findViewById(R.id.fab); 102 136 fab.setOnClickListener(this::onFabClick); 103 137 104 - list.addOnScrollListener(new RecyclerView.OnScrollListener(){ 105 - @Override 106 - public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ 107 - View topChild=recyclerView.getChildAt(0); 108 - int firstChildPos=recyclerView.getChildAdapterPosition(topChild); 109 - float newAlpha=firstChildPos>0 ? 1f : Math.min(1f, -topChild.getTop()/(float)headerTitle.getHeight()); 110 - toolbarTitleView.setAlpha(newAlpha); 111 - boolean newToolbarVisibility=newAlpha>0.5f; 112 - if(newToolbarVisibility!=toolbarContentVisible){ 113 - toolbarContentVisible=newToolbarVisibility; 114 - if(followMenuItem!=null) 115 - followMenuItem.setVisible(toolbarContentVisible); 138 + if(AccountSessionManager.get(accountID).protocol!=Protocol.ATPROTO){ 139 + list.addOnScrollListener(new RecyclerView.OnScrollListener(){ 140 + @Override 141 + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ 142 + View topChild=recyclerView.getChildAt(0); 143 + int firstChildPos=recyclerView.getChildAdapterPosition(topChild); 144 + float newAlpha=firstChildPos>0 ? 1f : Math.min(1f, -topChild.getTop()/(float)headerTitle.getHeight()); 145 + toolbarTitleView.setAlpha(newAlpha); 146 + boolean newToolbarVisibility=newAlpha>0.5f; 147 + if(newToolbarVisibility!=toolbarContentVisible){ 148 + toolbarContentVisible=newToolbarVisibility; 149 + if(followMenuItem!=null) 150 + followMenuItem.setVisible(toolbarContentVisible); 151 + } 116 152 } 117 - } 118 - }); 153 + }); 154 + }else{ 155 + toolbarTitleView.setAlpha(1f); 156 + } 119 157 } 120 158 121 159 private void onFabClick(View v){ ··· 132 170 133 171 @Override 134 172 protected RecyclerView.Adapter getAdapter(){ 173 + if(AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO){ 174 + return super.getAdapter(); 175 + } 135 176 View header=getActivity().getLayoutInflater().inflate(R.layout.header_hashtag_timeline, list, false); 136 177 headerTitle=header.findViewById(R.id.title); 137 178 headerSubtitle=header.findViewById(R.id.subtitle); ··· 155 196 156 197 @Override 157 198 public int getMainAdapterOffset(){ 199 + if(AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO) 200 + return 0; 158 201 return 1; 159 202 } 160 203 161 204 @Override 162 205 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ 163 - followMenuItem=menu.add(getString(hashtag!=null && hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName)); 164 - followMenuItem.setVisible(toolbarContentVisible); 206 + if(AccountSessionManager.get(accountID).protocol!=Protocol.ATPROTO){ 207 + followMenuItem=menu.add(getString(hashtag!=null && hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName)); 208 + followMenuItem.setVisible(toolbarContentVisible); 209 + } 165 210 } 166 211 167 212 @Override
+43 -1
mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java
··· 22 22 import org.joinmastodon.android.E; 23 23 import org.joinmastodon.android.PushNotificationReceiver; 24 24 import org.joinmastodon.android.R; 25 + import org.joinmastodon.android.api.MastodonAPIController; 25 26 import org.joinmastodon.android.api.requests.notifications.GetNotificationsV1; 26 27 import org.joinmastodon.android.api.requests.notifications.GetUnreadNotificationsCount; 27 28 import org.joinmastodon.android.api.session.AccountSession; ··· 34 35 import org.joinmastodon.android.model.Instance; 35 36 import org.joinmastodon.android.model.Notification; 36 37 import org.joinmastodon.android.model.NotificationType; 38 + import org.joinmastodon.android.model.Protocol; 37 39 import org.joinmastodon.android.ui.OutlineProviders; 38 40 import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; 39 41 import org.joinmastodon.android.ui.utils.UiUtils; ··· 47 49 48 50 import androidx.annotation.IdRes; 49 51 import androidx.annotation.Nullable; 52 + import app.bsky.notification.GetUnreadCountQueryParams; 53 + import app.bsky.notification.GetUnreadCountResponse; 54 + import app.bsky.notification.UpdateSeenRequest; 55 + import kotlin.Unit; 56 + import kotlin.coroutines.Continuation; 57 + import kotlin.coroutines.EmptyCoroutineContext; 58 + import kotlinx.coroutines.BuildersKt; 59 + import kotlinx.datetime.Clock; 50 60 import me.grishka.appkit.FragmentStackActivity; 51 61 import me.grishka.appkit.Nav; 52 62 import me.grishka.appkit.api.Callback; ··· 57 67 import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; 58 68 import me.grishka.appkit.utils.V; 59 69 import me.grishka.appkit.views.FragmentRootLinearLayout; 70 + import sh.christian.ozone.api.response.AtpResponse; 60 71 61 72 public class HomeFragment extends AppKitFragment implements AssistContentProviderFragment{ 62 73 private FragmentRootLinearLayout content; ··· 256 267 if(newFragment instanceof NotificationsListFragment){ 257 268 NotificationManager nm=getActivity().getSystemService(NotificationManager.class); 258 269 nm.cancel(accountID, PushNotificationReceiver.NOTIFICATION_ID); 270 + 271 + AccountSession session=AccountSessionManager.get(accountID); 272 + if(session.protocol==Protocol.ATPROTO){ 273 + markNotificationsAsSeenATProto(session); 274 + } 259 275 } 276 + } 277 + 278 + private void markNotificationsAsSeenATProto(AccountSession session){ 279 + MastodonAPIController.runInBackground(()->{ 280 + try{ 281 + session.executeBluesky((client)->{ 282 + return (AtpResponse<Unit>) BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, (scope, continuation) -> client.updateSeen(new UpdateSeenRequest(Clock.System.INSTANCE.now()), (Continuation) continuation)); 283 + }); 284 + getActivity().runOnUiThread(()->notificationsBadge.setVisibility(View.GONE)); 285 + }catch(Exception ignore){} 286 + }); 260 287 } 261 288 262 289 private boolean onTabLongClick(@IdRes int tab){ ··· 293 320 } 294 321 295 322 private void reloadNotificationsForUnreadCount(){ 296 - Instance instance=AccountSessionManager.get(accountID).getInstanceInfo(); 323 + AccountSession session=AccountSessionManager.get(accountID); 324 + if(session.protocol==Protocol.ATPROTO){ 325 + reloadNotificationsForUnreadCountATProto(session); 326 + return; 327 + } 328 + Instance instance=session.getInstanceInfo(); 297 329 if(instance==null) 298 330 return; 299 331 if(instance.getApiVersion()>=2){ ··· 333 365 public void onError(ErrorResponse error){} 334 366 }).exec(accountID); 335 367 } 368 + } 369 + 370 + private void reloadNotificationsForUnreadCountATProto(AccountSession session){ 371 + MastodonAPIController.runInBackground(()->{ 372 + try{ 373 + AtpResponse<GetUnreadCountResponse> resp=session.executeBluesky((client)->BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, (scope, continuation) -> client.getUnreadCount(new GetUnreadCountQueryParams(null, null), continuation))); 374 + if(resp instanceof AtpResponse.Success<GetUnreadCountResponse> success) 375 + getActivity().runOnUiThread(()->updateUnreadNotificationsBadge((int)success.getResponse().getCount(), false)); 376 + }catch(Exception ignore){} 377 + }); 336 378 } 337 379 338 380 @SuppressLint("DefaultLocale")
+48
mastodon/src/main/java/org/joinmastodon/android/fragments/PinnedPostsListFragment.java
··· 1 1 package org.joinmastodon.android.fragments; 2 2 3 + import android.app.Activity; 3 4 import android.os.Bundle; 5 + import android.text.TextUtils; 4 6 5 7 import org.joinmastodon.android.R; 8 + import org.joinmastodon.android.api.MastodonAPIController; 9 + import org.joinmastodon.android.api.MastodonErrorResponse; 10 + import org.joinmastodon.android.api.atproto.AtProtoClient; 11 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 6 12 import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; 13 + import org.joinmastodon.android.api.session.AccountSession; 14 + import org.joinmastodon.android.api.session.AccountSessionManager; 7 15 import org.joinmastodon.android.model.Account; 16 + import org.joinmastodon.android.model.Protocol; 8 17 import org.joinmastodon.android.model.Status; 9 18 import org.parceler.Parcels; 10 19 20 + import java.util.Collections; 11 21 import java.util.List; 22 + import java.util.stream.Collectors; 12 23 24 + import app.bsky.feed.GetPostsResponse; 25 + import app.bsky.feed.PostView; 13 26 import me.grishka.appkit.api.SimpleCallback; 27 + import sh.christian.ozone.api.response.AtpResponse; 14 28 15 29 public class PinnedPostsListFragment extends StatusListFragment{ 16 30 private Account account; ··· 25 39 26 40 @Override 27 41 protected void doLoadData(int offset, int count){ 42 + AccountSession session=AccountSessionManager.get(accountID); 43 + if(session.protocol==Protocol.ATPROTO){ 44 + doLoadDataATProto(session); 45 + return; 46 + } 28 47 new GetAccountStatuses(account.id, null, null, 100, GetAccountStatuses.Filter.PINNED, null) 29 48 .setCallback(new SimpleCallback<>(this){ 30 49 @Override ··· 32 51 onDataLoaded(result, false); 33 52 } 34 53 }).exec(accountID); 54 + } 55 + 56 + private void doLoadDataATProto(AccountSession session){ 57 + if(TextUtils.isEmpty(account.pinnedPostUri)){ 58 + onDataLoaded(Collections.emptyList(), false); 59 + return; 60 + } 61 + 62 + MastodonAPIController.runInBackground(()->{ 63 + try{ 64 + AtpResponse<GetPostsResponse> resp=session.executeBluesky((client)->{ 65 + List<String> atUris=Collections.singletonList(account.pinnedPostUri); 66 + return AtProtoClient.getPosts(client, atUris); 67 + }); 68 + 69 + if (resp instanceof AtpResponse.Success<GetPostsResponse> success) { 70 + List<PostView> posts=AtProtoClient.getPostsList(success.getResponse()); 71 + List<Status> result=posts.stream().map(AtProtoMapper::mapPost).collect(Collectors.toList()); 72 + for(Status s:result) 73 + s.pinned=true; 74 + 75 + getActivity().runOnUiThread(()->onDataLoaded(result, false)); 76 + }else{ 77 + throw new Exception("Failed to load pinned posts: "+resp); 78 + } 79 + }catch(Exception x){ 80 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 81 + } 82 + }); 35 83 } 36 84 }
+70
mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFeaturedFragment.java
··· 1 1 package org.joinmastodon.android.fragments; 2 2 3 + import android.app.Activity; 3 4 import android.graphics.Canvas; 4 5 import android.graphics.Paint; 5 6 import android.os.Bundle; 6 7 import android.os.Parcelable; 8 + import android.text.TextUtils; 7 9 import android.view.View; 8 10 9 11 import org.joinmastodon.android.R; 12 + import org.joinmastodon.android.api.MastodonAPIController; 13 + import org.joinmastodon.android.api.MastodonErrorResponse; 14 + import org.joinmastodon.android.api.atproto.AtProtoClient; 15 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 10 16 import org.joinmastodon.android.api.requests.accounts.GetAccountFeaturedHashtags; 11 17 import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; 18 + import org.joinmastodon.android.api.session.AccountSession; 19 + import org.joinmastodon.android.api.session.AccountSessionManager; 12 20 import org.joinmastodon.android.model.Account; 13 21 import org.joinmastodon.android.model.Hashtag; 22 + import org.joinmastodon.android.model.Protocol; 14 23 import org.joinmastodon.android.model.SearchResult; 15 24 import org.joinmastodon.android.model.Status; 16 25 import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem; ··· 26 35 import java.util.stream.Collectors; 27 36 28 37 import androidx.recyclerview.widget.RecyclerView; 38 + import app.bsky.feed.GetPostsResponse; 39 + import app.bsky.feed.PostView; 29 40 import me.grishka.appkit.Nav; 30 41 import me.grishka.appkit.api.SimpleCallback; 42 + import sh.christian.ozone.api.response.AtpResponse; 31 43 32 44 public class ProfileFeaturedFragment extends BaseStatusListFragment<SearchResult>{ 33 45 private Account profileAccount; ··· 46 58 profileAccount=Parcels.unwrap(getArguments().getParcelable("profileAccount")); 47 59 } 48 60 61 + public void setProfileAccount(Account account){ 62 + this.profileAccount=account; 63 + getArguments().putParcelable("profileAccount", Parcels.wrap(account)); 64 + if(!loaded && !dataLoading && isAdded() && !getArguments().getBoolean("noAutoLoad")){ 65 + loadData(); 66 + } 67 + } 68 + 49 69 @Override 50 70 protected List<StatusDisplayItem> buildDisplayItems(SearchResult s){ 51 71 ArrayList<StatusDisplayItem> items=switch(s.type){ ··· 118 138 119 139 @Override 120 140 protected void doLoadData(int offset, int count){ 141 + AccountSession session=AccountSessionManager.get(accountID); 142 + if(session.protocol==Protocol.ATPROTO){ 143 + doLoadDataATProto(session); 144 + return; 145 + } 121 146 if(!statusesLoaded){ 122 147 new GetAccountStatuses(profileAccount.id, null, null, 2, GetAccountStatuses.Filter.PINNED, null) 123 148 .setCallback(new SimpleCallback<>(this){ ··· 142 167 }) 143 168 .exec(accountID); 144 169 } 170 + } 171 + 172 + private void doLoadDataATProto(AccountSession session){ 173 + if(TextUtils.isEmpty(profileAccount.pinnedPostUri)){ 174 + tagsLoaded=true; 175 + statusesLoaded=true; 176 + pinnedStatuses=Collections.emptyList(); 177 + featuredTags=Collections.emptyList(); 178 + onOneApiRequestCompleted(); 179 + return; 180 + } 181 + 182 + MastodonAPIController.runInBackground(()->{ 183 + try{ 184 + AtpResponse<GetPostsResponse> resp=session.executeBluesky((client)->{ 185 + List<String> atUris=Collections.singletonList(profileAccount.pinnedPostUri); 186 + return AtProtoClient.getPosts(client, atUris); 187 + }); 188 + 189 + if(resp instanceof AtpResponse.Success<GetPostsResponse> success){ 190 + List<PostView> posts=AtProtoClient.getPostsList(success.getResponse()); 191 + List<Status> result=posts.stream().map(AtProtoMapper::mapPost).collect(Collectors.toList()); 192 + for(Status s:result) 193 + s.pinned=true; 194 + 195 + getActivity().runOnUiThread(()->{ 196 + pinnedStatuses=result; 197 + statusesLoaded=true; 198 + tagsLoaded=true; 199 + featuredTags=Collections.emptyList(); 200 + onOneApiRequestCompleted(); 201 + }); 202 + }else{ 203 + throw new Exception("Failed to load pinned posts: "+resp); 204 + } 205 + }catch(Exception x){ 206 + getActivity().runOnUiThread(()->{ 207 + statusesLoaded=true; 208 + tagsLoaded=true; 209 + pinnedStatuses=Collections.emptyList(); 210 + featuredTags=Collections.emptyList(); 211 + onOneApiRequestCompleted(); 212 + }); 213 + } 214 + }); 145 215 } 146 216 147 217 @Override
+318 -13
mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java
··· 13 13 import android.content.pm.ActivityInfo; 14 14 import android.content.res.Configuration; 15 15 import android.content.res.TypedArray; 16 + import android.graphics.Bitmap; 17 + import android.graphics.BitmapFactory; 16 18 import android.graphics.Outline; 17 19 import android.graphics.drawable.ColorDrawable; 18 20 import android.graphics.drawable.Drawable; ··· 51 53 import org.joinmastodon.android.BuildConfig; 52 54 import org.joinmastodon.android.GlobalUserPreferences; 53 55 import org.joinmastodon.android.R; 56 + import org.joinmastodon.android.api.MastodonAPIController; 54 57 import org.joinmastodon.android.api.MastodonAPIRequest; 58 + import org.joinmastodon.android.api.MastodonErrorResponse; 59 + import org.joinmastodon.android.api.atproto.AtProtoClient; 60 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 55 61 import org.joinmastodon.android.api.requests.accounts.GetAccountByID; 56 62 import org.joinmastodon.android.api.requests.accounts.GetAccountFamiliarFollowers; 57 63 import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; 58 64 import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; 59 65 import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; 60 66 import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials; 67 + import org.joinmastodon.android.api.session.AccountSession; 61 68 import org.joinmastodon.android.api.session.AccountSessionManager; 62 69 import org.joinmastodon.android.fragments.account_list.FamiliarFollowerListFragment; 63 70 import org.joinmastodon.android.fragments.account_list.FollowerListFragment; ··· 67 74 import org.joinmastodon.android.model.AccountField; 68 75 import org.joinmastodon.android.model.Attachment; 69 76 import org.joinmastodon.android.model.FamiliarFollowers; 77 + import org.joinmastodon.android.model.Protocol; 70 78 import org.joinmastodon.android.model.Relationship; 79 + import org.joinmastodon.android.model.Source; 71 80 import org.joinmastodon.android.model.viewmodel.AccountViewModel; 72 81 import org.joinmastodon.android.ui.M3AlertDialogBuilder; 73 82 import org.joinmastodon.android.ui.OutlineProviders; ··· 91 100 import org.joinmastodon.android.utils.ElevationOnScrollListener; 92 101 import org.parceler.Parcels; 93 102 103 + import java.io.ByteArrayOutputStream; 104 + import java.io.IOException; 105 + import java.io.InputStream; 94 106 import java.time.LocalDateTime; 95 107 import java.time.ZoneId; 96 108 import java.time.format.DateTimeFormatter; ··· 106 118 import androidx.recyclerview.widget.RecyclerView; 107 119 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; 108 120 import androidx.viewpager2.widget.ViewPager2; 121 + 122 + import com.atproto.repo.GetRecordQueryParams; 123 + import com.atproto.repo.GetRecordResponse; 124 + import com.atproto.repo.PutRecordRequest; 125 + import com.atproto.repo.UploadBlobResponse; 126 + 127 + import app.bsky.actor.GetProfileQueryParams; 128 + import app.bsky.actor.Profile; 129 + import app.bsky.actor.ProfileViewDetailed; 130 + import app.bsky.actor.ViewerState; 131 + import kotlinx.datetime.Clock; 109 132 import me.grishka.appkit.Nav; 110 133 import me.grishka.appkit.api.APIRequest; 111 134 import me.grishka.appkit.api.Callback; ··· 118 141 import me.grishka.appkit.utils.CubicBezierInterpolator; 119 142 import me.grishka.appkit.utils.V; 120 143 import me.grishka.appkit.views.FragmentRootLinearLayout; 144 + import sh.christian.ozone.api.model.Blob; 145 + import sh.christian.ozone.api.response.AtpResponse; 121 146 122 147 public class ProfileFragment extends LoaderFragment implements ScrollableToTop, AssistContentProviderFragment{ 123 148 private static final int AVATAR_RESULT=722; ··· 192 217 loaded=true; 193 218 if(!isOwnProfile) 194 219 loadRelationship(); 220 + // Always load full profile to get bio, fields, etc. 221 + loadData(); 195 222 }else{ 196 223 profileAccountID=getArguments().getString("profileAccountID"); 197 224 if(!getArguments().getBoolean("noAutoLoad", false)) ··· 216 243 @Override 217 244 public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ 218 245 View content=inflater.inflate(R.layout.fragment_profile, container, false); 246 + AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); 219 247 220 248 avatar=content.findViewById(R.id.avatar); 221 249 cover=content.findViewById(R.id.cover); ··· 313 341 314 342 @Override 315 343 public void onTabReselected(TabLayout.Tab tab){ 316 - if(getFragmentForPage(tab.getPosition()) instanceof ScrollableToTop stt) 317 - stt.scrollToTop(); 344 + if(getFragmentForPage(tab.getPosition()) instanceof ScrollableToTop scrollable) 345 + scrollable.scrollToTop(); 318 346 } 319 347 }); 320 348 ··· 354 382 if(account==null) 355 383 return true; 356 384 String username=account.acct; 357 - if(!username.contains("@")){ 358 - username+="@"+AccountSessionManager.getInstance().getAccount(accountID).domain; 359 - } 385 + if(!username.contains("@") && session.protocol!=Protocol.ATPROTO) 386 + username+="@"+session.domain; 360 387 getActivity().getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, "@"+username)); 361 388 UiUtils.maybeShowTextCopiedToast(getActivity()); 362 389 return true; ··· 379 406 bioEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); 380 407 381 408 usernameDomain.setOnClickListener(v->{ 382 - if(account==null) 409 + // TODO: make this open the handle on browser if on atproto 410 + if(account==null || session.protocol==Protocol.ATPROTO) 383 411 return; 384 412 new DecentralizationExplainerSheet(getActivity(), accountID, account).show(); 385 413 }); ··· 398 426 399 427 @Override 400 428 protected void doLoadData(){ 429 + AccountSession session=AccountSessionManager.get(accountID); 430 + if(session.protocol==Protocol.ATPROTO){ 431 + doLoadDataATProto(session); 432 + return; 433 + } 401 434 currentRequest=new GetAccountByID(profileAccountID) 402 435 .setCallback(new SimpleCallback<>(this){ 403 436 @Override 404 437 public void onSuccess(Account result){ 405 438 account=result; 406 439 isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); 440 + if(featuredFragment!=null) 441 + featuredFragment.setProfileAccount(account); 442 + if(timelineFragment!=null) 443 + timelineFragment.setProfileAccount(account); 444 + if(savedFragment!=null) 445 + savedFragment.setProfileAccount(account); 407 446 bindHeaderView(); 408 447 dataLoaded(); 409 448 if(!tabLayoutMediator.isAttached()) ··· 428 467 .exec(accountID); 429 468 } 430 469 470 + private void doLoadDataATProto(AccountSession session){ 471 + MastodonAPIController.runInBackground(()->{ 472 + try{ 473 + AtpResponse<ProfileViewDetailed> resp=session.executeBluesky((client)->{ 474 + GetProfileQueryParams params=new GetProfileQueryParams(AtProtoClient.createAtIdentifier(profileAccountID)); 475 + return AtProtoClient.getProfile(client, params); 476 + }); 477 + 478 + if(resp instanceof AtpResponse.Success<ProfileViewDetailed> success){ 479 + ProfileViewDetailed data=success.getResponse(); 480 + Account result=AtProtoMapper.mapAccount(data); 481 + 482 + getActivity().runOnUiThread(()->{ 483 + account=result; 484 + isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); 485 + if(featuredFragment!=null) 486 + featuredFragment.setProfileAccount(account); 487 + if(timelineFragment!=null) 488 + timelineFragment.setProfileAccount(account); 489 + if(savedFragment!=null) 490 + savedFragment.setProfileAccount(account); 491 + bindHeaderView(); 492 + dataLoaded(); 493 + if(!tabLayoutMediator.isAttached()) 494 + tabLayoutMediator.attach(); 495 + if(!isOwnProfile && data.getViewer()!=null){ 496 + Relationship rel=new Relationship(); 497 + rel.id=AtProtoClient.getDid(data); 498 + ViewerState viewer=data.getViewer(); 499 + rel.following=AtProtoClient.getFollowing(viewer)!=null; 500 + rel.followedBy=AtProtoClient.getFollowedBy(viewer)!=null; 501 + rel.blocking=AtProtoClient.getBlocking(viewer)!=null; 502 + rel.muting=AtProtoClient.getMuted(viewer); 503 + rel.atProtoFollowingUri=AtProtoClient.getFollowing(viewer); 504 + relationship=rel; 505 + updateRelationship(); 506 + } 507 + 508 + if(refreshing){ 509 + refreshing=false; 510 + refreshLayout.setRefreshing(false); 511 + if(timelineFragment!=null && timelineFragment.loaded) 512 + timelineFragment.onRefresh(); 513 + } 514 + V.setVisibilityAnimated(fab, View.VISIBLE); 515 + }); 516 + } else { 517 + throw new Exception("Failed to load profile: " + resp); 518 + } 519 + }catch(Exception x){ 520 + getActivity().runOnUiThread(()->{ 521 + refreshing=false; 522 + refreshLayout.setRefreshing(false); 523 + onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x)); 524 + }); 525 + } 526 + }); 527 + } 528 + 431 529 @Override 432 530 public void onRefresh(){ 433 531 if(refreshing) ··· 587 685 innerProgress.setVisibility(View.VISIBLE); 588 686 this.username.setText(username); 589 687 name.setText(username); 590 - usernameDomain.setText(domain); 688 + if(AccountSessionManager.getInstance().getAccount(accountID).protocol==Protocol.ATPROTO) 689 + usernameDomain.setVisibility(View.INVISIBLE); 690 + else 691 + usernameDomain.setText(domain); 591 692 avatar.setImageResource(R.drawable.image_placeholder); 592 693 cover.setImageResource(R.drawable.image_placeholder); 593 694 actions.setVisibility(View.GONE); ··· 597 698 } 598 699 599 700 private void bindHeaderView(){ 701 + AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); 600 702 if(innerProgress.getVisibility()==View.VISIBLE){ 601 703 TransitionManager.beginDelayedTransition(contentView, new TransitionSet() 602 704 .addTransition(new Fade(Fade.IN | Fade.OUT)) ··· 632 734 }else{ 633 735 username.setText(account.username); 634 736 } 635 - String domain=account.getDomain(); 636 - if(TextUtils.isEmpty(domain)) 637 - domain=AccountSessionManager.get(accountID).domain; 638 - usernameDomain.setText(domain); 737 + if(session.protocol==Protocol.ATPROTO) 738 + usernameDomain.setVisibility(View.INVISIBLE); 739 + else{ 740 + String domain=account.getDomain(); 741 + if(TextUtils.isEmpty(domain)) 742 + domain=session.domain; 743 + usernameDomain.setText(domain); 744 + } 639 745 640 746 CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account, getActivity()); 641 747 if(TextUtils.isEmpty(parsedBio)){ ··· 821 927 } 822 928 823 929 private void loadRelationship(){ 930 + AccountSession session=AccountSessionManager.get(accountID); 931 + if(session.protocol==Protocol.ATPROTO){ 932 + loadRelationshipATProto(session); 933 + return; 934 + } 824 935 MastodonAPIRequest<List<Relationship>> relReq=new GetAccountRelationships(Collections.singletonList(account.id)); 825 936 relReq.setCallback(new Callback<>(){ 826 937 @Override ··· 989 1100 } 990 1101 } 991 1102 1103 + private void loadRelationshipATProto(AccountSession session){ 1104 + MastodonAPIController.runInBackground(()->{ 1105 + try{ 1106 + AtpResponse<ProfileViewDetailed> resp=session.executeBluesky((client)->{ 1107 + GetProfileQueryParams params=new GetProfileQueryParams(AtProtoClient.createAtIdentifier(profileAccountID)); 1108 + return AtProtoClient.getProfile(client, params); 1109 + }); 1110 + if(resp instanceof AtpResponse.Success<ProfileViewDetailed> success){ 1111 + ProfileViewDetailed data=success.getResponse(); 1112 + Relationship rel=new Relationship(); 1113 + rel.id=AtProtoClient.getDid(data); 1114 + if(data.getViewer()!=null){ 1115 + ViewerState viewer=data.getViewer(); 1116 + rel.following=AtProtoClient.getFollowing(viewer)!=null; 1117 + rel.followedBy=AtProtoClient.getFollowedBy(viewer)!=null; 1118 + rel.blocking=AtProtoClient.getBlocking(viewer)!=null; 1119 + rel.muting=AtProtoClient.getMuted(viewer); 1120 + rel.atProtoFollowingUri=AtProtoClient.getFollowing(viewer); 1121 + } 1122 + getActivity().runOnUiThread(()->{ 1123 + relationship=rel; 1124 + updateRelationship(); 1125 + }); 1126 + } 1127 + }catch(Exception e){ 1128 + e.printStackTrace(); 1129 + } 1130 + }); 1131 + } 1132 + 992 1133 private void setActionProgressVisible(boolean visible){ 993 1134 actionButton.setTextVisible(!visible); 994 1135 actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); ··· 1000 1141 private void loadAccountInfoAndEnterEditMode(){ 1001 1142 if(editModeLoading) 1002 1143 return; 1144 + AccountSession session=AccountSessionManager.get(accountID); 1145 + if(session.protocol==Protocol.ATPROTO){ 1146 + loadAccountInfoAndEnterEditModeATProto(session); 1147 + return; 1148 + } 1003 1149 editModeLoading=true; 1004 1150 setActionProgressVisible(true); 1005 1151 new GetOwnAccount() ··· 1023 1169 } 1024 1170 }) 1025 1171 .exec(accountID); 1172 + } 1173 + 1174 + private void loadAccountInfoAndEnterEditModeATProto(AccountSession session){ 1175 + editModeLoading=true; 1176 + setActionProgressVisible(true); 1177 + MastodonAPIController.runInBackground(()->{ 1178 + try{ 1179 + AtpResponse<ProfileViewDetailed> resp=session.executeBluesky((client)->{ 1180 + GetProfileQueryParams params=new GetProfileQueryParams(AtProtoClient.createAtIdentifier(session.self.username)); 1181 + return AtProtoClient.getProfile(client, params); 1182 + }); 1183 + if(resp instanceof AtpResponse.Success<ProfileViewDetailed> success){ 1184 + Account result=AtProtoMapper.mapAccount(success.getResponse()); 1185 + result.source=new Source(); 1186 + result.source.note=AtProtoClient.getDescription(success.getResponse()); 1187 + result.source.fields=result.fields; 1188 + 1189 + getActivity().runOnUiThread(()->{ 1190 + editModeLoading=false; 1191 + enterEditMode(result); 1192 + setActionProgressVisible(false); 1193 + }); 1194 + }else{ 1195 + throw new Exception("Failed to load account info: " + resp); 1196 + } 1197 + }catch(Exception e){ 1198 + getActivity().runOnUiThread(()->{ 1199 + editModeLoading=false; 1200 + new MastodonErrorResponse(e.getLocalizedMessage(), 0, e).showToast(getActivity()); 1201 + setActionProgressVisible(false); 1202 + }); 1203 + } 1204 + }); 1026 1205 } 1027 1206 1028 1207 private void enterEditMode(Account account){ ··· 1178 1357 bio.setVisibility(View.VISIBLE); 1179 1358 countersLayout.setVisibility(View.VISIBLE); 1180 1359 refreshLayout.setEnabled(true); 1181 - usernameDomain.setVisibility(View.VISIBLE); 1360 + if(AccountSessionManager.getInstance().getAccount(accountID).protocol==Protocol.ATPROTO) 1361 + usernameDomain.setVisibility(View.VISIBLE); 1182 1362 qrCodeButton.setVisibility(View.VISIBLE); 1183 1363 1184 1364 bindHeaderView(); ··· 1188 1368 private void saveAndExitEditMode(){ 1189 1369 if(!isInEditMode) 1190 1370 throw new IllegalStateException(); 1371 + 1372 + AccountSession session=AccountSessionManager.get(accountID); 1373 + if(session!=null && session.protocol==Protocol.ATPROTO){ 1374 + saveAndExitEditModeATProto(session); 1375 + return; 1376 + } 1377 + 1191 1378 setActionProgressVisible(true); 1192 1379 savingEdits=true; 1193 1380 new UpdateAccountCredentials(nameEdit.getText().toString(), bioEdit.getText().toString(), editNewAvatar, editNewCover, aboutFragment.getFields()) ··· 1211 1398 .exec(accountID); 1212 1399 } 1213 1400 1401 + private void saveAndExitEditModeATProto(AccountSession session){ 1402 + setActionProgressVisible(true); 1403 + savingEdits=true; 1404 + MastodonAPIController.runInBackground(()->{ 1405 + try{ 1406 + AtpResponse<GetRecordResponse> getResp=session.executeBluesky((client)->{ 1407 + GetRecordQueryParams params=AtProtoClient.createGetRecordQueryParams(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.actor.profile", "self"); 1408 + return AtProtoClient.getRecord(client, params); 1409 + }); 1410 + 1411 + Profile record; 1412 + if(getResp instanceof AtpResponse.Success<GetRecordResponse> success){ 1413 + record=AtProtoClient.getProfileRecord(success.getResponse()); 1414 + }else{ 1415 + record=AtProtoClient.createProfile(null, null, null, null, null, null, Clock.System.INSTANCE.now()); 1416 + } 1417 + 1418 + String newDisplayName=nameEdit.getText().toString(); 1419 + String newDescription=bioEdit.getText().toString(); 1420 + Blob newAvatar=record.getAvatar(); 1421 + Blob newBanner=record.getBanner(); 1422 + 1423 + if(editNewAvatar!=null){ 1424 + byte[] avatarBytes=resizeAndCompressForAtProto(editNewAvatar, false); 1425 + if(avatarBytes!=null){ 1426 + AtpResponse<UploadBlobResponse> blobResp=session.executeBluesky((client)->{ 1427 + return AtProtoClient.uploadBlob(client, avatarBytes); 1428 + }); 1429 + if(blobResp instanceof AtpResponse.Success<UploadBlobResponse> success) 1430 + newAvatar=success.getResponse().getBlob(); 1431 + } 1432 + } 1433 + 1434 + if(editNewCover!=null){ 1435 + byte[] coverBytes=resizeAndCompressForAtProto(editNewCover, true); 1436 + if(coverBytes!=null){ 1437 + AtpResponse<UploadBlobResponse> blobResp=session.executeBluesky((client)->{ 1438 + return AtProtoClient.uploadBlob(client, coverBytes); 1439 + }); 1440 + if(blobResp instanceof AtpResponse.Success<UploadBlobResponse> success) 1441 + newBanner=success.getResponse().getBlob(); 1442 + } 1443 + } 1444 + 1445 + Profile finalRecord=AtProtoClient.createProfile(newDisplayName, newDescription, newAvatar, newBanner, record.getLabels(), record.getPinnedPost(), record.getCreatedAt()); 1446 + 1447 + session.executeBluesky((client)->{ 1448 + PutRecordRequest params=AtProtoClient.createPutRecordRequest(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.actor.profile", "self", AtProtoClient.toProfileJson(finalRecord)); 1449 + return AtProtoClient.putRecord(client, params); 1450 + }); 1451 + 1452 + AtpResponse<ProfileViewDetailed> refreshResp=session.executeBluesky((client)->{ 1453 + GetProfileQueryParams params=AtProtoClient.createGetProfileQueryParams(session.self.username); 1454 + return AtProtoClient.getProfile(client, params); 1455 + }); 1456 + 1457 + if(refreshResp instanceof AtpResponse.Success<ProfileViewDetailed> success){ 1458 + Account updatedAccount=AtProtoMapper.mapAccount(success.getResponse()); 1459 + 1460 + getActivity().runOnUiThread(()->{ 1461 + savingEdits=false; 1462 + account=updatedAccount; 1463 + AccountSessionManager.getInstance().updateAccountInfo(accountID, account); 1464 + exitEditMode(); 1465 + setActionProgressVisible(false); 1466 + }); 1467 + }else{ 1468 + throw new Exception("Failed to refresh profile: "+refreshResp); 1469 + } 1470 + }catch(Exception e){ 1471 + getActivity().runOnUiThread(()->{ 1472 + savingEdits=false; 1473 + new MastodonErrorResponse(e.getLocalizedMessage(), 0, e).showToast(getActivity()); 1474 + setActionProgressVisible(false); 1475 + }); 1476 + } 1477 + }); 1478 + } 1479 + 1480 + private byte[] resizeAndCompressForAtProto(Uri uri, boolean isBanner) throws IOException{ 1481 + InputStream in=getActivity().getContentResolver().openInputStream(uri); 1482 + if(in==null) return null; 1483 + BitmapFactory.Options opts=new BitmapFactory.Options(); 1484 + opts.inJustDecodeBounds=true; 1485 + BitmapFactory.decodeStream(in, null, opts); 1486 + in.close(); 1487 + 1488 + int maxDim=isBanner ? 2048 : 1024; 1489 + int w=opts.outWidth; 1490 + int h=opts.outHeight; 1491 + int sampleSize=1; 1492 + while(w/2>=maxDim || h/2>=maxDim){ 1493 + w/=2; 1494 + h/=2; 1495 + sampleSize*=2; 1496 + } 1497 + 1498 + opts.inJustDecodeBounds=false; 1499 + opts.inSampleSize=sampleSize; 1500 + in=getActivity().getContentResolver().openInputStream(uri); 1501 + if(in==null) return null; 1502 + Bitmap bmp=BitmapFactory.decodeStream(in, null, opts); 1503 + in.close(); 1504 + 1505 + if(bmp==null) return null; 1506 + 1507 + ByteArrayOutputStream baos=new ByteArrayOutputStream(); 1508 + int quality=100; 1509 + bmp.compress(Bitmap.CompressFormat.PNG, quality, baos); 1510 + while(baos.size()>950*1024 && quality>50){ 1511 + baos.reset(); 1512 + quality-=10; 1513 + bmp.compress(Bitmap.CompressFormat.JPEG, quality, baos); 1514 + } 1515 + 1516 + return baos.toByteArray(); 1517 + } 1518 + 1214 1519 private void confirmToggleMuted(){ 1215 1520 UiUtils.confirmToggleMuteUser(getActivity(), accountID, account, relationship.muting, this::updateRelationship); 1216 1521 } ··· 1395 1700 return position; 1396 1701 } 1397 1702 } 1398 - } 1703 + }
+8
mastodon/src/main/java/org/joinmastodon/android/fragments/SavedPostsTimelineFragment.java
··· 49 49 return f; 50 50 } 51 51 52 + public void setProfileAccount(Account account){ 53 + this.user=account; 54 + getArguments().putParcelable("profileAccount", Parcels.wrap(account)); 55 + if(!loaded && !dataLoading && isAdded() && !getArguments().getBoolean("noAutoLoad")){ 56 + loadData(); 57 + } 58 + } 59 + 52 60 @Override 53 61 public void onAttach(Activity activity){ 54 62 user=Parcels.unwrap(getArguments().getParcelable("profileAccount"));
+6
mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java
··· 20 20 import org.joinmastodon.android.api.requests.accounts.CheckInviteLink; 21 21 import org.joinmastodon.android.api.requests.catalog.GetCatalogDefaultInstances; 22 22 import org.joinmastodon.android.api.session.AccountSessionManager; 23 + import org.joinmastodon.android.fragments.onboarding.BlueskyLoginFragment; 23 24 import org.joinmastodon.android.fragments.onboarding.InstanceCatalogSignupFragment; 24 25 import org.joinmastodon.android.fragments.onboarding.InstanceChooserLoginFragment; 25 26 import org.joinmastodon.android.fragments.onboarding.InstanceRulesFragment; ··· 74 75 contentView=(SizeListenerFrameLayout) inflater.inflate(R.layout.fragment_splash, container, false); 75 76 contentView.findViewById(R.id.btn_get_started).setOnClickListener(this::onButtonClick); 76 77 contentView.findViewById(R.id.btn_log_in).setOnClickListener(this::onButtonClick); 78 + contentView.findViewById(R.id.btn_log_in_bluesky).setOnClickListener(this::onLoginBlueskyClick); 77 79 defaultServerButton=contentView.findViewById(R.id.btn_join_default_server); 78 80 defaultServerButton.setText(getString(R.string.join_default_server, chosenDefaultServer)); 79 81 defaultServerButton.setOnClickListener(this::onJoinDefaultServerClick); ··· 125 127 extras.putBoolean("signup", isSignup); 126 128 extras.putString("defaultServer", chosenDefaultServer); 127 129 Nav.go(getActivity(), isSignup ? InstanceCatalogSignupFragment.class : InstanceChooserLoginFragment.class, extras); 130 + } 131 + 132 + private void onLoginBlueskyClick(View v){ 133 + Nav.go(getActivity(), BlueskyLoginFragment.class, null); 128 134 } 129 135 130 136 private void onJoinDefaultServerClick(View v){
+5
mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java
··· 34 34 protected void addAccountToKnown(Status s){ 35 35 if(!knownAccounts.containsKey(s.account.id)) 36 36 knownAccounts.put(s.account.id, s.account); 37 + if(s.reblog!=null && !knownAccounts.containsKey(s.reblog.account.id)) 38 + knownAccounts.put(s.reblog.account.id, s.reblog.account); 39 + Status contentStatus=s.getContentStatus(); 40 + if(contentStatus.inReplyToAccount!=null && !knownAccounts.containsKey(contentStatus.inReplyToAccount.id)) 41 + knownAccounts.put(contentStatus.inReplyToAccount.id, contentStatus.inReplyToAccount); 37 42 } 38 43 39 44 @Override
+35
mastodon/src/main/java/org/joinmastodon/android/fragments/StatusQuotesFragment.java
··· 10 10 11 11 import me.grishka.appkit.api.SimpleCallback; 12 12 13 + import org.joinmastodon.android.api.MastodonAPIController; 14 + import org.joinmastodon.android.api.atproto.AtProtoClient; 15 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 16 + import org.joinmastodon.android.api.session.AccountSessionManager; 17 + import org.joinmastodon.android.model.Protocol; 18 + 19 + import android.app.Activity; 20 + import me.grishka.appkit.api.ErrorResponse; 21 + import org.joinmastodon.android.api.MastodonErrorResponse; 22 + 23 + import java.util.List; 24 + 25 + import sh.christian.ozone.api.response.AtpResponse; 26 + import app.bsky.feed.GetQuotesResponse; 27 + 13 28 public class StatusQuotesFragment extends StatusListFragment{ 14 29 private Status status; 30 + private String nextCursor; 15 31 16 32 @Override 17 33 public void onCreate(Bundle savedInstanceState){ ··· 23 39 24 40 @Override 25 41 protected void doLoadData(int offset, int count){ 42 + if (AccountSessionManager.get(accountID).protocol == Protocol.ATPROTO) { 43 + MastodonAPIController.runInBackground(()->{ 44 + try{ 45 + var params=AtProtoClient.createGetQuotesQueryParams(status.uri, status.cid, (long)count, offset==0 ? null : nextCursor); 46 + var resp=AccountSessionManager.get(accountID).executeBluesky(client->AtProtoClient.getQuotes(client, params)); 47 + if(resp instanceof AtpResponse.Success<GetQuotesResponse> success){ 48 + GetQuotesResponse result=success.getResponse(); 49 + List<Status> quotes=AtProtoMapper.mapQuotesList(result); 50 + nextCursor=AtProtoClient.getCursor(result); 51 + getActivity().runOnUiThread(()->onDataLoaded(quotes, nextCursor!=null)); 52 + }else{ 53 + throw new Exception("Failed to load quotes: " + resp); 54 + } 55 + }catch(Exception x){ 56 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 57 + } 58 + }); 59 + return; 60 + } 26 61 new GetStatusQuotes(status.id, offset>0 ? getMaxID() : null, count) 27 62 .setCallback(new SimpleCallback<>(this){ 28 63 @Override
+67 -1
mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java
··· 28 28 import org.joinmastodon.android.BuildConfig; 29 29 import org.joinmastodon.android.GlobalUserPreferences; 30 30 import org.joinmastodon.android.R; 31 + import org.joinmastodon.android.api.MastodonAPIController; 32 + import org.joinmastodon.android.api.MastodonErrorResponse; 33 + import org.joinmastodon.android.api.atproto.AtProtoClient; 34 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 31 35 import org.joinmastodon.android.api.requests.statuses.GetStatusContext; 36 + import org.joinmastodon.android.api.session.AccountSession; 32 37 import org.joinmastodon.android.api.session.AccountSessionManager; 33 38 import org.joinmastodon.android.model.Account; 34 39 import org.joinmastodon.android.model.AsyncRefresh; 35 40 import org.joinmastodon.android.model.FilterContext; 41 + import org.joinmastodon.android.model.Protocol; 36 42 import org.joinmastodon.android.model.Status; 37 43 import org.joinmastodon.android.model.StatusContext; 38 44 import org.joinmastodon.android.ui.OutlineProviders; ··· 61 67 import androidx.recyclerview.widget.DiffUtil; 62 68 import androidx.recyclerview.widget.ListUpdateCallback; 63 69 import androidx.recyclerview.widget.RecyclerView; 70 + import app.bsky.feed.GetPostThreadQueryParams; 71 + import app.bsky.feed.GetPostThreadResponse; 64 72 import me.grishka.appkit.Nav; 65 73 import me.grishka.appkit.api.SimpleCallback; 66 74 import me.grishka.appkit.imageloader.ViewImageLoader; ··· 69 77 import me.grishka.appkit.utils.MergeRecyclerAdapter; 70 78 import me.grishka.appkit.utils.SingleViewRecyclerAdapter; 71 79 import me.grishka.appkit.utils.V; 80 + import sh.christian.ozone.api.response.AtpResponse; 72 81 73 82 public class ThreadFragment extends StatusListFragment implements AssistContentProviderFragment{ 74 83 private Status mainStatus; ··· 164 173 165 174 @Override 166 175 protected void doLoadData(int offset, int count){ 176 + AccountSession session=AccountSessionManager.get(accountID); 177 + if(session!=null && session.protocol==Protocol.ATPROTO){ 178 + doLoadDataATProto(session); 179 + return; 180 + } 167 181 currentRequest=new GetStatusContext(mainStatus.id) 168 182 .setCallback(new SimpleCallback<>(this){ 169 183 @Override ··· 276 290 .exec(accountID); 277 291 } 278 292 293 + private void doLoadDataATProto(AccountSession session){ 294 + MastodonAPIController.runInBackground(()->{ 295 + try{ 296 + AtpResponse<GetPostThreadResponse> resp=session.executeBluesky((client)->{ 297 + GetPostThreadQueryParams params=AtProtoClient.createGetPostThreadQueryParams(mainStatus.uri, null, null); 298 + return AtProtoClient.getPostThread(client, params); 299 + }); 300 + 301 + if(resp instanceof AtpResponse.Success<GetPostThreadResponse> success){ 302 + StatusContext result=AtProtoMapper.mapThread(success.getResponse().getThread(), mainStatus.uri); 303 + 304 + getActivity().runOnUiThread(()->{ 305 + final boolean wasRefreshing=refreshing; 306 + if(refreshing){ 307 + data.clear(); 308 + displayItems.clear(); 309 + data.add(mainStatus); 310 + onAppendItems(Collections.singletonList(mainStatus)); 311 + refreshLayout.setRefreshing(false); 312 + } 313 + 314 + filterStatuses(result.descendants); 315 + filterStatuses(result.ancestors); 316 + 317 + if(footerProgress!=null) 318 + footerProgress.setVisibility(View.GONE); 319 + 320 + int prevCount=displayItems.size(); 321 + data.addAll(result.descendants); 322 + onAppendItems(result.descendants); 323 + if(!wasRefreshing) 324 + adapter.notifyItemRangeInserted(prevCount, displayItems.size()-prevCount); 325 + 326 + prependItems(result.ancestors, !wasRefreshing); 327 + 328 + if(wasRefreshing){ 329 + refreshing=false; 330 + adapter.notifyDataSetChanged(); 331 + } 332 + 333 + dataLoaded(); 334 + setRefreshEnabled(true); 335 + }); 336 + }else{ 337 + throw new Exception("Failed to load thread: " + resp); 338 + } 339 + }catch(Exception x){ 340 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 341 + } 342 + }); 343 + } 344 + 279 345 private void filterStatuses(List<Status> statuses){ 280 346 AccountSessionManager.get(accountID).filterStatuses(statuses, FilterContext.THREAD); 281 347 } ··· 562 628 } 563 629 } 564 630 } 565 - } 631 + }
+37
mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountSearchFragment.java
··· 7 7 import org.joinmastodon.android.R; 8 8 import org.joinmastodon.android.api.MastodonAPIRequest; 9 9 import org.joinmastodon.android.api.requests.search.GetSearchResults; 10 + import org.joinmastodon.android.api.session.AccountSessionManager; 10 11 import org.joinmastodon.android.model.Account; 11 12 import org.joinmastodon.android.model.SearchResults; 12 13 import org.joinmastodon.android.model.viewmodel.AccountViewModel; ··· 15 16 import org.joinmastodon.android.ui.viewholders.AccountViewHolder; 16 17 import org.parceler.Parcels; 17 18 19 + import java.util.Collections; 18 20 import java.util.List; 19 21 import java.util.stream.Collectors; 20 22 23 + import app.bsky.actor.SearchActorsResponse; 21 24 import me.grishka.appkit.Nav; 22 25 import me.grishka.appkit.api.SimpleCallback; 26 + import org.joinmastodon.android.api.MastodonAPIController; 27 + import org.joinmastodon.android.api.MastodonErrorResponse; 28 + import org.joinmastodon.android.api.atproto.AtProtoClient; 29 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 30 + import org.joinmastodon.android.model.Protocol; 31 + import sh.christian.ozone.api.response.AtpResponse; 23 32 24 33 public class AccountSearchFragment extends BaseAccountListFragment{ 25 34 protected String currentQuery; ··· 48 57 49 58 @Override 50 59 protected void doLoadData(int offset, int count){ 60 + if(AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO){ 61 + doLoadDataATProto(offset, count); 62 + return; 63 + } 51 64 refreshing=true; 52 65 currentRequest=new GetSearchResults(currentQuery, GetSearchResults.Type.ACCOUNTS, false, null, 0, 0) 53 66 .setCallback(new SimpleCallback<>(this){ ··· 57 70 } 58 71 }) 59 72 .exec(accountID); 73 + } 74 + 75 + private void doLoadDataATProto(int offset, int count){ 76 + if(TextUtils.isEmpty(currentQuery)){ 77 + onDataLoaded(Collections.emptyList(), false); 78 + return; 79 + } 80 + refreshing=true; 81 + MastodonAPIController.runInBackground(()->{ 82 + try{ 83 + AtpResponse<SearchActorsResponse> resp=AccountSessionManager.get(accountID).executeBluesky(client->{ 84 + var params=AtProtoClient.createSearchActorsQueryParams(currentQuery, (long)count, null); // AccountSearchFragment doesn't seem to support pagination via offset in doLoadData but BaseAccountListFragment might 85 + return AtProtoClient.searchActors(client, params); 86 + }); 87 + if(resp instanceof AtpResponse.Success<SearchActorsResponse> success){ 88 + var actors=AtProtoMapper.mapSearchActors(success.getResponse()); 89 + getActivity().runOnUiThread(()->onSuccess(actors)); 90 + }else{ 91 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse("Search failed", 0, null))); 92 + } 93 + }catch(Exception x){ 94 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 95 + } 96 + }); 60 97 } 61 98 62 99 protected void onSuccess(List<Account> result){
+36 -1
mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowerListFragment.java
··· 5 5 import org.joinmastodon.android.R; 6 6 import org.joinmastodon.android.api.requests.HeaderPaginationRequest; 7 7 import org.joinmastodon.android.api.requests.accounts.GetAccountFollowers; 8 + import org.joinmastodon.android.api.MastodonAPIController; 9 + import org.joinmastodon.android.api.atproto.AtProtoClient; 10 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 11 + import org.joinmastodon.android.api.session.AccountSessionManager; 8 12 import org.joinmastodon.android.model.Account; 9 13 14 + import java.util.List; 15 + import java.util.stream.Collectors; 16 + 17 + import android.app.Activity; 18 + import me.grishka.appkit.api.ErrorResponse; 19 + import org.joinmastodon.android.api.MastodonErrorResponse; 20 + import sh.christian.ozone.api.response.AtpResponse; 21 + import app.bsky.graph.GetFollowersResponse; 22 + 10 23 public class FollowerListFragment extends AccountRelatedAccountListFragment{ 11 24 12 25 @Override ··· 19 32 public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){ 20 33 return new GetAccountFollowers(account.id, maxID, count); 21 34 } 22 - } 35 + 36 + @Override 37 + protected void doLoadDataATProto(int offset, int count) { 38 + MastodonAPIController.runInBackground(()->{ 39 + try{ 40 + var params=AtProtoClient.createGetFollowersQueryParams(account.id, (long)count, offset>0 ? nextMaxID : null); 41 + var resp=AccountSessionManager.get(accountID).executeBluesky(client->AtProtoClient.getFollowers(client, params)); 42 + if(resp instanceof AtpResponse.Success<GetFollowersResponse> success){ 43 + GetFollowersResponse result=success.getResponse(); 44 + var accounts=result.getFollowers().stream() 45 + .map(AtProtoMapper::mapAccount) 46 + .collect(Collectors.toList()); 47 + var cursor=result.getCursor(); 48 + getActivity().runOnUiThread(()->onDataLoadedATProto(accounts, cursor)); 49 + }else{ 50 + throw new Exception("Failed to load followers: "+resp); 51 + } 52 + }catch(Exception x){ 53 + getActivity().runOnUiThread(() -> onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 54 + } 55 + }); 56 + } 57 + }
+36 -1
mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java
··· 5 5 import org.joinmastodon.android.R; 6 6 import org.joinmastodon.android.api.requests.HeaderPaginationRequest; 7 7 import org.joinmastodon.android.api.requests.accounts.GetAccountFollowing; 8 + import org.joinmastodon.android.api.MastodonAPIController; 9 + import org.joinmastodon.android.api.atproto.AtProtoClient; 10 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 11 + import org.joinmastodon.android.api.session.AccountSessionManager; 8 12 import org.joinmastodon.android.model.Account; 9 13 14 + import java.util.List; 15 + import java.util.stream.Collectors; 16 + 17 + import android.app.Activity; 18 + import me.grishka.appkit.api.ErrorResponse; 19 + import org.joinmastodon.android.api.MastodonErrorResponse; 20 + import sh.christian.ozone.api.response.AtpResponse; 21 + import app.bsky.graph.GetFollowsResponse; 22 + 10 23 public class FollowingListFragment extends AccountRelatedAccountListFragment{ 11 24 12 25 @Override ··· 19 32 public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){ 20 33 return new GetAccountFollowing(account.id, maxID, count); 21 34 } 22 - } 35 + 36 + @Override 37 + protected void doLoadDataATProto(int offset, int count) { 38 + MastodonAPIController.runInBackground(()->{ 39 + try{ 40 + var params=AtProtoClient.createGetFollowsQueryParams(account.id, (long)count, offset>0 ? nextMaxID : null); 41 + var resp=AccountSessionManager.get(accountID).executeBluesky(client->AtProtoClient.getFollows(client, params)); 42 + if(resp instanceof AtpResponse.Success<GetFollowsResponse> success){ 43 + GetFollowsResponse result=success.getResponse(); 44 + var accounts=result.getFollows().stream() 45 + .map(AtProtoMapper::mapAccount) 46 + .collect(Collectors.toList()); 47 + var cursor=result.getCursor(); 48 + getActivity().runOnUiThread(()->onDataLoadedATProto(accounts, cursor)); 49 + }else{ 50 + throw new Exception("Failed to load follows: " + resp); 51 + } 52 + }catch(Exception x){ 53 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 54 + } 55 + }); 56 + } 57 + }
+16 -1
mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java
··· 1 1 package org.joinmastodon.android.fragments.account_list; 2 2 3 3 import org.joinmastodon.android.api.requests.HeaderPaginationRequest; 4 + import org.joinmastodon.android.api.session.AccountSessionManager; 4 5 import org.joinmastodon.android.model.Account; 6 + import org.joinmastodon.android.model.Protocol; 5 7 import org.joinmastodon.android.model.HeaderPaginationList; 6 8 import org.joinmastodon.android.model.viewmodel.AccountViewModel; 7 9 10 + import java.util.List; 8 11 import java.util.stream.Collectors; 9 12 10 13 import me.grishka.appkit.api.SimpleCallback; 11 14 12 15 public abstract class PaginatedAccountListFragment extends BaseAccountListFragment{ 13 - private String nextMaxID; 16 + protected String nextMaxID; 14 17 15 18 public abstract HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count); 16 19 17 20 @Override 18 21 protected void doLoadData(int offset, int count){ 22 + if (AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO) { 23 + doLoadDataATProto(offset, count); 24 + return; 25 + } 19 26 currentRequest=onCreateRequest(offset==0 ? null : nextMaxID, count) 20 27 .setCallback(new SimpleCallback<>(this){ 21 28 @Override ··· 28 35 } 29 36 }) 30 37 .exec(accountID); 38 + } 39 + 40 + protected void doLoadDataATProto(int offset, int count) { 41 + } 42 + 43 + protected void onDataLoadedATProto(List<Account> accounts, String cursor) { 44 + nextMaxID=cursor; 45 + onDataLoaded(accounts.stream().map(a->new AccountViewModel(a, accountID, getActivity())).collect(Collectors.toList()), nextMaxID!=null); 31 46 } 32 47 33 48 @Override
+33
mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java
··· 7 7 import org.joinmastodon.android.api.requests.statuses.GetStatusFavorites; 8 8 import org.joinmastodon.android.model.Account; 9 9 10 + import org.joinmastodon.android.api.MastodonAPIController; 11 + import org.joinmastodon.android.api.atproto.AtProtoClient; 12 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 13 + import org.joinmastodon.android.api.session.AccountSessionManager; 14 + 15 + import java.util.List; 16 + 17 + import android.app.Activity; 18 + import me.grishka.appkit.api.ErrorResponse; 19 + import org.joinmastodon.android.api.MastodonErrorResponse; 20 + import sh.christian.ozone.api.response.AtpResponse; 21 + import app.bsky.feed.GetLikesResponse; 22 + 10 23 public class StatusFavoritesListFragment extends StatusRelatedAccountListFragment{ 11 24 @Override 12 25 public void onCreate(Bundle savedInstanceState){ ··· 17 30 @Override 18 31 public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){ 19 32 return new GetStatusFavorites(status.id, maxID, count); 33 + } 34 + 35 + @Override 36 + protected void doLoadDataATProto(int offset, int count) { 37 + MastodonAPIController.runInBackground(()->{ 38 + try{ 39 + var params=AtProtoClient.createGetLikesQueryParams(status.uri, status.cid, (long)count, offset==0 ? null : nextMaxID); 40 + var resp=AccountSessionManager.get(accountID).executeBluesky(client->AtProtoClient.getLikes(client, params)); 41 + if(resp instanceof AtpResponse.Success<GetLikesResponse> success){ 42 + GetLikesResponse result=success.getResponse(); 43 + List<Account> accounts=AtProtoMapper.mapLikes(result); 44 + String cursor=AtProtoClient.getCursor(result); 45 + getActivity().runOnUiThread(()->onDataLoadedATProto(accounts, cursor)); 46 + }else{ 47 + throw new Exception("Failed to load likes: "+resp); 48 + } 49 + }catch(Exception x){ 50 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 51 + } 52 + }); 20 53 } 21 54 }
+33
mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java
··· 7 7 import org.joinmastodon.android.api.requests.statuses.GetStatusReblogs; 8 8 import org.joinmastodon.android.model.Account; 9 9 10 + import org.joinmastodon.android.api.MastodonAPIController; 11 + import org.joinmastodon.android.api.atproto.AtProtoClient; 12 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 13 + import org.joinmastodon.android.api.session.AccountSessionManager; 14 + 15 + import java.util.List; 16 + 17 + import android.app.Activity; 18 + import me.grishka.appkit.api.ErrorResponse; 19 + import org.joinmastodon.android.api.MastodonErrorResponse; 20 + import sh.christian.ozone.api.response.AtpResponse; 21 + import app.bsky.feed.GetRepostedByResponse; 22 + 10 23 public class StatusReblogsListFragment extends StatusRelatedAccountListFragment{ 11 24 @Override 12 25 public void onCreate(Bundle savedInstanceState){ ··· 17 30 @Override 18 31 public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){ 19 32 return new GetStatusReblogs(status.id, maxID, count); 33 + } 34 + 35 + @Override 36 + protected void doLoadDataATProto(int offset, int count) { 37 + MastodonAPIController.runInBackground(()->{ 38 + try{ 39 + var params=AtProtoClient.createGetRepostedByQueryParams(status.uri, status.cid, (long)count, offset==0 ? null : nextMaxID); 40 + var resp=AccountSessionManager.get(accountID).executeBluesky(client->AtProtoClient.getRepostedBy(client, params)); 41 + if(resp instanceof AtpResponse.Success<GetRepostedByResponse> success){ 42 + GetRepostedByResponse result=success.getResponse(); 43 + List<Account> accounts=AtProtoMapper.mapRepostedBy(result); 44 + String cursor=AtProtoClient.getCursor(result); 45 + getActivity().runOnUiThread(()->onDataLoadedATProto(accounts, cursor)); 46 + }else{ 47 + throw new Exception("Failed to load reposts: "+resp); 48 + } 49 + }catch(Exception x){ 50 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 51 + } 52 + }); 20 53 } 21 54 }
+111
mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java
··· 7 7 8 8 import org.joinmastodon.android.R; 9 9 import org.joinmastodon.android.api.requests.search.GetSearchResults; 10 + import org.joinmastodon.android.api.session.AccountSession; 10 11 import org.joinmastodon.android.api.session.AccountSessionManager; 11 12 import org.joinmastodon.android.fragments.BaseStatusListFragment; 12 13 import org.joinmastodon.android.fragments.ProfileFragment; ··· 29 30 import java.util.Set; 30 31 import java.util.stream.Collectors; 31 32 33 + import app.bsky.actor.SearchActorsResponse; 34 + import app.bsky.feed.SearchPostsResponse; 32 35 import me.grishka.appkit.Nav; 33 36 import me.grishka.appkit.api.SimpleCallback; 37 + import org.joinmastodon.android.api.MastodonAPIController; 38 + import org.joinmastodon.android.api.MastodonErrorResponse; 39 + import org.joinmastodon.android.api.atproto.AtProtoClient; 40 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 41 + import org.joinmastodon.android.model.Protocol; 42 + import sh.christian.ozone.api.response.AtpResponse; 34 43 35 44 public class SearchFragment extends BaseStatusListFragment<SearchResult>{ 36 45 private String currentQuery; ··· 38 47 private EnumSet<SearchResult.Type> currentFilter=EnumSet.allOf(SearchResult.Type.class); 39 48 private List<SearchResult> unfilteredResults=Collections.emptyList(); 40 49 private InputMethodManager imm; 50 + private String nextCursor; 41 51 42 52 public SearchFragment(){ 43 53 setLayout(R.layout.fragment_search); ··· 112 122 113 123 @Override 114 124 protected void doLoadData(int _offset, int count){ 125 + if(AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO){ 126 + doLoadDataATProto(_offset, count); 127 + return; 128 + } 115 129 GetSearchResults.Type type; 116 130 if(currentFilter.size()==1){ 117 131 type=switch(currentFilter.iterator().next()){ ··· 169 183 } 170 184 }) 171 185 .exec(accountID); 186 + } 187 + 188 + private void doLoadDataATProto(int offset, int count){ 189 + AccountSession session=AccountSessionManager.get(accountID); 190 + MastodonAPIController.runInBackground(()->{ 191 + try{ 192 + SearchResult.Type filter=null; 193 + if(currentFilter.size()==1) 194 + filter=currentFilter.iterator().next(); 195 + 196 + String cursor=offset>0 ? nextCursor : null; 197 + 198 + if(filter==SearchResult.Type.ACCOUNT){ 199 + AtpResponse<SearchActorsResponse> resp=session.executeBluesky(client->{ 200 + var params=AtProtoClient.createSearchActorsQueryParams(currentQuery, (long)count, cursor); 201 + return AtProtoClient.searchActors(client, params); 202 + }); 203 + if(resp instanceof AtpResponse.Success<SearchActorsResponse> success){ 204 + var result=success.getResponse(); 205 + var accounts=AtProtoMapper.mapSearchActors(result); 206 + nextCursor=AtProtoClient.getCursor(result); 207 + var searchResults=accounts.stream().map(SearchResult::new).collect(Collectors.toList()); 208 + getActivity().runOnUiThread(()->onDataLoaded(searchResults, nextCursor!=null)); 209 + }else{ 210 + throw new Exception("Search failed: "+resp); 211 + } 212 + }else if(filter==SearchResult.Type.STATUS || filter==SearchResult.Type.HASHTAG){ 213 + String q=currentQuery; 214 + if(filter==SearchResult.Type.HASHTAG && !q.startsWith("#")) 215 + q="#"+q; 216 + final String finalQ=q; 217 + AtpResponse<SearchPostsResponse> resp=session.executeBluesky(client->{ 218 + var params=AtProtoClient.createSearchPostsQueryParams(finalQ, (long)count, cursor); 219 + return AtProtoClient.searchPosts(client, params); 220 + }); 221 + if(resp instanceof AtpResponse.Success<SearchPostsResponse> success){ 222 + var result=success.getResponse(); 223 + var posts=AtProtoMapper.mapSearchPosts(result); 224 + nextCursor=AtProtoClient.getCursor(result); 225 + var searchResults=posts.stream().map(SearchResult::new).collect(Collectors.toList()); 226 + getActivity().runOnUiThread(()->onDataLoaded(searchResults, nextCursor!=null)); 227 + }else{ 228 + throw new Exception("Search failed: "+resp); 229 + } 230 + }else{ 231 + // Mixed search 232 + if(offset==0){ 233 + AtpResponse<SearchActorsResponse> actorsResp=session.executeBluesky(client->{ 234 + var params=AtProtoClient.createSearchActorsQueryParams(currentQuery, 5L, null); 235 + return AtProtoClient.searchActors(client, params); 236 + }); 237 + AtpResponse<SearchPostsResponse> postsResp=session.executeBluesky(client->{ 238 + var params=AtProtoClient.createSearchPostsQueryParams(currentQuery, (long)count, null); 239 + return AtProtoClient.searchPosts(client, params); 240 + }); 241 + 242 + List<SearchResult> results=new ArrayList<>(); 243 + if(actorsResp instanceof AtpResponse.Success<SearchActorsResponse> success){ 244 + var actors=AtProtoMapper.mapSearchActors(success.getResponse()); 245 + results.addAll(actors.stream().map(SearchResult::new).collect(Collectors.toList())); 246 + } 247 + String _nextCursor=null; 248 + if(postsResp instanceof AtpResponse.Success<SearchPostsResponse> success){ 249 + var posts=AtProtoMapper.mapSearchPosts(success.getResponse()); 250 + results.addAll(posts.stream().map(SearchResult::new).collect(Collectors.toList())); 251 + _nextCursor=AtProtoClient.getCursor(success.getResponse()); 252 + } 253 + final String finalNextCursor=_nextCursor; 254 + getActivity().runOnUiThread(()->{ 255 + nextCursor=finalNextCursor; 256 + onDataLoaded(results, nextCursor!=null); 257 + }); 258 + }else{ 259 + AtpResponse<SearchPostsResponse> resp=session.executeBluesky(client->{ 260 + var params=AtProtoClient.createSearchPostsQueryParams(currentQuery, (long)count, cursor); 261 + return AtProtoClient.searchPosts(client, params); 262 + }); 263 + if(resp instanceof AtpResponse.Success<SearchPostsResponse> success){ 264 + var posts=AtProtoMapper.mapSearchPosts(success.getResponse()); 265 + nextCursor=AtProtoClient.getCursor(success.getResponse()); 266 + var results=posts.stream().map(SearchResult::new).collect(Collectors.toList()); 267 + getActivity().runOnUiThread(()->onDataLoaded(results, nextCursor!=null)); 268 + }else{ 269 + throw new Exception("Search failed: "+resp); 270 + } 271 + } 272 + } 273 + }catch(Exception x){ 274 + getActivity().runOnUiThread(()->onError(new MastodonErrorResponse(x.getLocalizedMessage(), 0, x))); 275 + } 276 + }); 277 + } 278 + 279 + @Override 280 + public void onRefresh(){ 281 + nextCursor=null; 282 + super.onRefresh(); 172 283 } 173 284 174 285 @Override
+53
mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java
··· 27 27 import org.joinmastodon.android.MainActivity; 28 28 import org.joinmastodon.android.R; 29 29 import org.joinmastodon.android.api.requests.search.GetSearchResults; 30 + import org.joinmastodon.android.api.session.AccountSession; 30 31 import org.joinmastodon.android.api.session.AccountSessionManager; 31 32 import org.joinmastodon.android.fragments.MastodonRecyclerFragment; 32 33 import org.joinmastodon.android.model.Relationship; ··· 44 45 45 46 import java.util.ArrayList; 46 47 import java.util.HashMap; 48 + import java.util.List; 47 49 import java.util.function.Function; 48 50 import java.util.regex.Matcher; 49 51 import java.util.regex.Pattern; ··· 54 56 import androidx.annotation.NonNull; 55 57 import androidx.annotation.RequiresApi; 56 58 import androidx.recyclerview.widget.RecyclerView; 59 + import app.bsky.actor.SearchActorsTypeaheadResponse; 57 60 import me.grishka.appkit.Nav; 58 61 import me.grishka.appkit.api.SimpleCallback; 59 62 import me.grishka.appkit.fragments.CustomTransitionsFragment; ··· 62 65 import me.grishka.appkit.utils.MergeRecyclerAdapter; 63 66 import me.grishka.appkit.utils.V; 64 67 import me.grishka.appkit.views.UsableRecyclerView; 68 + import org.joinmastodon.android.api.MastodonAPIController; 69 + import org.joinmastodon.android.api.atproto.AtProtoClient; 70 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 71 + import org.joinmastodon.android.model.Protocol; 72 + import sh.christian.ozone.api.response.AtpResponse; 65 73 66 74 public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultViewModel> implements CustomTransitionsFragment{ 67 75 private static final Pattern HASHTAG_REGEX=Pattern.compile("^(\\w*[a-zA-Z·]\\w*)$", Pattern.CASE_INSENSITIVE); ··· 106 114 107 115 @Override 108 116 protected void doLoadData(int offset, int count){ 117 + if(AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO){ 118 + doLoadDataATProto(offset, count); 119 + return; 120 + } 109 121 if(isInRecentMode()){ 110 122 AccountSessionManager.getInstance().getAccount(accountID).getCacheController().getRecentSearches(results->{ 111 123 if(getActivity()==null) ··· 140 152 } 141 153 }) 142 154 .exec(accountID); 155 + } 156 + } 157 + 158 + private void doLoadDataATProto(int offset, int count){ 159 + if(isInRecentMode()){ 160 + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().getRecentSearches(results->{ 161 + if(getActivity()==null) 162 + return; 163 + 164 + onDataLoaded(results.stream().map(sr->{ 165 + SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, true, getActivity()); 166 + if(sr.type==SearchResult.Type.HASHTAG){ 167 + vm.hashtagItem.setOnClick(i->openHashtag(sr)); 168 + } 169 + return vm; 170 + }).collect(Collectors.toList()), false); 171 + recentsHeader.setVisible(!data.isEmpty()); 172 + }); 173 + }else{ 174 + AccountSession session=AccountSessionManager.get(accountID); 175 + MastodonAPIController.runInBackground(()->{ 176 + try{ 177 + AtpResponse<SearchActorsTypeaheadResponse> actorsResp=session.executeBluesky(client->{ 178 + var params=AtProtoClient.createSearchActorsTypeaheadQueryParams(currentQuery, 10L); 179 + return AtProtoClient.searchActorsTypeahead(client, params); 180 + }); 181 + 182 + List<SearchResult> results=new ArrayList<>(); 183 + if(actorsResp instanceof AtpResponse.Success<SearchActorsTypeaheadResponse> success){ 184 + var actors=AtProtoMapper.mapSearchActorsTypeahead(success.getResponse()); 185 + results.addAll(actors.stream().map(SearchResult::new).collect(Collectors.toList())); 186 + } 187 + 188 + getActivity().runOnUiThread(()->{ 189 + onDataLoaded(results.stream() 190 + .map(sr->new SearchResultViewModel(sr, accountID, false, getActivity())) 191 + .collect(Collectors.toList()), false); 192 + recentsHeader.setVisible(false); 193 + }); 194 + }catch(Exception ignore){} 195 + }); 143 196 } 144 197 } 145 198
+173
mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/BlueskyLoginFragment.java
··· 1 + package org.joinmastodon.android.fragments.onboarding; 2 + 3 + import android.app.ProgressDialog; 4 + import android.content.Intent; 5 + import android.os.Bundle; 6 + import android.text.TextUtils; 7 + import android.util.Log; 8 + import android.view.LayoutInflater; 9 + import android.view.View; 10 + import android.view.ViewGroup; 11 + import android.view.WindowInsets; 12 + import android.widget.Button; 13 + import android.widget.EditText; 14 + import android.widget.Toast; 15 + 16 + import org.joinmastodon.android.MainActivity; 17 + import org.joinmastodon.android.R; 18 + import org.joinmastodon.android.api.MastodonAPIController; 19 + import org.joinmastodon.android.api.atproto.AtProtoClient; 20 + import org.joinmastodon.android.api.session.AccountSession; 21 + import org.joinmastodon.android.api.session.AccountSessionManager; 22 + import org.joinmastodon.android.model.Account; 23 + import org.joinmastodon.android.model.Application; 24 + import org.joinmastodon.android.model.AtProtoInstance; 25 + import org.joinmastodon.android.model.Instance; 26 + import org.joinmastodon.android.model.Protocol; 27 + import org.joinmastodon.android.model.Token; 28 + import org.joinmastodon.android.ui.utils.SimpleTextWatcher; 29 + import org.joinmastodon.android.ui.utils.UiUtils; 30 + import org.joinmastodon.android.ui.views.ProgressBarButton; 31 + 32 + import java.time.Instant; 33 + import java.util.ArrayList; 34 + 35 + import androidx.annotation.Nullable; 36 + 37 + import com.atproto.server.CreateSessionRequest; 38 + import com.atproto.server.CreateSessionResponse; 39 + 40 + import io.ktor.client.HttpClient; 41 + import kotlin.coroutines.EmptyCoroutineContext; 42 + import kotlinx.coroutines.BuildersKt; 43 + import me.grishka.appkit.fragments.ToolbarFragment; 44 + import sh.christian.ozone.XrpcBlueskyApi; 45 + import sh.christian.ozone.api.response.AtpResponse; 46 + 47 + public class BlueskyLoginFragment extends ToolbarFragment{ 48 + private EditText serverUrlEdit, handleEdit, passwordEdit; 49 + private Button loginBtn; 50 + private View buttonBar; 51 + private ProgressDialog progressDialog; 52 + 53 + @Override 54 + public void onCreate(Bundle savedInstanceState){ 55 + super.onCreate(savedInstanceState); 56 + setTitle(R.string.login_title); 57 + } 58 + 59 + @Nullable 60 + @Override 61 + public View onCreateContentView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){ 62 + View view=inflater.inflate(R.layout.fragment_bluesky_login, container, false); 63 + serverUrlEdit=view.findViewById(R.id.server_url); 64 + handleEdit=view.findViewById(R.id.handle); 65 + passwordEdit=view.findViewById(R.id.password); 66 + loginBtn=view.findViewById(R.id.btn_next); 67 + buttonBar=view.findViewById(R.id.button_bar); 68 + 69 + loginBtn.setOnClickListener(v->doLogin()); 70 + 71 + SimpleTextWatcher tw=new SimpleTextWatcher(e->updateButtonState()); 72 + serverUrlEdit.addTextChangedListener(tw); 73 + handleEdit.addTextChangedListener(tw); 74 + passwordEdit.addTextChangedListener(tw); 75 + updateButtonState(); 76 + 77 + return view; 78 + } 79 + 80 + private void updateButtonState(){ 81 + loginBtn.setEnabled(!TextUtils.isEmpty(serverUrlEdit.getText()) && !TextUtils.isEmpty(handleEdit.getText()) && !TextUtils.isEmpty(passwordEdit.getText())); 82 + } 83 + 84 + private void doLogin(){ 85 + String serverUrl=serverUrlEdit.getText().toString().trim(); 86 + String handle=handleEdit.getText().toString().trim(); 87 + String password=passwordEdit.getText().toString(); 88 + 89 + if(!serverUrl.startsWith("http://") && !serverUrl.startsWith("https://")) 90 + serverUrl="https://"+serverUrl; 91 + 92 + final String _serverUrl=serverUrl; 93 + 94 + showProgressDialog(); 95 + MastodonAPIController.runInBackground(()->{ 96 + try{ 97 + CreateSessionRequest req = new CreateSessionRequest(handle, password, null, null); 98 + 99 + HttpClient httpClient = AtProtoClient.createHttpClient(_serverUrl); 100 + XrpcBlueskyApi api = AtProtoClient.createXrpcBlueskyApi(httpClient); 101 + AtpResponse<CreateSessionResponse> resp = BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, (scope, continuation) -> api.createSession(req, continuation)); 102 + 103 + getActivity().runOnUiThread(()->{ 104 + progressDialog.dismiss(); 105 + 106 + if (resp instanceof AtpResponse.Success<CreateSessionResponse> success) { 107 + CreateSessionResponse data=success.getResponse(); 108 + Token token=new Token(); 109 + token.accessToken=AtProtoClient.getAccessJwt(data); 110 + token.refreshToken=AtProtoClient.getRefreshJwt(data); 111 + token.tokenType="Bearer"; 112 + token.createdAt=System.currentTimeMillis()/1000; 113 + 114 + // Will be updated later 115 + Account self=new Account(); 116 + self.id=AtProtoClient.getDid(data); 117 + self.username=AtProtoClient.getHandle(data); 118 + self.acct=self.username; 119 + self.displayName=self.username; 120 + self.url="https://bsky.app/profile/"+self.username; 121 + self.createdAt=Instant.EPOCH; 122 + self.avatar=self.avatarStatic=self.header=self.headerStatic=self.note=""; 123 + self.emojis=new ArrayList<>(); 124 + self.fields=new ArrayList<>(); 125 + 126 + AtProtoInstance instance=new AtProtoInstance(); 127 + instance.domain=_serverUrl.replace("https://", "").replace("http://", ""); 128 + 129 + Application app=new Application(); 130 + app.name="MastodonAT for Android"; 131 + 132 + AccountSession session=new AccountSession(token, self, app, instance.getDomain(), true, null, Protocol.ATPROTO); 133 + AccountSessionManager.getInstance().addInstance(instance); 134 + AccountSessionManager.getInstance().addAccount(session); 135 + AccountSessionManager.getInstance().updateInstanceInfo(instance.getDomain()); 136 + AccountSessionManager.get(session.getID()).updateAccountInfo(); 137 + 138 + Intent intent=new Intent(getActivity(), MainActivity.class); 139 + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); 140 + startActivity(intent); 141 + } else { 142 + progressDialog.dismiss(); 143 + String msg = "Login failed"; 144 + if (resp instanceof AtpResponse.Failure failure) { 145 + Log.e("BlueskyLogin", "API call failed: " + failure); 146 + if (failure.getError() != null) msg = AtProtoClient.getMessage(failure); 147 + } 148 + throw new RuntimeException(msg); 149 + } 150 + }); 151 + }catch(Exception x){ 152 + getActivity().runOnUiThread(()->{ 153 + progressDialog.dismiss(); 154 + Toast.makeText(getActivity(), x.getLocalizedMessage(), Toast.LENGTH_LONG).show(); 155 + }); 156 + } 157 + }); 158 + } 159 + 160 + private void showProgressDialog(){ 161 + if(progressDialog==null){ 162 + progressDialog=new ProgressDialog(getActivity()); 163 + progressDialog.setMessage(getString(R.string.loading)); 164 + progressDialog.setCancelable(false); 165 + } 166 + progressDialog.show(); 167 + } 168 + 169 + @Override 170 + public void onApplyWindowInsets(WindowInsets insets){ 171 + super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets)); 172 + } 173 + }
+1
mastodon/src/main/java/org/joinmastodon/android/model/Account.java
··· 133 133 */ 134 134 public Instant muteExpiresAt; 135 135 public boolean noindex; 136 + public String pinnedPostUri; 136 137 137 138 138 139 @Override
+83
mastodon/src/main/java/org/joinmastodon/android/model/AtProtoInstance.java
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import org.parceler.Parcel; 4 + 5 + import java.util.List; 6 + 7 + @Parcel 8 + public class AtProtoInstance extends Instance{ 9 + public String domain; 10 + public List<String> availableUserDomains; 11 + public boolean inviteCodeRequired; 12 + public String contactEmail; 13 + 14 + public AtProtoInstance(){ 15 + title="Bluesky"; 16 + description="Bluesky / AT Protocol"; 17 + version="1.0.0"; 18 + languages=List.of("en"); 19 + 20 + configuration=new Configuration(); 21 + configuration.statuses=new StatusesConfiguration(); 22 + configuration.statuses.maxCharacters=300; 23 + configuration.statuses.maxMediaAttachments=4; 24 + configuration.statuses.charactersReservedPerUrl=23; 25 + 26 + configuration.mediaAttachments=new MediaAttachmentsConfiguration(); 27 + configuration.mediaAttachments.imageSizeLimit=10*1024*1024; 28 + configuration.mediaAttachments.supportedMimeTypes=List.of("*/*"); 29 + 30 + configuration.polls=new PollsConfiguration(); 31 + configuration.polls.maxOptions=4; 32 + configuration.polls.maxCharactersPerOption=100; 33 + configuration.polls.minExpiration=300; 34 + configuration.polls.maxExpiration=30*24*3600; 35 + 36 + configuration.urls=new URLsConfiguration(); 37 + } 38 + 39 + @Override 40 + public String getDomain() { 41 + return domain; 42 + } 43 + 44 + @Override 45 + public Account getContactAccount() { 46 + return null; 47 + } 48 + 49 + @Override 50 + public String getContactEmail() { 51 + return contactEmail != null ? contactEmail : ""; 52 + } 53 + 54 + @Override 55 + public boolean areRegistrationsOpen() { 56 + return true; 57 + } 58 + 59 + @Override 60 + public boolean isSignupReasonRequired() { 61 + return false; 62 + } 63 + 64 + @Override 65 + public boolean areInvitesEnabled() { 66 + return inviteCodeRequired; 67 + } 68 + 69 + @Override 70 + public String getThumbnailURL() { 71 + return null; 72 + } 73 + 74 + @Override 75 + public int getVersion() { 76 + return 3; 77 + } 78 + 79 + @Override 80 + public long getApiVersion(String name) { 81 + return 7; // Pretend to be a modern Mastodon for some feature checks (like quote posts) 82 + } 83 + }
+1
mastodon/src/main/java/org/joinmastodon/android/model/Attachment.java
··· 30 30 public Metadata meta; 31 31 32 32 public transient Drawable blurhashPlaceholder; 33 + public transient sh.christian.ozone.api.model.Blob atProtoBlob; 33 34 34 35 public Attachment(){} 35 36
+10
mastodon/src/main/java/org/joinmastodon/android/model/Protocol.java
··· 1 + package org.joinmastodon.android.model; 2 + 3 + import com.google.gson.annotations.SerializedName; 4 + 5 + public enum Protocol{ 6 + @SerializedName("mastodon") 7 + MASTODON, 8 + @SerializedName("atproto") 9 + ATPROTO 10 + }
+2
mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java
··· 19 19 public boolean domainBlocking; 20 20 public boolean blockedBy; 21 21 public String note; 22 + public String atProtoFollowingUri; 22 23 23 24 public boolean canFollow(){ 24 25 return !(following || blocking || blockedBy || domainBlocking); ··· 40 41 ", domainBlocking="+domainBlocking+ 41 42 ", blockedBy="+blockedBy+ 42 43 ", note='"+note+'\''+ 44 + ", followingUri='"+atProtoFollowingUri+'\''+ 43 45 '}'; 44 46 } 45 47 }
+7
mastodon/src/main/java/org/joinmastodon/android/model/Status.java
··· 20 20 public class Status extends BaseModel implements DisplayItemsParent{ 21 21 @RequiredField 22 22 public String id; 23 + // for atproto 24 + public String cid; 23 25 @RequiredField 24 26 public String uri; 25 27 @RequiredField ··· 51 53 public String url; 52 54 public String inReplyToId; 53 55 public String inReplyToAccountId; 56 + public Account inReplyToAccount; 54 57 public Status reblog; 55 58 public Poll poll; 56 59 public Card card; ··· 72 75 public transient TranslationState translationState=TranslationState.HIDDEN; 73 76 public transient Translation translation; 74 77 78 + public String atProtoLikeUri; 79 + public String atProtoRepostUri; 75 80 public Status(){} 76 81 77 82 @Override ··· 150 155 ", strippedText='"+strippedText+'\''+ 151 156 ", translationState="+translationState+ 152 157 ", translation="+translation+ 158 + ", atProtoLikeUri='"+atProtoLikeUri+'\''+ 159 + ", atProtoRepostUri='"+atProtoRepostUri+'\''+ 153 160 '}'; 154 161 } 155 162
+4
mastodon/src/main/java/org/joinmastodon/android/model/Token.java
··· 12 12 @RequiredField 13 13 public String accessToken; 14 14 /** 15 + * The refresh token, if any. Used by ATProto. 16 + */ 17 + public String refreshToken; 18 + /** 15 19 * The OAuth token type. Mastodon uses Bearer tokens. 16 20 */ 17 21 public String tokenType;
+4 -17
mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java
··· 218 218 activity.getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, item.status.url)); 219 219 UiUtils.maybeShowTextCopiedToast(activity); 220 220 }else if(id==R.id.pin){ 221 - new SetStatusPinned(item.status.id, !item.status.pinned) 222 - .setCallback(new Callback<>(){ 223 - @Override 224 - public void onSuccess(Status result){ 225 - item.status.pinned=!item.status.pinned; 226 - new Snackbar.Builder(activity) 227 - .setText(item.status.pinned ? R.string.post_pinned : R.string.post_unpinned) 228 - .show(); 229 - } 230 - 231 - @Override 232 - public void onError(ErrorResponse error){ 233 - error.showToast(activity); 234 - } 235 - }) 236 - .wrapProgress(activity, R.string.loading, true) 237 - .exec(item.accountID); 221 + AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setPinned(item.status, !item.status.pinned); 222 + new Snackbar.Builder(activity) 223 + .setText(item.status.pinned ? R.string.post_pinned : R.string.post_unpinned) 224 + .show(); 238 225 }else if(id==R.id.mute_conversation){ 239 226 new SetStatusConversationMuted(item.status.id, !item.status.muted) 240 227 .setCallback(new Callback<>(){
+3 -1
mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/NotificationHeaderStatusDisplayItem.java
··· 205 205 case FOLLOW, FOLLOW_REQUEST -> R.drawable.ic_person_add_fill1_24px; 206 206 case POLL -> R.drawable.ic_insert_chart_fill1_24px; 207 207 case UPDATE, QUOTED_UPDATE -> R.drawable.ic_edit_24px; 208 + case QUOTE -> R.drawable.ic_format_quote_fill1_24px; 209 + case MENTION -> R.drawable.ic_alternate_email_24px; 208 210 default -> throw new IllegalStateException("Unexpected value: "+item.notification.notification.type); 209 211 }); 210 212 icon.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(item.context, switch(item.notification.notification.type){ 211 213 case FAVORITE -> R.attr.colorFavorite; 212 214 case REBLOG -> R.attr.colorBoost; 213 - case FOLLOW, FOLLOW_REQUEST, UPDATE -> R.attr.colorM3Primary; 215 + case FOLLOW, FOLLOW_REQUEST, UPDATE, QUOTE, MENTION -> R.attr.colorM3Primary; 214 216 default -> R.attr.colorM3Outline; 215 217 }))); 216 218 itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), item.notification.status==null ? V.dp(12) : 0);
+183 -77
mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java
··· 4 4 import android.annotation.TargetApi; 5 5 import android.app.Activity; 6 6 import android.app.UiModeManager; 7 + import android.app.ProgressDialog; 7 8 import android.content.ActivityNotFoundException; 8 9 import android.content.ClipData; 9 10 import android.content.ComponentName; ··· 61 62 import org.joinmastodon.android.MainActivity; 62 63 import org.joinmastodon.android.MastodonApp; 63 64 import org.joinmastodon.android.R; 65 + import org.joinmastodon.android.api.MastodonAPIController; 66 + import org.joinmastodon.android.api.MastodonErrorResponse; 67 + import org.joinmastodon.android.api.atproto.AtProtoClient; 64 68 import org.joinmastodon.android.api.requests.accounts.SetAccountBlocked; 65 69 import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; 66 70 import org.joinmastodon.android.api.requests.accounts.SetAccountMuted; ··· 68 72 import org.joinmastodon.android.api.requests.search.GetSearchResults; 69 73 import org.joinmastodon.android.api.requests.statuses.DeleteStatus; 70 74 import org.joinmastodon.android.api.requests.statuses.GetStatusByID; 75 + import org.joinmastodon.android.api.session.AccountSession; 71 76 import org.joinmastodon.android.api.session.AccountSessionManager; 72 77 import org.joinmastodon.android.events.RemoveAccountPostsEvent; 73 78 import org.joinmastodon.android.events.StatusDeletedEvent; ··· 77 82 import org.joinmastodon.android.model.Account; 78 83 import org.joinmastodon.android.model.Emoji; 79 84 import org.joinmastodon.android.model.Hashtag; 85 + import org.joinmastodon.android.model.Protocol; 80 86 import org.joinmastodon.android.model.Relationship; 81 87 import org.joinmastodon.android.model.SearchResults; 82 88 import org.joinmastodon.android.model.Status; ··· 116 122 import androidx.browser.customtabs.CustomTabsIntent; 117 123 import androidx.recyclerview.widget.DiffUtil; 118 124 import androidx.recyclerview.widget.RecyclerView; 125 + 126 + import com.atproto.repo.CreateRecordRequest; 127 + import com.atproto.repo.CreateRecordResponse; 128 + import com.atproto.repo.DeleteRecordRequest; 129 + 130 + import app.bsky.graph.Follow; 131 + import kotlinx.datetime.Clock; 119 132 import me.grishka.appkit.Nav; 120 133 import me.grishka.appkit.api.Callback; 121 134 import me.grishka.appkit.api.ErrorResponse; ··· 125 138 import me.grishka.appkit.utils.CubicBezierInterpolator; 126 139 import me.grishka.appkit.utils.V; 127 140 import okhttp3.MediaType; 141 + import sh.christian.ozone.api.response.AtpResponse; 128 142 129 143 public class UiUtils{ 130 144 private static Handler mainHandler=new Handler(Looper.getMainLooper()); ··· 139 153 Intent intent; 140 154 if(GlobalUserPreferences.useCustomTabs){ 141 155 intent=new CustomTabsIntent.Builder() 142 - .setShowTitle(true) 143 - .build() 144 - .intent; 156 + .setShowTitle(true) 157 + .build() 158 + .intent; 145 159 }else{ 146 160 intent=new Intent(Intent.ACTION_VIEW); 147 161 } ··· 453 467 public static void confirmToggleBlockUser(Activity activity, String accountID, Account account, boolean currentlyBlocked, Consumer<Relationship> resultCallback){ 454 468 if(!currentlyBlocked){ 455 469 new BlockAccountConfirmationSheet(activity, account, (onSuccess, onError)->{ 456 - new SetAccountBlocked(account.id, true) 457 - .setCallback(new Callback<>(){ 458 - @Override 459 - public void onSuccess(Relationship result){ 460 - resultCallback.accept(result); 461 - onSuccess.run(); 462 - E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); 463 - } 470 + new SetAccountBlocked(account.id, true) 471 + .setCallback(new Callback<>(){ 472 + @Override 473 + public void onSuccess(Relationship result){ 474 + resultCallback.accept(result); 475 + onSuccess.run(); 476 + E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); 477 + } 464 478 465 - @Override 466 - public void onError(ErrorResponse error){ 467 - error.showToast(activity); 468 - onError.run(); 469 - } 479 + @Override 480 + public void onError(ErrorResponse error){ 481 + error.showToast(activity); 482 + onError.run(); 483 + } 470 484 }) 471 485 .exec(accountID); 472 - }).show(); 486 + }) 487 + .show(); 473 488 }else{ 474 489 new SetAccountBlocked(account.id, false) 475 - .setCallback(new Callback<>(){ 490 + .setCallback(new Callback<>(){ 476 491 @Override 477 492 public void onSuccess(Relationship result){ 478 493 resultCallback.accept(result); ··· 494 509 public static void confirmToggleBlockDomain(Activity activity, String accountID, Account account, boolean currentlyBlocked, Runnable resultCallback, Consumer<Relationship> callbackInCaseUserWasBlockedInstead){ 495 510 if(!currentlyBlocked){ 496 511 new BlockDomainConfirmationSheet(activity, account, (onSuccess, onError)->{ 497 - new SetDomainBlocked(account.getDomain(), true) 498 - .setCallback(new Callback<>(){ 499 - @Override 500 - public void onSuccess(Object result){ 501 - resultCallback.run(); 502 - onSuccess.run(); 503 - } 512 + new SetDomainBlocked(account.getDomain(), true) 513 + .setCallback(new Callback<>(){ 514 + @Override 515 + public void onSuccess(Object result){ 516 + resultCallback.run(); 517 + onSuccess.run(); 518 + } 504 519 505 - @Override 506 - public void onError(ErrorResponse error){ 507 - error.showToast(activity); 508 - onError.run(); 509 - } 520 + @Override 521 + public void onError(ErrorResponse error){ 522 + error.showToast(activity); 523 + onError.run(); 524 + } 510 525 }) 511 526 .exec(accountID); 512 527 }, (onSuccess, onError)->{ 513 - new SetAccountBlocked(account.id, true) 514 - .setCallback(new Callback<>(){ 515 - @Override 516 - public void onSuccess(Relationship result){ 517 - callbackInCaseUserWasBlockedInstead.accept(result); 518 - onSuccess.run(); 519 - E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); 520 - } 528 + new SetAccountBlocked(account.id, true) 529 + .setCallback(new Callback<>(){ 530 + @Override 531 + public void onSuccess(Relationship result){ 532 + callbackInCaseUserWasBlockedInstead.accept(result); 533 + onSuccess.run(); 534 + E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); 535 + } 521 536 522 - @Override 523 - public void onError(ErrorResponse error){ 524 - error.showToast(activity); 525 - onError.run(); 526 - } 537 + @Override 538 + public void onError(ErrorResponse error){ 539 + error.showToast(activity); 540 + onError.run(); 541 + } 527 542 }) 528 543 .exec(accountID); 529 544 }, accountID).show(); 530 545 }else{ 531 546 new SetDomainBlocked(account.getDomain(), false) 532 - .setCallback(new Callback<>(){ 547 + .setCallback(new Callback<>(){ 533 548 @Override 534 549 public void onSuccess(Object result){ 535 550 resultCallback.run(); ··· 551 566 public static void confirmToggleMuteUser(Activity activity, String accountID, Account account, boolean currentlyMuted, Consumer<Relationship> resultCallback){ 552 567 if(!currentlyMuted){ 553 568 new MuteAccountConfirmationSheet(activity, account, (onSuccess, onError)->{ 554 - new SetAccountMuted(account.id, true) 555 - .setCallback(new Callback<>(){ 556 - @Override 557 - public void onSuccess(Relationship result){ 558 - resultCallback.accept(result); 559 - onSuccess.run(); 560 - E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); 561 - } 569 + new SetAccountMuted(account.id, true) 570 + .setCallback(new Callback<>(){ 571 + @Override 572 + public void onSuccess(Relationship result){ 573 + resultCallback.accept(result); 574 + onSuccess.run(); 575 + E.post(new RemoveAccountPostsEvent(accountID, account.id, false)); 576 + } 562 577 563 - @Override 564 - public void onError(ErrorResponse error){ 565 - error.showToast(activity); 566 - onError.run(); 567 - } 578 + @Override 579 + public void onError(ErrorResponse error){ 580 + error.showToast(activity); 581 + onError.run(); 582 + } 568 583 }) 569 584 .exec(accountID); 570 585 }).show(); 571 586 }else{ 572 587 new SetAccountMuted(account.id, false) 573 - .setCallback(new Callback<>(){ 588 + .setCallback(new Callback<>(){ 574 589 @Override 575 590 public void onSuccess(Relationship result){ 576 591 resultCallback.accept(result); ··· 590 605 } 591 606 592 607 public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer<Status> resultCallback){ 593 - Runnable delete=()->new DeleteStatus(status.id) 594 - .setCallback(new Callback<>(){ 595 - @Override 596 - public void onSuccess(Status result){ 597 - resultCallback.accept(result); 598 - AccountSessionManager.getInstance().getAccount(accountID).getCacheController().deleteStatus(status.id); 599 - E.post(new StatusDeletedEvent(status.id, accountID)); 600 - } 608 + Runnable delete=()-> { 609 + AccountSession session = AccountSessionManager.get(accountID); 610 + if (session.protocol == Protocol.ATPROTO) { 611 + deletePostAtProto(activity, session, status, resultCallback); 612 + return; 613 + } 614 + new DeleteStatus(status.id) 615 + .setCallback(new Callback<>() { 616 + @Override 617 + public void onSuccess(Status result) { 618 + resultCallback.accept(result); 619 + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().deleteStatus(status.id); 620 + E.post(new StatusDeletedEvent(status.id, accountID)); 621 + } 601 622 602 - @Override 603 - public void onError(ErrorResponse error){ 604 - error.showToast(activity); 605 - } 606 - }) 607 - .wrapProgress(activity, R.string.deleting, false) 608 - .exec(accountID); 623 + @Override 624 + public void onError(ErrorResponse error) { 625 + error.showToast(activity); 626 + } 627 + }) 628 + .wrapProgress(activity, R.string.deleting, false) 629 + .exec(accountID); 630 + }; 609 631 if(GlobalUserPreferences.confirmDeletePost) 610 632 showConfirmationAlert(activity, R.string.confirm_delete_title, R.string.confirm_delete, R.string.delete, delete); 611 633 else 612 634 delete.run(); 613 635 } 614 636 637 + private static void deletePostAtProto(Activity activity, AccountSession session, Status status, Consumer<Status> resultCallback) { 638 + ProgressDialog progress = new ProgressDialog(activity); 639 + progress.setMessage(activity.getString(R.string.deleting)); 640 + progress.setCancelable(false); 641 + progress.show(); 642 + 643 + MastodonAPIController.runInBackground(() -> { 644 + try { 645 + session.executeBluesky((client) -> { 646 + DeleteRecordRequest request = AtProtoClient.createDeleteRecordRequest(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.feed.post", AtProtoClient.getRkey(AtProtoClient.createAtUri(status.uri))); 647 + return AtProtoClient.deleteRecord(client, request); 648 + }); 649 + 650 + activity.runOnUiThread(() -> { 651 + progress.dismiss(); 652 + resultCallback.accept(status); 653 + AccountSessionManager.getInstance().getAccount(session.getID()).getCacheController().deleteStatus(status.id); 654 + E.post(new StatusDeletedEvent(status.id, session.getID())); 655 + }); 656 + } catch (Exception x) { 657 + activity.runOnUiThread(() -> { 658 + progress.dismiss(); 659 + Toast.makeText(activity, x.getLocalizedMessage(), Toast.LENGTH_LONG).show(); 660 + }); 661 + } 662 + }); 663 + } 664 + 615 665 public static void setRelationshipToActionButtonM3(Relationship relationship, Button button){ 616 666 int styleRes; 617 667 if(relationship.blocking){ ··· 646 696 }else if(relationship.muting){ 647 697 confirmToggleMuteUser(activity, accountID, account, true, resultCallback); 648 698 }else{ 699 + AccountSession session=AccountSessionManager.get(accountID); 700 + if(session!=null && session.protocol==Protocol.ATPROTO){ 701 + performAccountActionATProto(activity, session, account, relationship, progressCallback, resultCallback); 702 + return; 703 + } 649 704 Runnable action=()->{ 650 705 progressCallback.accept(true); 651 706 new SetAccountFollowed(account.id, !relationship.following && !relationship.requested, true, false) 652 - .setCallback(new Callback<>(){ 707 + .setCallback(new Callback<>(){ 653 708 @Override 654 709 public void onSuccess(Relationship result){ 655 710 resultCallback.accept(result); ··· 672 727 }else{ 673 728 action.run(); 674 729 } 730 + } 731 + } 732 + 733 + private static void performAccountActionATProto(Activity activity, AccountSession session, Account account, Relationship relationship, Consumer<Boolean> progressCallback, Consumer<Relationship> resultCallback){ 734 + Runnable action=()->{ 735 + progressCallback.accept(true); 736 + MastodonAPIController.runInBackground(()->{ 737 + try{ 738 + if(relationship.following){ 739 + // Unfollow 740 + if(relationship.atProtoFollowingUri !=null){ 741 + session.executeBluesky((client)->{ 742 + DeleteRecordRequest request = AtProtoClient.createDeleteRecordRequest(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.graph.follow", AtProtoClient.getRkey(AtProtoClient.createAtUri(relationship.atProtoFollowingUri))); 743 + return AtProtoClient.deleteRecord(client, request); 744 + }); 745 + relationship.following=false; 746 + relationship.atProtoFollowingUri =null; 747 + } 748 + }else{ 749 + // Follow 750 + AtpResponse<CreateRecordResponse> resp=session.executeBluesky((client)->{ 751 + Follow record = AtProtoClient.createFollow(account.id, Clock.System.INSTANCE.now()); 752 + CreateRecordRequest request = AtProtoClient.createCreateRecordRequest(AtProtoClient.createAtIdentifier(session.self.id), "app.bsky.graph.follow", AtProtoClient.toFollowJson(record)); 753 + return AtProtoClient.createRecord(client, request); 754 + }); 755 + if (resp instanceof AtpResponse.Success<CreateRecordResponse> success) { 756 + relationship.following=true; 757 + relationship.atProtoFollowingUri =AtProtoClient.getUri(success.getResponse()); 758 + } else { 759 + throw new Exception("Follow failed: " + resp); 760 + } 761 + } 762 + 763 + activity.runOnUiThread(()->{ 764 + resultCallback.accept(relationship); 765 + progressCallback.accept(false); 766 + if(!relationship.following) 767 + E.post(new RemoveAccountPostsEvent(session.getID(), account.id, true)); 768 + }); 769 + }catch(Exception e){ 770 + activity.runOnUiThread(()->{ 771 + new MastodonErrorResponse(e.getLocalizedMessage(), 0, e).showToast(activity); 772 + progressCallback.accept(false); 773 + }); 774 + } 775 + }); 776 + }; 777 + if(relationship.following && GlobalUserPreferences.confirmUnfollow){ 778 + showConfirmationAlert(activity, null, activity.getString(R.string.unfollow_confirmation, account.getDisplayUsername()), activity.getString(R.string.unfollow), action); 779 + }else{ 780 + action.run(); 675 781 } 676 782 } 677 783 ··· 813 919 if(AccountSessionManager.getInstance().getAccount(accountID).domain.equalsIgnoreCase(uri.getAuthority()) && path.size()==2 && path.get(0).matches("^@[a-zA-Z0-9_]+$") && path.get(1).matches("^[0-9]+$")){ 814 920 // Match URLs like https://mastodon.social/@Gargron/108132679274083591 815 921 new GetStatusByID(path.get(1)) 816 - .setCallback(new Callback<>(){ 922 + .setCallback(new Callback<>(){ 817 923 @Override 818 924 public void onSuccess(Status result){ 819 925 Bundle args=new Bundle(); ··· 833 939 return; 834 940 }else{ 835 941 new GetSearchResults(url, null, true, null, 0, 0) 836 - .setCallback(new Callback<>(){ 942 + .setCallback(new Callback<>(){ 837 943 @Override 838 944 public void onSuccess(SearchResults result){ 839 945 Bundle args=new Bundle(); ··· 1178 1284 holder.itemView.setOnClickListener(v->holder.onClick()); 1179 1285 return holder.itemView; 1180 1286 } 1181 - } 1287 + }
+50
mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeAutocompleteViewController.java
··· 13 13 import android.widget.TextView; 14 14 15 15 import org.joinmastodon.android.R; 16 + import org.joinmastodon.android.api.MastodonAPIController; 17 + import org.joinmastodon.android.api.atproto.AtProtoClient; 18 + import org.joinmastodon.android.api.atproto.AtProtoMapper; 16 19 import org.joinmastodon.android.api.requests.search.GetSearchResults; 17 20 import org.joinmastodon.android.api.session.AccountSessionManager; 18 21 import org.joinmastodon.android.model.Emoji; 19 22 import org.joinmastodon.android.model.Hashtag; 23 + import org.joinmastodon.android.model.Protocol; 20 24 import org.joinmastodon.android.model.SearchResults; 21 25 import org.joinmastodon.android.model.viewmodel.AccountViewModel; 22 26 import org.joinmastodon.android.ui.BetterItemAnimator; ··· 34 38 import androidx.annotation.NonNull; 35 39 import androidx.recyclerview.widget.LinearLayoutManager; 36 40 import androidx.recyclerview.widget.RecyclerView; 41 + import app.bsky.actor.SearchActorsTypeaheadResponse; 37 42 import me.grishka.appkit.api.APIRequest; 38 43 import me.grishka.appkit.api.Callback; 39 44 import me.grishka.appkit.api.ErrorResponse; ··· 46 51 import me.grishka.appkit.utils.MergeRecyclerAdapter; 47 52 import me.grishka.appkit.utils.V; 48 53 import me.grishka.appkit.views.UsableRecyclerView; 54 + import sh.christian.ozone.api.response.AtpResponse; 49 55 50 56 public class ComposeAutocompleteViewController{ 51 57 private static final int LOADING_FAKE_USER_COUNT=3; ··· 252 258 } 253 259 254 260 private void doSearchUsers(){ 261 + if(AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO){ 262 + doSearchUsersATProto(); 263 + return; 264 + } 255 265 currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.ACCOUNTS, false, null, 0, 0) 256 266 .setCallback(new Callback<>(){ 257 267 @Override ··· 287 297 .exec(accountID); 288 298 } 289 299 300 + private void doSearchUsersATProto(){ 301 + MastodonAPIController.runInBackground(()->{ 302 + try{ 303 + AtpResponse<SearchActorsTypeaheadResponse> resp=AccountSessionManager.get(accountID).executeBluesky(client->{ 304 + var params=AtProtoClient.createSearchActorsTypeaheadQueryParams(lastText.substring(1), 10L); 305 + return AtProtoClient.searchActorsTypeahead(client, params); 306 + }); 307 + if(resp instanceof AtpResponse.Success<SearchActorsTypeaheadResponse> success){ 308 + var actors=AtProtoMapper.mapSearchActorsTypeahead(success.getResponse()); 309 + activity.runOnUiThread(()->{ 310 + if(mode!=Mode.USERS) 311 + return; 312 + List<AccountViewModel> oldList=users; 313 + users=actors.stream().map(a->new AccountViewModel(a, accountID, activity)).collect(Collectors.toList()); 314 + if(isLoading){ 315 + isLoading=false; 316 + if(users.size()>=LOADING_FAKE_USER_COUNT){ 317 + usersAdapter.notifyItemRangeChanged(0, LOADING_FAKE_USER_COUNT); 318 + if(users.size()>LOADING_FAKE_USER_COUNT) 319 + usersAdapter.notifyItemRangeInserted(LOADING_FAKE_USER_COUNT, users.size()-LOADING_FAKE_USER_COUNT); 320 + }else{ 321 + usersAdapter.notifyItemRangeChanged(0, users.size()); 322 + usersAdapter.notifyItemRangeRemoved(users.size(), LOADING_FAKE_USER_COUNT-users.size()); 323 + } 324 + }else{ 325 + UiUtils.updateList(oldList, users, list, usersAdapter, (a1, a2)->a1.account.id.equals(a2.account.id)); 326 + } 327 + list.invalidateItemDecorations(); 328 + emptyButtonAdapter.setVisible(users.isEmpty()); 329 + imgLoader.updateImages(); 330 + }); 331 + } 332 + }catch(Exception ignore){} 333 + }); 334 + } 335 + 290 336 private void doSearchHashtags(){ 337 + if(AccountSessionManager.get(accountID).protocol==Protocol.ATPROTO){ 338 + // Bluesky doesn't have a hashtag search API yet, so just leave it as it is (typed hashtag only) 339 + return; 340 + } 291 341 currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.HASHTAGS, false, null, 0, 0) 292 342 .setCallback(new Callback<>(){ 293 343 @Override
+83 -2
mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java
··· 32 32 33 33 import org.joinmastodon.android.MastodonApp; 34 34 import org.joinmastodon.android.R; 35 + import org.joinmastodon.android.api.atproto.AtProtoClient; 35 36 import org.joinmastodon.android.api.MastodonAPIController; 36 37 import org.joinmastodon.android.api.ProgressListener; 37 38 import org.joinmastodon.android.api.requests.statuses.CreateStatus; 38 39 import org.joinmastodon.android.api.requests.statuses.GetAttachmentByID; 39 40 import org.joinmastodon.android.api.requests.statuses.UpdateAttachment; 40 41 import org.joinmastodon.android.api.requests.statuses.UploadAttachment; 42 + import org.joinmastodon.android.api.session.AccountSession; 43 + import org.joinmastodon.android.api.session.AccountSessionManager; 41 44 import org.joinmastodon.android.fragments.ComposeFragment; 42 45 import org.joinmastodon.android.fragments.ComposeImageDescriptionFragment; 43 46 import org.joinmastodon.android.model.Attachment; 44 47 import org.joinmastodon.android.model.Instance; 48 + import org.joinmastodon.android.model.Protocol; 45 49 import org.joinmastodon.android.ui.OutlineProviders; 46 50 import org.joinmastodon.android.ui.drawables.EmptyDrawable; 47 51 import org.joinmastodon.android.ui.utils.UiUtils; ··· 50 54 import org.parceler.Parcel; 51 55 import org.parceler.Parcels; 52 56 57 + import java.io.ByteArrayOutputStream; 58 + import java.io.InputStream; 53 59 import java.util.ArrayList; 54 60 import java.util.Collection; 55 61 import java.util.HashMap; ··· 61 67 import java.util.function.Consumer; 62 68 import java.util.stream.Collectors; 63 69 70 + import kotlin.coroutines.EmptyCoroutineContext; 71 + import kotlinx.coroutines.BuildersKt; 64 72 import me.grishka.appkit.Nav; 65 73 import me.grishka.appkit.api.Callback; 66 74 import me.grishka.appkit.api.ErrorResponse; ··· 68 76 import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; 69 77 import me.grishka.appkit.utils.CubicBezierInterpolator; 70 78 import me.grishka.appkit.utils.V; 79 + import sh.christian.ozone.api.response.AtpResponse; 71 80 72 81 public class ComposeMediaViewController{ 73 82 private static final int MAX_ATTACHMENTS=4; ··· 78 87 private ReorderableLinearLayout attachmentsView; 79 88 private HorizontalScrollView attachmentsScroller; 80 89 81 - private ArrayList<DraftMediaAttachment> attachments=new ArrayList<>(); 90 + public ArrayList<DraftMediaAttachment> attachments=new ArrayList<>(); 82 91 private boolean attachmentsErrorShowing; 83 92 84 93 public ComposeMediaViewController(ComposeFragment fragment){ ··· 310 319 } 311 320 attachment.state=AttachmentUploadState.UPLOADING; 312 321 attachment.progressBar.setVisibility(View.VISIBLE); 322 + 323 + AccountSession session=AccountSessionManager.getInstance().tryGetAccount(fragment.getAccountID()); 324 + if(session.protocol==Protocol.ATPROTO){ 325 + attachment.progressBar.setIndeterminate(true); 326 + MastodonAPIController.runInBackground(()->{ 327 + try{ 328 + byte[] data; 329 + try(InputStream in=fragment.getActivity().getContentResolver().openInputStream(attachment.uri); 330 + ByteArrayOutputStream out=new ByteArrayOutputStream()){ 331 + byte[] buf=new byte[8192]; 332 + int read; 333 + while((read=in.read(buf))>0){ 334 + out.write(buf, 0, read); 335 + } 336 + data=out.toByteArray(); 337 + } 338 + AtpResponse<com.atproto.repo.UploadBlobResponse> resp=session.executeBluesky((client)->BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, (scope, continuation)->client.uploadBlob(data, continuation))); 339 + Attachment result=new Attachment(); 340 + if(resp instanceof AtpResponse.Success<com.atproto.repo.UploadBlobResponse> success){ 341 + result.atProtoBlob=AtProtoClient.getBlob(success.getResponse()); 342 + result.id=AtProtoClient.getBlobLink(result.atProtoBlob); 343 + result.type=Attachment.Type.IMAGE; 344 + if(attachment.mimeType.startsWith("video/")) 345 + result.type=Attachment.Type.VIDEO; 346 + result.url=attachment.uri.toString(); 347 + result.previewUrl=result.url; 348 + Drawable img=attachment.imageView.getDrawable(); 349 + if(img!=null){ 350 + result.meta=new Attachment.Metadata(); 351 + result.meta.width=img.getIntrinsicWidth(); 352 + result.meta.height=img.getIntrinsicHeight(); 353 + } 354 + attachment.serverAttachment=result; 355 + fragment.getActivity().runOnUiThread(()->finishMediaAttachmentUpload(attachment)); 356 + }else{ 357 + throw new Exception("Upload failed: "+resp); 358 + } 359 + }catch(Exception x){ 360 + Log.e(TAG, "ATProto blob upload failed", x); 361 + fragment.getActivity().runOnUiThread(()->{ 362 + attachment.uploadRequest=null; 363 + attachment.state=AttachmentUploadState.ERROR; 364 + attachment.titleView.setText(R.string.upload_failed); 365 + V.setVisibilityAnimated(attachment.editButton, View.VISIBLE); 366 + attachment.editButton.setImageResource(R.drawable.ic_restart_alt_24px); 367 + attachment.editButton.setOnClickListener(ComposeMediaViewController.this::onRetryOrCancelMediaUploadClick); 368 + attachment.setUseErrorColors(true); 369 + V.setVisibilityAnimated(attachment.progressBar, View.GONE); 370 + if(!areThereAnyUploadingAttachments()) 371 + uploadNextQueuedAttachment(); 372 + }); 373 + } 374 + }); 375 + return; 376 + } 377 + 313 378 int maxSize=0; 314 379 String contentType=fragment.getActivity().getContentResolver().getType(attachment.uri); 315 380 if(contentType!=null && contentType.startsWith("image/")){ ··· 388 453 } 389 454 }) 390 455 .exec(fragment.getAccountID()); 456 + } 457 + 458 + private String fileExtForMimeType(String mimeType){ 459 + return switch(mimeType.toLowerCase()){ 460 + case "image/jpeg" -> ".jpg"; 461 + case "image/png" -> ".png"; 462 + case "image/gif" -> ".gif"; 463 + case "image/webp" -> ".webp"; 464 + case "video/mp4" -> ".mp4"; 465 + case "video/webm" -> ".webm"; 466 + default -> ""; 467 + }; 391 468 } 392 469 393 470 private void onRemoveMediaAttachmentClick(View v){ ··· 600 677 } 601 678 602 679 public void saveAltTextsBeforePublishing(Runnable onSuccess, Consumer<ErrorResponse> onError){ 680 + if (AccountSessionManager.get(fragment.getAccountID()).protocol == Protocol.ATPROTO) { 681 + onSuccess.run(); 682 + return; 683 + } 603 684 ArrayList<UpdateAttachment> updateAltTextRequests=new ArrayList<>(); 604 685 for(DraftMediaAttachment att:attachments){ 605 686 if(!att.descriptionSaved && (fragment.editingStatus==null || !fragment.editingStatus.mediaAttachments.contains(att.serverAttachment))){ ··· 638 719 } 639 720 640 721 @Parcel 641 - static class DraftMediaAttachment{ 722 + public static class DraftMediaAttachment{ 642 723 public Attachment serverAttachment; 643 724 public Uri uri; 644 725 public transient UploadAttachment uploadRequest;
+156
mastodon/src/main/res/layout/fragment_bluesky_login.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <me.grishka.appkit.views.FragmentRootLinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 3 + xmlns:tools="http://schemas.android.com/tools" 4 + xmlns:app="http://schemas.android.com/apk/res-auto" 5 + android:orientation="vertical" 6 + android:layout_width="match_parent" 7 + android:layout_height="match_parent"> 8 + 9 + <ScrollView 10 + android:id="@+id/scroller" 11 + android:layout_width="match_parent" 12 + android:layout_height="0dp" 13 + android:layout_weight="1"> 14 + 15 + <LinearLayout 16 + android:layout_width="match_parent" 17 + android:layout_height="wrap_content" 18 + android:orientation="vertical" 19 + android:clipChildren="false"> 20 + 21 + <org.joinmastodon.android.ui.views.FloatingHintEditTextLayout 22 + android:layout_width="match_parent" 23 + android:layout_height="wrap_content" 24 + android:minHeight="80dp" 25 + android:paddingTop="4dp" 26 + app:labelTextColor="@color/m3_outlined_text_field_label" 27 + android:foreground="@drawable/bg_m3_outlined_text_field"> 28 + 29 + <EditText 30 + android:id="@+id/server_url" 31 + android:layout_width="match_parent" 32 + android:layout_height="56dp" 33 + android:layout_marginStart="56dp" 34 + android:layout_marginEnd="24dp" 35 + android:layout_marginTop="8dp" 36 + android:padding="16dp" 37 + android:background="@null" 38 + android:elevation="0dp" 39 + android:inputType="textUri" 40 + android:autofillHints="url" 41 + android:singleLine="true" 42 + android:hint="@string/server_url_bsky"/> 43 + 44 + <View 45 + android:layout_width="24dp" 46 + android:layout_height="24dp" 47 + android:layout_gravity="start|top" 48 + android:layout_marginStart="16dp" 49 + android:layout_marginTop="12dp" 50 + android:backgroundTint="?colorM3OnSurfaceVariant" 51 + android:background="@drawable/ic_public_24px"/> 52 + 53 + </org.joinmastodon.android.ui.views.FloatingHintEditTextLayout> 54 + 55 + <org.joinmastodon.android.ui.views.FloatingHintEditTextLayout 56 + android:layout_width="match_parent" 57 + android:layout_height="wrap_content" 58 + android:minHeight="80dp" 59 + android:paddingTop="4dp" 60 + app:labelTextColor="@color/m3_outlined_text_field_label" 61 + android:foreground="@drawable/bg_m3_outlined_text_field"> 62 + 63 + <EditText 64 + android:id="@+id/handle" 65 + android:layout_width="match_parent" 66 + android:layout_height="56dp" 67 + android:layout_marginStart="56dp" 68 + android:layout_marginEnd="24dp" 69 + android:layout_marginTop="8dp" 70 + android:padding="16dp" 71 + android:background="@null" 72 + android:elevation="0dp" 73 + android:inputType="textUri" 74 + android:autofillHints="username" 75 + android:singleLine="true" 76 + android:hint="@string/username"/> 77 + 78 + <View 79 + android:layout_width="24dp" 80 + android:layout_height="24dp" 81 + android:layout_gravity="start|top" 82 + android:layout_marginStart="16dp" 83 + android:layout_marginTop="12dp" 84 + android:backgroundTint="?colorM3OnSurfaceVariant" 85 + android:background="@drawable/ic_outline_person_24"/> 86 + 87 + </org.joinmastodon.android.ui.views.FloatingHintEditTextLayout> 88 + 89 + <org.joinmastodon.android.ui.views.FloatingHintEditTextLayout 90 + android:layout_width="match_parent" 91 + android:layout_height="wrap_content" 92 + android:minHeight="80dp" 93 + android:paddingTop="4dp" 94 + app:labelTextColor="@color/m3_outlined_text_field_label" 95 + android:foreground="@drawable/bg_m3_outlined_text_field"> 96 + 97 + <EditText 98 + android:id="@+id/password" 99 + android:layout_width="match_parent" 100 + android:layout_height="56dp" 101 + android:layout_marginStart="56dp" 102 + android:layout_marginEnd="24dp" 103 + android:layout_marginTop="8dp" 104 + android:padding="16dp" 105 + android:background="@null" 106 + android:elevation="0dp" 107 + android:inputType="textPassword" 108 + android:autofillHints="password" 109 + android:singleLine="true" 110 + android:hint="@string/password"/> 111 + 112 + <View 113 + android:layout_width="24dp" 114 + android:layout_height="24dp" 115 + android:layout_gravity="start|top" 116 + android:layout_marginStart="16dp" 117 + android:layout_marginTop="12dp" 118 + android:backgroundTint="?colorM3OnSurfaceVariant" 119 + android:background="@drawable/ic_outline_password_24"/> 120 + 121 + </org.joinmastodon.android.ui.views.FloatingHintEditTextLayout> 122 + 123 + </LinearLayout> 124 + </ScrollView> 125 + 126 + <LinearLayout 127 + android:id="@+id/button_bar" 128 + android:layout_width="match_parent" 129 + android:layout_height="wrap_content" 130 + android:orientation="horizontal"> 131 + 132 + <org.joinmastodon.android.ui.views.ProgressBarButton 133 + android:id="@+id/btn_next" 134 + android:layout_width="match_parent" 135 + android:layout_height="wrap_content" 136 + android:layout_marginLeft="16dp" 137 + android:layout_marginRight="16dp" 138 + android:layout_marginTop="8dp" 139 + android:layout_marginBottom="8dp" 140 + android:minWidth="145dp" 141 + style="@style/Widget.Mastodon.M3.Button.Filled" 142 + android:text="@string/log_in" 143 + app:progressBar="@+id/action_progress"/> 144 + 145 + <ProgressBar 146 + android:id="@+id/action_progress" 147 + style="?android:progressBarStyleSmall" 148 + android:layout_width="wrap_content" 149 + android:layout_height="wrap_content" 150 + android:layout_gravity="center_vertical" 151 + android:visibility="gone" 152 + tools:visibility="visible"/> 153 + 154 + </LinearLayout> 155 + 156 + </me.grishka.appkit.views.FragmentRootLinearLayout>
+11
mastodon/src/main/res/layout/fragment_splash.xml
··· 210 210 211 211 </LinearLayout> 212 212 213 + <Button 214 + android:id="@+id/btn_log_in_bluesky" 215 + android:layout_width="match_parent" 216 + android:layout_height="wrap_content" 217 + android:layout_marginLeft="8dp" 218 + android:layout_marginRight="8dp" 219 + android:textColor="#FFF" 220 + style="@style/Widget.Mastodon.M3.Button.Text" 221 + android:background="@drawable/bg_button_m3_text_white" 222 + android:text="@string/log_in_bsky"/> 223 + 213 224 </LinearLayout> 214 225 215 226 </org.joinmastodon.android.ui.views.SizeListenerFrameLayout>
+8 -2
mastodon/src/main/res/values/strings.xml
··· 386 386 <string name="alt_text">Alt text</string> 387 387 <string name="help">Help</string> 388 388 <string name="what_is_alt_text">What is alt text?</string> 389 - <string name="alt_text_help">Alt text provides image descriptions for people with vision impairments, low-bandwidth connections, or those seeking extra context.\n\nYou can improve accessibility and understanding for everyone by writing clear, concise, and objective alt text.\n\n<ul><li>Capture important elements</li>\n<li>Summarize text in images</li>\n<li>Use regular sentence structure</li>\n<li>Avoid redundant information</li>\n<li>Focus on trends and key findings in complex visuals (like diagrams or maps)</li></ul></string> 389 + <string name="alt_text_help">Alt text provides image descriptions for people with vision impairments, low-bandwidth connections, or those seeking extra context.\n\nYou can improve accessibility and understanding for everyone by writing clear, concise, and objective alt text.\n\n<ul><li 390 + >Capture important elements</li>\n<li>Summarize text in images</li>\n<li>Use regular sentence structure</li>\n<li>Avoid redundant information</li>\n<li>Focus on trends and key findings in complex visuals (like diagrams or maps)</li></ul></string> 390 391 <string name="edit_post">Edit post</string> 391 392 <string name="no_verified_link">No verified link</string> 392 393 <string name="compose_autocomplete_emoji_empty">Browse emoji</string> ··· 930 931 <string name="quote_followers_only_cancel">Back to editing</string> 931 932 <string name="quote_followers_only_dont_show_again">Don\'t show this message again</string> 932 933 <string name="more_replies_found">More replies found</string> 933 - </resources> 934 + <!-- Bluesky/ATProto --> 935 + <string name="log_in_bsky">Log in to Bluesky</string> 936 + <string name="server_url_bsky">PDS URL</string> 937 + <string name="share_sheet_preview_profile_bsky">%s on Bluesky</string> 938 + <string name="share_sheet_preview_post_bsky">%1$s on Bluesky: “%2$s”</string> 939 + </resources>