Statuses and notifications loaded/parsed via Retrofit/GSON

Notification checker uses since_id as the more exact check-for-updates
This commit is contained in:
Eugen Rochko 2017-03-09 00:27:37 +01:00
parent 3120fbed4c
commit 750c1c80a0
21 changed files with 418 additions and 777 deletions

View File

@ -75,7 +75,6 @@ public class AccountActivity extends BaseActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_account);
createMastodonAPI();
Intent intent = getIntent();
accountId = intent.getStringExtra("id");

View File

@ -184,21 +184,17 @@ public class AccountFragment extends Fragment implements AccountActionListener,
}
};
String endpoint;
switch (type) {
default:
case FOLLOWS: {
endpoint = String.format(getString(R.string.endpoint_following), accountId);
api.accountFollowing(accountId, fromId, null, null).enqueue(cb);
break;
}
case FOLLOWERS: {
endpoint = String.format(getString(R.string.endpoint_followers), accountId);
api.accountFollowers(accountId, fromId, null, null).enqueue(cb);
break;
}
case BLOCKS: {
endpoint = getString(R.string.endpoint_blocks);
api.blocks(fromId, null, null).enqueue(cb);
break;
}

View File

@ -51,6 +51,9 @@ public class BaseActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
createMastodonAPI();
if (PreferenceManager.getDefaultSharedPreferences(this).getBoolean("lightTheme", false)) {
setTheme(R.style.AppTheme_Light);
}

View File

@ -76,6 +76,7 @@ import com.android.volley.Request;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonObjectRequest;
import com.keylesspalace.tusky.entity.Status;
import org.json.JSONArray;
import org.json.JSONException;

View File

@ -2,7 +2,9 @@ package com.keylesspalace.tusky;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Media;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext;
import java.util.List;
@ -146,7 +148,7 @@ public interface MastodonAPI {
@Query("limit") Integer limit);
@GET("api/v1/favourites")
Call<List<Account>> favourites(
Call<List<Status>> favourites(
@Query("max_id") String maxId,
@Query("since_id") String sinceId,
@Query("limit") Integer limit);

View File

@ -1,133 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky;
import android.support.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
class Notification {
enum Type {
MENTION,
REBLOG,
FAVOURITE,
FOLLOW,
}
private Type type;
private String id;
private String displayName;
private String username;
private String avatar;
private String accountId;
/** Which of the user's statuses has been mentioned, reblogged, or favourited. */
private Status status;
private Notification(Type type, String id, String displayName, String username, String avatar,
String accountId) {
this.type = type;
this.id = id;
this.displayName = displayName;
this.username = username;
this.avatar = avatar;
this.accountId = accountId;
}
Type getType() {
return type;
}
String getId() {
return id;
}
String getDisplayName() {
return displayName;
}
String getUsername() {
return username;
}
String getAvatar() {
return avatar;
}
String getAccountId() {
return accountId;
}
@Nullable Status getStatus() {
return status;
}
void setStatus(Status status) {
this.status = status;
}
private boolean hasStatusType() {
return type == Type.MENTION
|| type == Type.FAVOURITE
|| type == Type.REBLOG;
}
static List<Notification> parse(JSONArray array) throws JSONException {
List<Notification> notifications = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject object = array.getJSONObject(i);
String id = object.getString("id");
Notification.Type type = Notification.Type.valueOf(
object.getString("type").toUpperCase());
JSONObject account = object.getJSONObject("account");
String displayName = account.getString("display_name");
if (displayName.isEmpty()) {
displayName = account.getString("username");
}
String username = account.getString("acct");
String avatar = account.getString("avatar");
String accountId = account.getString("id");
Notification notification = new Notification(type, id, displayName, username, avatar,
accountId);
if (notification.hasStatusType()) {
JSONObject statusObject = object.getJSONObject("status");
Status status = Status.parse(statusObject, false);
notification.setStatus(status);
}
notifications.add(notification);
}
return notifications;
}
@Override
public int hashCode() {
return id.hashCode();
}
@Override
public boolean equals(Object other) {
if (this.id == null) {
return this == other;
} else if (!(other instanceof Notification)) {
return false;
}
Notification notification = (Notification) other;
return notification.getId().equals(this.id);
}
}

View File

@ -28,6 +28,8 @@ import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.squareup.picasso.Picasso;
import java.util.ArrayList;
@ -86,26 +88,26 @@ class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRe
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
if (position < notifications.size()) {
Notification notification = notifications.get(position);
Notification.Type type = notification.getType();
Notification.Type type = notification.type;
switch (type) {
case MENTION: {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
Status status = notification.getStatus();
Status status = notification.status;
holder.setupWithStatus(status, statusListener);
break;
}
case FAVOURITE:
case REBLOG: {
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
holder.setMessage(type, notification.getDisplayName(),
notification.getStatus());
holder.setMessage(type, notification.account.displayName,
notification.status);
break;
}
case FOLLOW: {
FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(notification.getDisplayName(), notification.getUsername(),
notification.getAvatar());
holder.setupButtons(followListener, notification.getAccountId());
holder.setMessage(notification.account.displayName, notification.account.username,
notification.account.avatar);
holder.setupButtons(followListener, notification.account.id);
break;
}
}
@ -126,7 +128,7 @@ class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRe
return VIEW_TYPE_FOOTER;
} else {
Notification notification = notifications.get(position);
switch (notification.getType()) {
switch (notification.type) {
default:
case MENTION: {
return VIEW_TYPE_MENTION;
@ -269,7 +271,7 @@ class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRe
str.setSpan(new android.text.style.StyleSpan(Typeface.BOLD), 0, displayName.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
message.setText(str);
statusContent.setText(status.getContent());
statusContent.setText(status.content);
}
}
}

View File

@ -32,6 +32,8 @@ import com.android.volley.AuthFailureError;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonArrayRequest;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import org.json.JSONArray;
import org.json.JSONException;
@ -40,6 +42,9 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
public class NotificationsFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, StatusActionListener, FooterActionListener,
NotificationsAdapter.FollowListener {
@ -92,7 +97,7 @@ public class NotificationsFragment extends SFragment implements
NotificationsAdapter adapter = (NotificationsAdapter) view.getAdapter();
Notification notification = adapter.getItem(adapter.getItemCount() - 2);
if (notification != null) {
sendFetchNotificationsRequest(notification.getId());
sendFetchNotificationsRequest(notification.id);
} else {
sendFetchNotificationsRequest();
}
@ -135,37 +140,19 @@ public class NotificationsFragment extends SFragment implements
}
private void sendFetchNotificationsRequest(final String fromId) {
String endpoint = getString(R.string.endpoint_notifications);
String url = "https://" + domain + endpoint;
if (fromId != null) {
url += "?max_id=" + fromId;
}
JsonArrayRequest request = new JsonArrayRequest(url,
new Response.Listener<JSONArray>() {
@Override
public void onResponse(JSONArray response) {
try {
List<Notification> notifications = Notification.parse(response);
onFetchNotificationsSuccess(notifications, fromId);
} catch (JSONException e) {
onFetchNotificationsFailure(e);
}
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onFetchNotificationsFailure(error);
}
}) {
MastodonAPI api = ((BaseActivity) getActivity()).mastodonAPI;
api.notifications(fromId, null, null).enqueue(new Callback<List<Notification>>() {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
public void onResponse(Call<List<Notification>> call, retrofit2.Response<List<Notification>> response) {
onFetchNotificationsSuccess(response.body(), fromId);
}
};
request.setTag(TAG);
VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
@Override
public void onFailure(Call<List<Notification>> call, Throwable t) {
onFetchNotificationsFailure((Exception) t);
}
});
}
private void sendFetchNotificationsRequest() {
@ -174,7 +161,7 @@ public class NotificationsFragment extends SFragment implements
private static boolean findNotification(List<Notification> notifications, String id) {
for (Notification notification : notifications) {
if (notification.getId().equals(id)) {
if (notification.id.equals(id)) {
return true;
}
}
@ -218,7 +205,7 @@ public class NotificationsFragment extends SFragment implements
public void onLoadMore() {
Notification notification = adapter.getItem(adapter.getItemCount() - 2);
if (notification != null) {
sendFetchNotificationsRequest(notification.getId());
sendFetchNotificationsRequest(notification.id);
} else {
sendFetchNotificationsRequest();
}
@ -226,22 +213,22 @@ public class NotificationsFragment extends SFragment implements
public void onReply(int position) {
Notification notification = adapter.getItem(position);
super.reply(notification.getStatus());
super.reply(notification.status);
}
public void onReblog(boolean reblog, int position) {
Notification notification = adapter.getItem(position);
super.reblog(notification.getStatus(), reblog, adapter, position);
super.reblog(notification.status, reblog, adapter, position);
}
public void onFavourite(boolean favourite, int position) {
Notification notification = adapter.getItem(position);
super.favourite(notification.getStatus(), favourite, adapter, position);
super.favourite(notification.status, favourite, adapter, position);
}
public void onMore(View view, int position) {
Notification notification = adapter.getItem(position);
super.more(notification.getStatus(), view, adapter, position);
super.more(notification.status, view, adapter, position);
}
public void onViewMedia(String url, Status.MediaAttachment.Type type) {
@ -250,7 +237,7 @@ public class NotificationsFragment extends SFragment implements
public void onViewThread(int position) {
Notification notification = adapter.getItem(position);
super.viewThread(notification.getStatus());
super.viewThread(notification.status);
}
public void onViewTag(String tag) {

View File

@ -26,22 +26,36 @@ import android.provider.Settings;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.TaskStackBuilder;
import android.text.Spanned;
import com.android.volley.AuthFailureError;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.ImageRequest;
import com.android.volley.toolbox.JsonArrayRequest;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.keylesspalace.tusky.entity.*;
import com.keylesspalace.tusky.entity.Notification;
import org.json.JSONArray;
import org.json.JSONException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class PullNotificationService extends IntentService {
private static final int NOTIFY_ID = 6; // This is an arbitrary number.
private static final String TAG = "PullNotifications"; // logging tag and Volley request tag
@ -62,82 +76,80 @@ public class PullNotificationService extends IntentService {
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
String domain = preferences.getString("domain", null);
String accessToken = preferences.getString("accessToken", null);
long date = preferences.getLong("lastUpdate", 0);
Date lastUpdate = null;
if (date != 0) {
lastUpdate = new Date(date);
}
checkNotifications(domain, accessToken, lastUpdate);
String lastUpdateId = preferences.getString("lastUpdateId", null);
checkNotifications(domain, accessToken, lastUpdateId);
}
private void checkNotifications(final String domain, final String accessToken,
final Date lastUpdate) {
String endpoint = getString(R.string.endpoint_notifications);
String url = "https://" + domain + endpoint;
JsonArrayRequest request = new JsonArrayRequest(url,
new Response.Listener<JSONArray>() {
final String lastUpdateId) {
OkHttpClient okHttpClient = new OkHttpClient.Builder()
.addInterceptor(new Interceptor() {
@Override
public void onResponse(JSONArray response) {
List<Notification> notifications;
try {
notifications = Notification.parse(response);
} catch (JSONException e) {
onCheckNotificationsFailure(e);
return;
}
onCheckNotificationsSuccess(notifications, lastUpdate);
public okhttp3.Response intercept(Chain chain) throws IOException {
Request originalRequest = chain.request();
Request.Builder builder = originalRequest.newBuilder()
.header("Authorization", String.format("Bearer %s", accessToken));
Request newRequest = builder.build();
return chain.proceed(newRequest);
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onCheckNotificationsFailure(error);
}
}) {
})
.build();
Gson gson = new GsonBuilder()
.registerTypeAdapter(Spanned.class, new SpannedTypeAdapter())
.create();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl("https://" + domain)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build();
MastodonAPI api = retrofit.create(MastodonAPI.class);
api.notifications(null, lastUpdateId, null).enqueue(new Callback<List<Notification>>() {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
public void onResponse(Call<List<Notification>> call, retrofit2.Response<List<Notification>> response) {
onCheckNotificationsSuccess(response.body(), lastUpdateId);
}
};
request.setTag(TAG);
VolleySingleton.getInstance(this).addToRequestQueue(request);
@Override
public void onFailure(Call<List<Notification>> call, Throwable t) {
onCheckNotificationsFailure((Exception) t);
}
});
}
private void onCheckNotificationsSuccess(List<Notification> notifications, Date lastUpdate) {
Date newest = null;
private void onCheckNotificationsSuccess(List<com.keylesspalace.tusky.entity.Notification> notifications, String lastUpdateId) {
List<MentionResult> mentions = new ArrayList<>();
for (Notification notification : notifications) {
if (notification.getType() == Notification.Type.MENTION) {
Status status = notification.getStatus();
for (com.keylesspalace.tusky.entity.Notification notification : notifications) {
if (notification.type == com.keylesspalace.tusky.entity.Notification.Type.MENTION) {
Status status = notification.status;
if (status != null) {
Date createdAt = status.getCreatedAt();
if (lastUpdate == null || createdAt.after(lastUpdate)) {
MentionResult mention = new MentionResult();
mention.content = status.getContent().toString();
mention.displayName = notification.getDisplayName();
mention.avatarUrl = status.getAvatar();
mentions.add(mention);
}
if (newest == null || createdAt.after(newest)) {
newest = createdAt;
}
MentionResult mention = new MentionResult();
mention.content = status.content.toString();
mention.displayName = notification.account.displayName;
mention.avatarUrl = status.account.avatar;
mentions.add(mention);
}
}
}
long now = new Date().getTime();
if (mentions.size() > 0) {
if (notifications.size() > 0) {
SharedPreferences preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
editor.putLong("lastUpdate", now);
editor.putString("lastUpdateId", notifications.get(0).id);
editor.apply();
}
if (mentions.size() > 0) {
loadAvatar(mentions, mentions.get(0).avatarUrl);
} else if (newest != null) {
long hoursAgo = (now - newest.getTime()) / (60 * 60 * 1000);
if (hoursAgo >= 1) {
dismissStaleNotifications();
}
}
}
@ -227,10 +239,4 @@ public class PullNotificationService extends IntentService {
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.notify(NOTIFY_ID, builder.build());
}
private void dismissStaleNotifications() {
NotificationManager notificationManager =
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(NOTIFY_ID);
}
}

View File

@ -38,6 +38,7 @@ import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonArrayRequest;
import com.android.volley.toolbox.JsonObjectRequest;
import com.keylesspalace.tusky.entity.Status;
import org.json.JSONArray;
import org.json.JSONException;
@ -48,6 +49,9 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
public class ReportActivity extends BaseActivity {
private static final String TAG = "ReportActivity"; // logging tag and Volley request tag
@ -197,46 +201,26 @@ public class ReportActivity extends BaseActivity {
}
private void fetchRecentStatuses(String accountId) {
String endpoint = String.format(getString(R.string.endpoint_statuses), accountId);
String url = "https://" + domain + endpoint;
JsonArrayRequest request = new JsonArrayRequest(url,
new Response.Listener<JSONArray>() {
@Override
public void onResponse(JSONArray response) {
List<Status> statusList;
try {
statusList = Status.parse(response);
} catch (JSONException e) {
onFetchStatusesFailure(e);
return;
}
// Add all the statuses except reblogs.
List<ReportAdapter.ReportStatus> itemList = new ArrayList<>();
for (Status status : statusList) {
if (status.getRebloggedByDisplayName() == null) {
ReportAdapter.ReportStatus item = new ReportAdapter.ReportStatus(
status.getId(), status.getContent(), false);
itemList.add(item);
}
}
adapter.addItems(itemList);
}
},
new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onFetchStatusesFailure(error);
}
}) {
mastodonAPI.accountStatuses(accountId, null, null, null).enqueue(new Callback<List<Status>>() {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
public void onResponse(Call<List<Status>> call, retrofit2.Response<List<Status>> response) {
List<Status> statusList = response.body();
List<ReportAdapter.ReportStatus> itemList = new ArrayList<>();
for (Status status : statusList) {
if (status.reblog != null) {
ReportAdapter.ReportStatus item = new ReportAdapter.ReportStatus(
status.id, status.content, false);
itemList.add(item);
}
}
adapter.addItems(itemList);
}
};
request.setTag(TAG);
VolleySingleton.getInstance(this).addToRequestQueue(request);
@Override
public void onFailure(Call<List<Status>> call, Throwable t) {
onFetchStatusesFailure((Exception) t);
}
});
}
private void onFetchStatusesFailure(Exception exception) {

View File

@ -33,6 +33,7 @@ import com.android.volley.Request;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonObjectRequest;
import com.keylesspalace.tusky.entity.Status;
import org.json.JSONObject;
@ -111,24 +112,24 @@ public class SFragment extends Fragment {
}
protected void reply(Status status) {
String inReplyToId = status.getId();
Status.Mention[] mentions = status.getMentions();
String inReplyToId = status.getActionableId();
Status.Mention[] mentions = status.mentions;
List<String> mentionedUsernames = new ArrayList<>();
for (Status.Mention mention : mentions) {
mentionedUsernames.add(mention.getUsername());
mentionedUsernames.add(mention.username);
}
mentionedUsernames.add(status.getUsername());
mentionedUsernames.add(status.account.username);
mentionedUsernames.remove(loggedInUsername);
Intent intent = new Intent(getContext(), ComposeActivity.class);
intent.putExtra("in_reply_to_id", inReplyToId);
intent.putExtra("reply_visibility", status.getVisibility().toString().toLowerCase());
intent.putExtra("reply_visibility", status.visibility.toString().toLowerCase());
intent.putExtra("mentioned_usernames", mentionedUsernames.toArray(new String[0]));
startActivity(intent);
}
protected void reblog(final Status status, final boolean reblog,
final RecyclerView.Adapter adapter, final int position) {
String id = status.getId();
String id = status.getActionableId();
String endpoint;
if (reblog) {
endpoint = String.format(getString(R.string.endpoint_reblog), id);
@ -139,7 +140,7 @@ public class SFragment extends Fragment {
new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
status.setReblogged(reblog);
status.reblogged = reblog;
adapter.notifyItemChanged(position);
}
}, null);
@ -147,7 +148,7 @@ public class SFragment extends Fragment {
protected void favourite(final Status status, final boolean favourite,
final RecyclerView.Adapter adapter, final int position) {
String id = status.getId();
String id = status.getActionableId();
String endpoint;
if (favourite) {
endpoint = String.format(getString(R.string.endpoint_favourite), id);
@ -157,7 +158,7 @@ public class SFragment extends Fragment {
sendRequest(Request.Method.POST, endpoint, null, new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
status.setFavourited(favourite);
status.favourited = favourite;
adapter.notifyItemChanged(position);
}
}, null);
@ -180,10 +181,10 @@ public class SFragment extends Fragment {
protected void more(Status status, View view, final AdapterItemRemover adapter,
final int position) {
final String id = status.getId();
final String accountId = status.getAccountId();
final String accountUsename = status.getUsername();
final Spanned content = status.getContent();
final String id = status.getActionableId();
final String accountId = status.getActionableStatus().account.id;
final String accountUsename = status.getActionableStatus().account.username;
final Spanned content = status.getActionableStatus().content;
PopupMenu popup = new PopupMenu(getContext(), view);
// Give a different menu depending on whether this is the user's own toot or not.
if (loggedInAccountId == null || !loggedInAccountId.equals(accountId)) {
@ -234,12 +235,8 @@ public class SFragment extends Fragment {
protected void viewMedia(String url, Status.MediaAttachment.Type type) {
switch (type) {
case IMAGE: {
Fragment newFragment;
if (fileExtensionMatches(url, "gif")) {
newFragment = ViewGifFragment.newInstance(url);
} else {
newFragment = ViewMediaFragment.newInstance(url);
}
Fragment newFragment = ViewMediaFragment.newInstance(url);
FragmentManager manager = getFragmentManager();
manager.beginTransaction()
.add(R.id.overlay_fragment_container, newFragment)
@ -264,7 +261,7 @@ public class SFragment extends Fragment {
protected void viewThread(Status status) {
Intent intent = new Intent(getContext(), ViewThreadActivity.class);
intent.putExtra("id", status.getId());
intent.putExtra("id", status.id);
startActivity(intent);
}

View File

@ -1,357 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky;
import android.support.annotation.Nullable;
import android.text.Spanned;
import com.emojione.Emojione;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
public class Status {
enum Visibility {
PUBLIC,
UNLISTED,
PRIVATE,
}
private String id;
private String accountId;
private String displayName;
/** the username with the remote domain appended, like @domain.name, if it's a remote account */
private String username;
/** the main text of the status, marked up with style for links & mentions, etc */
private Spanned content;
/** the fully-qualified url of the avatar image */
private String avatar;
private String rebloggedByDisplayName;
/** when the status was initially created */
private Date createdAt;
/** whether the authenticated user has reblogged this status */
private boolean reblogged;
/** whether the authenticated user has favourited this status */
private boolean favourited;
private boolean sensitive;
private String spoilerText;
private Visibility visibility;
private MediaAttachment[] attachments;
private Mention[] mentions;
static final int MAX_MEDIA_ATTACHMENTS = 4;
public Status(String id, String accountId, String displayName, String username, Spanned content,
String avatar, Date createdAt, boolean reblogged, boolean favourited,
String visibility) {
this.id = id;
this.accountId = accountId;
this.displayName = displayName;
this.username = username;
this.content = content;
this.avatar = avatar;
this.createdAt = createdAt;
this.reblogged = reblogged;
this.favourited = favourited;
this.spoilerText = "";
this.visibility = Visibility.valueOf(visibility.toUpperCase());
this.attachments = new MediaAttachment[0];
this.mentions = new Mention[0];
}
String getId() {
return id;
}
String getAccountId() {
return accountId;
}
String getDisplayName() {
return displayName;
}
String getUsername() {
return username;
}
Spanned getContent() {
return content;
}
String getAvatar() {
return avatar;
}
Date getCreatedAt() {
return createdAt;
}
String getRebloggedByDisplayName() {
return rebloggedByDisplayName;
}
boolean getReblogged() {
return reblogged;
}
boolean getFavourited() {
return favourited;
}
boolean getSensitive() {
return sensitive;
}
String getSpoilerText() {
return spoilerText;
}
Visibility getVisibility() {
return visibility;
}
MediaAttachment[] getAttachments() {
return attachments;
}
Mention[] getMentions() {
return mentions;
}
private void setRebloggedByDisplayName(String name) {
rebloggedByDisplayName = name;
}
void setReblogged(boolean reblogged) {
this.reblogged = reblogged;
}
void setFavourited(boolean favourited) {
this.favourited = favourited;
}
private void setSpoilerText(String spoilerText) {
this.spoilerText = spoilerText;
}
private void setMentions(Mention[] mentions) {
this.mentions = mentions;
}
private void setAttachments(MediaAttachment[] attachments, boolean sensitive) {
this.attachments = attachments;
this.sensitive = sensitive;
}
@Override
public int hashCode() {
return id.hashCode();
}
@Override
public boolean equals(Object other) {
if (this.id == null) {
return this == other;
} else if (!(other instanceof Status)) {
return false;
}
Status status = (Status) other;
return status.id.equals(this.id);
}
@SuppressWarnings("SimpleDateFormat") // UTC needs to not specify a Locale
@Nullable
private static Date parseDate(String dateTime) {
Date date;
String s = dateTime.replace("Z", "+00:00");
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
try {
date = format.parse(s);
} catch (ParseException e) {
e.printStackTrace();
return null;
}
return date;
}
private static MediaAttachment.Type parseMediaType(@Nullable String type) {
if (type == null) {
return MediaAttachment.Type.UNKNOWN;
}
switch (type.toUpperCase()) {
case "IMAGE": return MediaAttachment.Type.IMAGE;
case "GIFV":
case "VIDEO": return MediaAttachment.Type.VIDEO;
default: return MediaAttachment.Type.UNKNOWN;
}
}
public static Status parse(JSONObject object, boolean isReblog) throws JSONException {
String id = object.getString("id");
String content = object.getString("content");
Date createdAt = parseDate(object.getString("created_at"));
boolean reblogged = object.optBoolean("reblogged");
boolean favourited = object.optBoolean("favourited");
String spoilerText = object.getString("spoiler_text");
boolean sensitive = object.optBoolean("sensitive");
String visibility = object.getString("visibility");
JSONObject account = object.getJSONObject("account");
String accountId = account.getString("id");
String displayName = account.getString("display_name");
if (displayName.isEmpty()) {
displayName = account.getString("username");
}
String username = account.getString("acct");
String avatarUrl = account.getString("avatar");
String avatar;
if (!avatarUrl.equals("/avatars/original/missing.png")) {
avatar = avatarUrl;
} else {
avatar = "";
}
JSONArray mentionsArray = object.getJSONArray("mentions");
Mention[] mentions = null;
if (mentionsArray != null) {
int n = mentionsArray.length();
mentions = new Mention[n];
for (int i = 0; i < n; i++) {
JSONObject mention = mentionsArray.getJSONObject(i);
String url = mention.getString("url");
String mentionedUsername = mention.getString("acct");
String mentionedAccountId = mention.getString("id");
mentions[i] = new Mention(url, mentionedUsername, mentionedAccountId);
}
}
JSONArray mediaAttachments = object.getJSONArray("media_attachments");
MediaAttachment[] attachments = null;
if (mediaAttachments != null) {
int n = mediaAttachments.length();
attachments = new MediaAttachment[n];
for (int i = 0; i < n; i++) {
JSONObject attachment = mediaAttachments.getJSONObject(i);
String url = attachment.getString("url");
String previewUrl = attachment.getString("preview_url");
String type = attachment.getString("type");
attachments[i] = new MediaAttachment(url, previewUrl, parseMediaType(type));
}
}
Status reblog = null;
/* This case shouldn't be hit after the first recursion at all. But if this method is
* passed unusual data this check will prevent extra recursion */
if (!isReblog) {
JSONObject reblogObject = object.optJSONObject("reblog");
if (reblogObject != null) {
reblog = parse(reblogObject, true);
}
}
Status status;
if (reblog != null) {
status = reblog;
status.setRebloggedByDisplayName(displayName);
} else {
Spanned contentPlus = HtmlUtils.fromHtml(Emojione.shortnameToUnicode(content, false));
status = new Status(
id, accountId, displayName, username, contentPlus, avatar, createdAt,
reblogged, favourited, visibility);
if (mentions != null) {
status.setMentions(mentions);
}
if (attachments != null) {
status.setAttachments(attachments, sensitive);
}
if (!spoilerText.isEmpty()) {
status.setSpoilerText(spoilerText);
}
}
return status;
}
public static List<Status> parse(JSONArray array) throws JSONException {
List<Status> statuses = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject object = array.getJSONObject(i);
statuses.add(parse(object, false));
}
return statuses;
}
static class MediaAttachment {
enum Type {
IMAGE,
VIDEO,
UNKNOWN,
}
private String url;
private String previewUrl;
private Type type;
MediaAttachment(String url, String previewUrl, Type type) {
this.url = url;
this.previewUrl = previewUrl;
this.type = type;
}
String getUrl() {
return url;
}
String getPreviewUrl() {
return previewUrl;
}
Type getType() {
return type;
}
}
static class Mention {
private String url;
private String username;
private String id;
Mention(String url, String username, String id) {
this.url = url;
this.username = username;
this.id = id;
}
String getUrl() {
return url;
}
String getUsername() {
return username;
}
String getId() {
return id;
}
}
}

View File

@ -17,6 +17,8 @@ package com.keylesspalace.tusky;
import android.view.View;
import com.keylesspalace.tusky.entity.Status;
interface StatusActionListener {
void onReply(int position);
void onReblog(final boolean reblog, final int position);

View File

@ -30,6 +30,7 @@ import android.widget.ImageView;
import android.widget.TextView;
import android.widget.ToggleButton;
import com.keylesspalace.tusky.entity.Status;
import com.squareup.picasso.Picasso;
import com.varunest.sparkbutton.SparkButton;
import com.varunest.sparkbutton.SparkEventListener;
@ -124,8 +125,8 @@ class StatusViewHolder extends RecyclerView.ViewHolder {
final String accountUsername = text.subSequence(1, text.length()).toString();
String id = null;
for (Status.Mention mention: mentions) {
if (mention.getUsername().equals(accountUsername)) {
id = mention.getId();
if (mention.username.equals(accountUsername)) {
id = mention.id;
}
}
if (id != null) {
@ -227,7 +228,7 @@ class StatusViewHolder extends RecyclerView.ViewHolder {
final int n = Math.min(attachments.length, Status.MAX_MEDIA_ATTACHMENTS);
for (int i = 0; i < n; i++) {
String previewUrl = attachments[i].getPreviewUrl();
String previewUrl = attachments[i].previewUrl;
previews[i].setVisibility(View.VISIBLE);
@ -236,8 +237,8 @@ class StatusViewHolder extends RecyclerView.ViewHolder {
.placeholder(mediaPreviewUnloadedId)
.into(previews[i]);
final String url = attachments[i].getUrl();
final Status.MediaAttachment.Type type = attachments[i].getType();
final String url = attachments[i].url;
final Status.MediaAttachment.Type type = attachments[i].type;
previews[i].setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
@ -339,33 +340,35 @@ class StatusViewHolder extends RecyclerView.ViewHolder {
}
void setupWithStatus(Status status, StatusActionListener listener) {
setDisplayName(status.getDisplayName());
setUsername(status.getUsername());
setCreatedAt(status.getCreatedAt());
setContent(status.getContent(), status.getMentions(), listener);
setAvatar(status.getAvatar());
setReblogged(status.getReblogged());
setFavourited(status.getFavourited());
String rebloggedByDisplayName = status.getRebloggedByDisplayName();
if (rebloggedByDisplayName == null) {
Status realStatus = status.getActionableStatus();
setDisplayName(realStatus.account.displayName);
setUsername(realStatus.account.username);
setCreatedAt(realStatus.createdAt);
setContent(realStatus.content, realStatus.mentions, listener);
setAvatar(realStatus.account.avatar);
setReblogged(realStatus.reblogged);
setFavourited(realStatus.favourited);
String rebloggedByDisplayName = status.account.displayName;
if (status.reblog == null) {
hideRebloggedByDisplayName();
} else {
setRebloggedByDisplayName(rebloggedByDisplayName);
}
Status.MediaAttachment[] attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
Status.MediaAttachment[] attachments = realStatus.attachments;
boolean sensitive = realStatus.sensitive;
setMediaPreviews(attachments, sensitive, listener);
/* A status without attachments is sometimes still marked sensitive, so it's necessary to
* check both whether there are any attachments and if it's marked sensitive. */
if (!sensitive || attachments.length == 0) {
hideSensitiveMediaWarning();
}
setupButtons(listener, status.getAccountId());
setRebloggingEnabled(status.getVisibility() != Status.Visibility.PRIVATE);
if (status.getSpoilerText().isEmpty()) {
setupButtons(listener, realStatus.account.id);
setRebloggingEnabled(realStatus.visibility != Status.Visibility.PRIVATE);
if (realStatus.spoilerText.isEmpty()) {
hideSpoilerText();
} else {
setSpoilerText(status.getSpoilerText());
setSpoilerText(realStatus.spoilerText);
}
}
}

View File

@ -20,6 +20,8 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.keylesspalace.tusky.entity.Status;
import java.util.ArrayList;
import java.util.List;

View File

@ -21,6 +21,8 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.keylesspalace.tusky.entity.Status;
import java.util.ArrayList;
import java.util.List;

View File

@ -31,6 +31,7 @@ import com.android.volley.AuthFailureError;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonArrayRequest;
import com.keylesspalace.tusky.entity.Status;
import org.json.JSONArray;
import org.json.JSONException;
@ -39,6 +40,9 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
public class TimelineFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, StatusActionListener, FooterActionListener {
private static final String TAG = "Timeline"; // logging tag and Volley request tag
@ -117,7 +121,7 @@ public class TimelineFragment extends SFragment implements
TimelineAdapter adapter = (TimelineAdapter) view.getAdapter();
Status status = adapter.getItem(adapter.getItemCount() - 2);
if (status != null) {
sendFetchTimelineRequest(status.getId());
sendFetchTimelineRequest(status.id);
} else {
sendFetchTimelineRequest();
}
@ -168,67 +172,43 @@ public class TimelineFragment extends SFragment implements
}
private void sendFetchTimelineRequest(final String fromId) {
String endpoint;
MastodonAPI api = ((BaseActivity) getActivity()).mastodonAPI;
Callback<List<Status>> cb = new Callback<List<Status>>() {
@Override
public void onResponse(Call<List<Status>> call, retrofit2.Response<List<Status>> response) {
onFetchTimelineSuccess(response.body(), fromId);
}
@Override
public void onFailure(Call<List<Status>> call, Throwable t) {
onFetchTimelineFailure((Exception) t);
}
};
switch (kind) {
default:
case HOME: {
endpoint = getString(R.string.endpoint_timelines_home);
break;
}
case MENTIONS: {
endpoint = getString(R.string.endpoint_timelines_mentions);
api.homeTimeline(fromId, null, null).enqueue(cb);
break;
}
case PUBLIC: {
endpoint = getString(R.string.endpoint_timelines_public);
api.publicTimeline(null, fromId, null, null).enqueue(cb);
break;
}
case TAG: {
endpoint = String.format(getString(R.string.endpoint_timelines_tag), hashtagOrId);
api.hashtagTimeline(hashtagOrId, null, fromId, null, null).enqueue(cb);
break;
}
case USER: {
endpoint = String.format(getString(R.string.endpoint_statuses), hashtagOrId);
api.accountStatuses(hashtagOrId, fromId, null, null).enqueue(cb);
break;
}
case FAVOURITES: {
endpoint = getString(R.string.endpoint_favourites);
api.favourites(fromId, null, null).enqueue(cb);
break;
}
}
String url = "https://" + domain + endpoint;
if (fromId != null) {
url += "?max_id=" + fromId;
}
JsonArrayRequest request = new JsonArrayRequest(url,
new Response.Listener<JSONArray>() {
@Override
public void onResponse(JSONArray response) {
List<Status> statuses = null;
try {
statuses = Status.parse(response);
} catch (JSONException e) {
onFetchTimelineFailure(e);
}
if (statuses != null) {
onFetchTimelineSuccess(statuses, fromId);
}
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onFetchTimelineFailure(error);
}
}) {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
}
};
request.setTag(TAG);
VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
}
private void sendFetchTimelineRequest() {
@ -237,7 +217,7 @@ public class TimelineFragment extends SFragment implements
private static boolean findStatus(List<Status> statuses, String id) {
for (Status status : statuses) {
if (status.getId().equals(id)) {
if (status.id.equals(id)) {
return true;
}
}
@ -281,7 +261,7 @@ public class TimelineFragment extends SFragment implements
public void onLoadMore() {
Status status = adapter.getItem(adapter.getItemCount() - 2);
if (status != null) {
sendFetchTimelineRequest(status.getId());
sendFetchTimelineRequest(status.id);
} else {
sendFetchTimelineRequest();
}

View File

@ -30,12 +30,17 @@ import android.view.ViewGroup;
import com.android.volley.Request;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.List;
import retrofit2.Call;
import retrofit2.Callback;
public class ViewThreadFragment extends SFragment implements StatusActionListener {
private RecyclerView recyclerView;
private ThreadAdapter adapter;
@ -78,54 +83,39 @@ public class ViewThreadFragment extends SFragment implements StatusActionListene
}
private void sendStatusRequest(final String id) {
String endpoint = String.format(getString(R.string.endpoint_get_status), id);
super.sendRequest(Request.Method.GET, endpoint, null,
new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
Status status;
try {
status = Status.parse(response, false);
} catch (JSONException e) {
onThreadRequestFailure(id);
return;
}
int position = adapter.insertStatus(status);
recyclerView.scrollToPosition(position);
}
},
new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onThreadRequestFailure(id);
}
});
MastodonAPI api = ((BaseActivity) getActivity()).mastodonAPI;
api.status(id).enqueue(new Callback<Status>() {
@Override
public void onResponse(Call<Status> call, retrofit2.Response<Status> response) {
int position = adapter.insertStatus(response.body());
recyclerView.scrollToPosition(position);
}
@Override
public void onFailure(Call<Status> call, Throwable t) {
onThreadRequestFailure(id);
}
});
}
private void sendThreadRequest(final String id) {
String endpoint = String.format(getString(R.string.endpoint_context), id);
super.sendRequest(Request.Method.GET, endpoint, null,
new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
try {
List<Status> ancestors =
Status.parse(response.getJSONArray("ancestors"));
List<Status> descendants =
Status.parse(response.getJSONArray("descendants"));
adapter.addAncestors(ancestors);
adapter.addDescendants(descendants);
} catch (JSONException e) {
onThreadRequestFailure(id);
}
}
},
new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onThreadRequestFailure(id);
}
});
MastodonAPI api = ((BaseActivity) getActivity()).mastodonAPI;
api.statusContext(id).enqueue(new Callback<StatusContext>() {
@Override
public void onResponse(Call<StatusContext> call, retrofit2.Response<StatusContext> response) {
StatusContext context = response.body();
adapter.addAncestors(context.ancestors);
adapter.addDescendants(context.descendants);
}
@Override
public void onFailure(Call<StatusContext> call, Throwable t) {
onThreadRequestFailure(id);
}
});
}
private void onThreadRequestFailure(final String id) {
@ -162,7 +152,7 @@ public class ViewThreadFragment extends SFragment implements StatusActionListene
public void onViewThread(int position) {
Status status = adapter.getItem(position);
if (thisThreadsStatusId.equals(status.getId())) {
if (thisThreadsStatusId.equals(status.id)) {
// If already viewing this thread, don't reopen it.
return;
}

View File

@ -0,0 +1,55 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky.entity;
import com.google.gson.annotations.SerializedName;
public class Notification {
public enum Type {
@SerializedName("mention")
MENTION,
@SerializedName("reblog")
REBLOG,
@SerializedName("favourite")
FAVOURITE,
@SerializedName("follow")
FOLLOW,
}
public Type type;
public String id;
public Account account;
public Status status;
@Override
public int hashCode() {
return id.hashCode();
}
@Override
public boolean equals(Object other) {
if (this.id == null) {
return this == other;
} else if (!(other instanceof Notification)) {
return false;
}
Notification notification = (Notification) other;
return notification.id.equals(this.id);
}
}

View File

@ -0,0 +1,122 @@
/* Copyright 2017 Andrew Dawson
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky. If not, see
* <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky.entity;
import android.text.Spanned;
import com.google.gson.annotations.SerializedName;
import java.util.Date;
public class Status {
private Status actionableStatus;
public String getActionableId() {
return reblog == null ? id : reblog.id;
}
public Status getActionableStatus() {
return reblog == null ? this : reblog;
}
public enum Visibility {
@SerializedName("public")
PUBLIC,
@SerializedName("unlisted")
UNLISTED,
@SerializedName("private")
PRIVATE,
}
public String id;
public Account account;
public Spanned content;
public Status reblog;
@SerializedName("created_at")
public Date createdAt;
public boolean reblogged;
public boolean favourited;
public boolean sensitive;
@SerializedName("spoiler_text")
public String spoilerText;
public Visibility visibility;
@SerializedName("media_attachments")
public MediaAttachment[] attachments;
public Mention[] mentions;
public static final int MAX_MEDIA_ATTACHMENTS = 4;
@Override
public int hashCode() {
return id.hashCode();
}
@Override
public boolean equals(Object other) {
if (this.id == null) {
return this == other;
} else if (!(other instanceof Status)) {
return false;
}
Status status = (Status) other;
return status.id.equals(this.id);
}
public static class MediaAttachment {
public enum Type {
@SerializedName("image")
IMAGE,
@SerializedName("gifv")
GIFV,
@SerializedName("video")
VIDEO,
UNKNOWN,
}
public String url;
@SerializedName("preview_url")
public String previewUrl;
@SerializedName("text_url")
public String textUrl;
@SerializedName("remote_url")
public String remoteUrl;
public Type type;
}
public static class Mention {
public String id;
public String url;
@SerializedName("acct")
public String username;
}
}

View File

@ -1,7 +1,5 @@
package com.keylesspalace.tusky.entity;
import com.keylesspalace.tusky.Status;
import java.util.List;
public class StatusContext {