Merge branch 'master' into #142/SaveToots

# Conflicts:
#	app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java
main
torrentcome 7 years ago
commit 682c7166eb
  1. 1
      app/build.gradle
  2. 1
      app/src/main/java/com/keylesspalace/tusky/AccountActivity.java
  3. 45
      app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java
  4. 3
      app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.java
  5. 38
      app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java
  6. 54
      app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java
  7. 8
      app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java
  8. 3
      app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java
  9. 3
      app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java
  10. 3
      app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java
  11. 59
      app/src/main/java/com/keylesspalace/tusky/adapter/FooterViewHolder.java
  12. 3
      app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java
  13. 87
      app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java
  14. 14
      app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java
  15. 2
      app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java
  16. 82
      app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java
  17. 232
      app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java
  18. 250
      app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java
  19. 40
      app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java
  20. 237
      app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java
  21. 118
      app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java
  22. 1
      app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java
  23. 11
      app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.java
  24. 5
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
  25. 148
      app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.java
  26. 4
      app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java
  27. 67
      app/src/main/java/com/keylesspalace/tusky/util/NotificationMaker.java
  28. 52
      app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.java
  29. 29
      app/src/main/res/layout/item_footer.xml
  30. 25
      app/src/main/res/layout/item_footer_empty.xml
  31. 9
      app/src/main/res/layout/item_footer_end.xml
  32. 14
      app/src/main/res/values-pl/strings.xml
  33. 7
      app/src/main/res/values/strings.xml

@ -53,6 +53,7 @@ dependencies {
compile('org.eclipse.paho:org.eclipse.paho.android.service:1.1.1') {
exclude module: 'support-v4'
}
compile 'org.bouncycastle:bcprov-jdk15on:1.57'
testCompile 'junit:junit:4.12'
//room

@ -244,7 +244,6 @@ public class AccountActivity extends BaseActivity {
String subtitle = String.format(getString(R.string.status_username_format),
account.username);
getSupportActionBar().setSubtitle(subtitle);
}
boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(this)

@ -92,6 +92,7 @@ import com.keylesspalace.tusky.util.MediaUtils;
import com.keylesspalace.tusky.util.MentionTokenizer;
import com.keylesspalace.tusky.util.ParserUtils;
import com.keylesspalace.tusky.util.SpanUtils;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.EditTextTyped;
import com.keylesspalace.tusky.view.RoundedTransformation;
@ -117,12 +118,6 @@ import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import static com.keylesspalace.tusky.util.MediaUtils.MEDIA_SIZE_UNKNOWN;
import static com.keylesspalace.tusky.util.MediaUtils.getMediaSize;
import static com.keylesspalace.tusky.util.MediaUtils.inputStreamGetBytes;
import static com.keylesspalace.tusky.util.StringUtils.carriageReturn;
import static com.keylesspalace.tusky.util.StringUtils.randomAlphanumericString;
public class ComposeActivity extends BaseActivity implements ComposeOptionsFragment.Listener, ParserUtils.ParserListener {
private static final String TAG = "ComposeActivity"; // logging tag
private static final int STATUS_CHARACTER_LIMIT = 500;
@ -260,11 +255,13 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
if (previousInputContentInfo != null) {
onCommitContentInternal(previousInputContentInfo, previousFlags);
}
photoUploadUri = savedInstanceState.getParcelable("photoUploadUri");
} else {
showMarkSensitive = false;
startingVisibility = preferences.getString("rememberedVisibility", "public");
statusMarkSensitive = false;
startingHideText = false;
photoUploadUri = null;
}
/* If the composer is started up as a reply to another post, override the "starting" state
@ -435,7 +432,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
}
}
for (Uri uri : uriList) {
long mediaSize = getMediaSize(getContentResolver(), uri);
long mediaSize = MediaUtils.getMediaSize(getContentResolver(), uri);
pickMedia(uri, mediaSize);
}
} else if (type.equals("text/plain")) {
@ -477,6 +474,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
}
currentInputContentInfo = null;
currentFlags = 0;
outState.putParcelable("photoUploadUri", photoUploadUri);
super.onSaveInstanceState(outState);
}
@ -732,7 +730,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
// Just eat this exception.
}
} else {
mediaSize = MEDIA_SIZE_UNKNOWN;
mediaSize = MediaUtils.MEDIA_SIZE_UNKNOWN;
}
pickMedia(uri, mediaSize);
@ -875,7 +873,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[],
@NonNull int[] grantResults) {
@NonNull int[] grantResults) {
switch (requestCode) {
case PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE: {
if (grantResults.length > 0
@ -895,6 +893,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
}
}
@NonNull
private File createNewImageFile() throws IOException {
// Create an image file name
String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date());
@ -1073,7 +1072,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
final String filename = String.format("%s_%s_%s.%s",
getString(R.string.app_name),
String.valueOf(new Date().getTime()),
randomAlphanumericString(10),
StringUtils.randomAlphanumericString(10),
fileExtension);
byte[] content = item.content;
@ -1088,7 +1087,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
return;
}
content = inputStreamGetBytes(stream);
content = MediaUtils.inputStreamGetBytes(stream);
IOUtils.closeQuietly(stream);
if (content == null) {
@ -1114,8 +1113,8 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
@Override
public void onFailure(Call<Media> call, Throwable t) {
Log.d(TAG, t.getMessage());
onUploadFailure(item, false);
Log.d(TAG, "Upload request failed. " + t.getMessage());
onUploadFailure(item, call.isCanceled());
}
});
}
@ -1149,7 +1148,10 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
if (finishingUploadDialog != null) {
finishingUploadDialog.cancel();
}
removeMediaFromQueue(item);
if (!isCanceled) {
// If it is canceled, it's already been removed, otherwise do it.
removeMediaFromQueue(item);
}
}
private void cancelReadyingMedia(QueuedMedia item) {
@ -1166,19 +1168,19 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == MEDIA_PICK_RESULT && resultCode == RESULT_OK && data != null) {
if (resultCode == RESULT_OK && requestCode == MEDIA_PICK_RESULT && data != null) {
Uri uri = data.getData();
long mediaSize = getMediaSize(getContentResolver(), uri);
long mediaSize = MediaUtils.getMediaSize(getContentResolver(), uri);
pickMedia(uri, mediaSize);
} else if (requestCode == MEDIA_TAKE_PHOTO_RESULT && resultCode == RESULT_OK) {
long mediaSize = getMediaSize(getContentResolver(), photoUploadUri);
} else if (resultCode == RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
long mediaSize = MediaUtils.getMediaSize(getContentResolver(), photoUploadUri);
pickMedia(photoUploadUri, mediaSize);
}
}
private void pickMedia(Uri uri, long mediaSize) {
ContentResolver contentResolver = getContentResolver();
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
if (mediaSize == MediaUtils.MEDIA_SIZE_UNKNOWN) {
displayTransientError(R.string.error_media_upload_opening);
return;
}
@ -1280,7 +1282,7 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
if (!TextUtils.isEmpty(headerInfo.title)) {
cleanBaseUrl(headerInfo);
textEditor.append(headerInfo.title);
textEditor.append(carriageReturn);
textEditor.append(StringUtils.carriageReturn);
textEditor.append(headerInfo.baseUrl);
}
if (!TextUtils.isEmpty(headerInfo.image)) {
@ -1299,7 +1301,8 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
runOnUiThread(new Runnable() {
@Override
public void run() {
long mediaSize = getMediaSize(getContentResolver(), headerInfo);
long mediaSize = MediaUtils.getMediaSize(getContentResolver(),
headerInfo);
pickMedia(headerInfo, mediaSize);
}
});

@ -170,7 +170,8 @@ public class EditProfileActivity extends BaseActivity {
Account me = response.body();
priorDisplayName = me.getDisplayName();
priorNote = me.note.toString();
CircularImageView avatar = (CircularImageView) findViewById(R.id.edit_profile_avatar_preview);
CircularImageView avatar =
(CircularImageView) findViewById(R.id.edit_profile_avatar_preview);
ImageView header = (ImageView) findViewById(R.id.edit_profile_header_preview);
displayNameEditText.setText(priorDisplayName);

@ -18,14 +18,20 @@ package com.keylesspalace.tusky;
import android.app.Application;
import android.arch.persistence.room.Room;
import android.net.Uri;
import android.util.Log;
import com.jakewharton.picasso.OkHttp3Downloader;
import com.keylesspalace.tusky.db.AppDatabase;
import com.keylesspalace.tusky.util.OkHttpUtils;
import com.squareup.picasso.Picasso;
public class TuskyApplication extends Application {
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.security.Provider;
import java.security.Security;
public class TuskyApplication extends Application {
private static final String TAG = "TuskyApplication"; // logging tag
private static AppDatabase db;
public static AppDatabase getDB() {
@ -57,6 +63,36 @@ public class TuskyApplication extends Application {
Picasso.with(this).setLoggingEnabled(true);
}
/* Install the new provider or, if there's a pre-existing older version, replace the
* existing version of it. */
final String providerName = "BC";
Provider existingProvider = Security.getProvider(providerName);
if (existingProvider == null) {
try {
Security.addProvider(new BouncyCastleProvider());
} catch (SecurityException e) {
Log.e(TAG, "Permission to add the security provider was denied.");
}
} else {
Provider replacement = new BouncyCastleProvider();
if (existingProvider.getVersion() < replacement.getVersion()) {
Provider[] providers = Security.getProviders();
int priority = 1;
for (int i = 0; i < providers.length; i++) {
if (providers[i].getName().equals(providerName)) {
priority = i + 1;
}
}
try {
Security.removeProvider(providerName);
Security.insertProviderAt(replacement, priority);
} catch (SecurityException e) {
Log.e(TAG, "Permission to update a security provider was denied.");
}
}
}
db = Room.databaseBuilder(getApplicationContext(),
AppDatabase.class, "tuskyDB").allowMainThreadQueries().build();
}

@ -22,16 +22,22 @@ import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
public abstract class AccountAdapter extends RecyclerView.Adapter {
List<Account> accountList;
AccountActionListener accountActionListener;
FooterViewHolder.State footerState;
private String topId;
private String bottomId;
AccountAdapter(AccountActionListener accountActionListener) {
super();
accountList = new ArrayList<>();
this.accountActionListener = accountActionListener;
footerState = FooterViewHolder.State.END;
}
@Override
@ -39,12 +45,20 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
return accountList.size() + 1;
}
public void update(List<Account> newAccounts) {
public void update(@Nullable List<Account> newAccounts, @Nullable String fromId,
@Nullable String uptoId) {
if (newAccounts == null || newAccounts.isEmpty()) {
return;
}
if (fromId != null) {
bottomId = fromId;
}
if (uptoId != null) {
topId = uptoId;
}
if (accountList.isEmpty()) {
accountList = newAccounts;
// This construction removes duplicates.
accountList = new ArrayList<>(new HashSet<>(newAccounts));
} else {
int index = accountList.indexOf(newAccounts.get(newAccounts.size() - 1));
for (int i = 0; i < index; i++) {
@ -60,10 +74,25 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
notifyDataSetChanged();
}
public void addItems(List<Account> newAccounts) {
public void addItems(List<Account> newAccounts, @Nullable String fromId) {
if (fromId != null) {
bottomId = fromId;
}
int end = accountList.size();
accountList.addAll(newAccounts);
notifyItemRangeInserted(end, newAccounts.size());
Account last = accountList.get(end - 1);
if (last != null && !findAccount(newAccounts, last.id)) {
accountList.addAll(newAccounts);
notifyItemRangeInserted(end, newAccounts.size());
}
}
private static boolean findAccount(List<Account> accounts, String id) {
for (Account account : accounts) {
if (account.id.equals(id)) {
return true;
}
}
return false;
}
@Nullable
@ -84,10 +113,25 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
notifyItemInserted(position);
}
@Nullable
public Account getItem(int position) {
if (position >= 0 && position < accountList.size()) {
return accountList.get(position);
}
return null;
}
public void setFooterState(FooterViewHolder.State newFooterState) {
footerState = newFooterState;
}
@Nullable
public String getBottomId() {
return bottomId;
}
@Nullable
public String getTopId() {
return topId;
}
}

@ -17,7 +17,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder {
private TextView username;
private TextView displayName;
private CircularImageView avatar;
private String id;
private String accountId;
AccountViewHolder(View itemView) {
super(itemView);
@ -28,7 +28,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder {
}
void setupWithAccount(Account account) {
id = account.id;
accountId = account.id;
String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.username);
username.setText(formattedUsername);
@ -45,7 +45,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder {
container.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onViewAccount(id);
listener.onViewAccount(accountId);
}
});
}
@ -54,7 +54,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder {
container.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onViewAccount(id);
listener.onViewAccount(accountId);
}
});
}

@ -59,6 +59,9 @@ public class BlocksAdapter extends AccountAdapter {
BlockedUserViewHolder holder = (BlockedUserViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position));
holder.setupActionListener(accountActionListener, true);
} else {
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setState(footerState);
}
}

@ -55,6 +55,9 @@ public class FollowAdapter extends AccountAdapter {
AccountViewHolder holder = (AccountViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position));
holder.setupActionListener(accountActionListener);
} else {
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setState(footerState);
}
}

@ -59,6 +59,9 @@ public class FollowRequestsAdapter extends AccountAdapter {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position));
holder.setupActionListener(accountActionListener);
} else {
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setState(footerState);
}
}

@ -15,18 +15,69 @@
package com.keylesspalace.tusky.adapter;
import android.graphics.drawable.Drawable;
import android.support.v7.content.res.AppCompatResources;
import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.support.v7.widget.RecyclerView.LayoutParams;
import com.keylesspalace.tusky.R;
class FooterViewHolder extends RecyclerView.ViewHolder {
public class FooterViewHolder extends RecyclerView.ViewHolder {
public enum State {
EMPTY,
END,
LOADING
}
private View container;
private ProgressBar progressBar;
private TextView endMessage;
FooterViewHolder(View itemView) {
super(itemView);
ProgressBar progressBar = (ProgressBar) itemView.findViewById(R.id.footer_progress_bar);
if (progressBar != null) {
progressBar.setIndeterminate(true);
container = itemView.findViewById(R.id.footer_container);
progressBar = (ProgressBar) itemView.findViewById(R.id.footer_progress_bar);
endMessage = (TextView) itemView.findViewById(R.id.footer_end_message);
Drawable top = AppCompatResources.getDrawable(itemView.getContext(),
R.drawable.elephant_friend);
if (top != null) {
top.setBounds(0, 0, top.getIntrinsicWidth() / 2, top.getIntrinsicHeight() / 2);
}
endMessage.setCompoundDrawables(null, top, null, null);
}
public void setState(State state) {
switch (state) {
case LOADING: {
RecyclerView.LayoutParams layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT);
container.setLayoutParams(layoutParams);
container.setVisibility(View.VISIBLE);
progressBar.setVisibility(View.VISIBLE);
endMessage.setVisibility(View.GONE);
break;
}
case END: {
RecyclerView.LayoutParams layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT);
container.setLayoutParams(layoutParams);
container.setVisibility(View.GONE);
progressBar.setVisibility(View.GONE);
endMessage.setVisibility(View.GONE);
break;
}
case EMPTY: {
RecyclerView.LayoutParams layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT);
container.setLayoutParams(layoutParams);
container.setVisibility(View.VISIBLE);
progressBar.setVisibility(View.GONE);
endMessage.setVisibility(View.VISIBLE);
break;
}
}
}
}

@ -44,6 +44,9 @@ public class MutesAdapter extends AccountAdapter {
MutedUserViewHolder holder = (MutedUserViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position));
holder.setupActionListener(accountActionListener, true, position);
} else {
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setState(footerState);
}
}

@ -37,6 +37,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.squareup.picasso.Picasso;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
public class NotificationsAdapter extends RecyclerView.Adapter implements AdapterItemRemover {
@ -45,17 +46,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 2;
private static final int VIEW_TYPE_FOLLOW = 3;
public enum FooterState {
EMPTY,
END,
LOADING
}
private List<Notification> notifications;
private StatusActionListener statusListener;
private NotificationActionListener notificationActionListener;
private FooterState footerState = FooterState.END;
private FooterViewHolder.State footerState;
private boolean mediaPreviewEnabled;
private String bottomId;
private String topId;
public NotificationsAdapter(StatusActionListener statusListener,
NotificationActionListener notificationActionListener) {
@ -63,6 +60,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
notifications = new ArrayList<>();
this.statusListener = statusListener;
this.notificationActionListener = notificationActionListener;
footerState = FooterViewHolder.State.END;
mediaPreviewEnabled = true;
}
@ -76,24 +74,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
return new StatusViewHolder(view);
}
case VIEW_TYPE_FOOTER: {
View view;
switch (footerState) {
default:
case LOADING:
view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_footer, parent, false);
break;
case END: {
view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_footer_end, parent, false);
break;
}
case EMPTY: {
view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_footer_empty, parent, false);
break;
}
}
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_footer, parent, false);
return new FooterViewHolder(view);
}
case VIEW_TYPE_STATUS_NOTIFICATION: {
@ -137,6 +119,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
break;
}
}
} else {
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setState(footerState);
}
}
@ -186,19 +171,28 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
}
}
public @Nullable Notification getItem(int position) {
@Nullable
public Notification getItem(int position) {
if (position >= 0 && position < notifications.size()) {
return notifications.get(position);
}
return null;
}
public void update(List<Notification> newNotifications) {
public void update(@Nullable List<Notification> newNotifications, @Nullable String fromId,
@Nullable String uptoId) {
if (newNotifications == null || newNotifications.isEmpty()) {
return;
}
if (fromId != null) {
bottomId = fromId;
}
if (uptoId != null) {
topId = uptoId;
}
if (notifications.isEmpty()) {
notifications = newNotifications;
// This construction removes duplicates.
notifications = new ArrayList<>(new HashSet<>(newNotifications));
} else {
int index = notifications.indexOf(newNotifications.get(newNotifications.size() - 1));
for (int i = 0; i < index; i++) {
@ -214,10 +208,25 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
notifyDataSetChanged();
}
public void addItems(List<Notification> new_notifications) {
public void addItems(List<Notification> newNotifications, @Nullable String fromId) {
if (fromId != null) {
bottomId = fromId;
}
int end = notifications.size();
notifications.addAll(new_notifications);
notifyItemRangeInserted(end, new_notifications.size());
Notification last = notifications.get(end - 1);
if (last != null && !findNotification(newNotifications, last.id)) {
notifications.addAll(newNotifications);
notifyItemRangeInserted(end, newNotifications.size());
}
}
private static boolean findNotification(List<Notification> notifications, String id) {
for (Notification notification : notifications) {
if (notification.id.equals(id)) {
return true;
}
}
return false;
}
public void clear() {
@ -225,12 +234,18 @@ public class NotificationsAdapter extends RecyclerView.Adapter implements Adapte
notifyDataSetChanged();
}
public void setFooterState(FooterState newFooterState) {
FooterState oldValue = footerState;
public void setFooterState(FooterViewHolder.State newFooterState) {
footerState = newFooterState;
if (footerState != oldValue) {
notifyItemChanged(notifications.size());
}
}
@Nullable
public String getBottomId() {
return bottomId;
}
@Nullable
public String getTopId() {
return topId;
}
public void setMediaPreviewEnabled(boolean enabled) {

@ -425,8 +425,8 @@ class StatusViewHolder extends RecyclerView.ViewHolder {
container.setOnClickListener(viewThreadListener);
}
void setupWithStatus(Status status, StatusActionListener listener,
boolean mediaPreviewEnabled) {
void setupWithStatus(Status status, final StatusActionListener listener,
boolean mediaPreviewEnabled) {
Status realStatus = status.getActionableStatus();
setDisplayName(realStatus.account.getDisplayName());
@ -474,5 +474,15 @@ class StatusViewHolder extends RecyclerView.ViewHolder {
} else {
setSpoilerText(realStatus.spoilerText);
}
// I think it's not efficient to create new object every time we bind a holder.
// More efficient approach would be creating View.OnClickListener during holder creation
// and storing StatusActionListener in a variable after binding.
rebloggedBar.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onOpenReblog(getAdapterPosition());
}
});
}
}

@ -103,7 +103,7 @@ public class ThreadAdapter extends RecyclerView.Adapter implements AdapterItemRe
// In case of refresh, remove old ancestors and descendants first. We'll remove all blindly,
// as we have no guarantee on their order to be the same as before
int oldSize = statuses.size();
if (oldSize > 0) {
if (oldSize > 1) {
mainStatus = statuses.get(statusIndex);
statuses.clear();
notifyItemRangeRemoved(0, oldSize);

@ -27,27 +27,25 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.entity.Status;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItemRemover {
private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_FOOTER = 1;
public enum FooterState {
EMPTY,
END,
LOADING
}
private List<Status> statuses;
private StatusActionListener statusListener;
private FooterState footerState = FooterState.END;
private FooterViewHolder.State footerState;
private boolean mediaPreviewEnabled;
private String topId;
private String bottomId;
public TimelineAdapter(StatusActionListener statusListener) {
super();
statuses = new ArrayList<>();
this.statusListener = statusListener;
footerState = FooterViewHolder.State.END;
mediaPreviewEnabled = true;
}
@ -61,24 +59,8 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
return new StatusViewHolder(view);
}
case VIEW_TYPE_FOOTER: {
View view;
switch (footerState) {
default:
case LOADING:
view = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item_footer, viewGroup, false);
break;
case END: {
view = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item_footer_end, viewGroup, false);
break;
}
case EMPTY: {
view = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item_footer_empty, viewGroup, false);
break;
}
}
View view = LayoutInflater.from(viewGroup.getContext())
.inflate(R.layout.item_footer, viewGroup, false);
return new FooterViewHolder(view);
}
}
@ -90,6 +72,9 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
StatusViewHolder holder = (StatusViewHolder) viewHolder;
Status status = statuses.get(position);
holder.setupWithStatus(status, statusListener, mediaPreviewEnabled);
} else {
FooterViewHolder holder = (FooterViewHolder) viewHolder;
holder.setState(footerState);
}
}
@ -126,12 +111,20 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
}
}
public void update(List<Status> newStatuses) {
public void update(@Nullable List<Status> newStatuses, @Nullable String fromId,
@Nullable String uptoId) {
if (newStatuses == null || newStatuses.isEmpty()) {
return;
}
if (fromId != null) {
bottomId = fromId;
}
if (uptoId != null) {
topId = uptoId;
}
if (statuses.isEmpty()) {
statuses = newStatuses;
// This construction removes duplicates.
statuses = new ArrayList<>(new HashSet<>(newStatuses));
} else {
int index = statuses.indexOf(newStatuses.get(newStatuses.size() - 1));
for (int i = 0; i < index; i++) {
@ -147,10 +140,25 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
notifyDataSetChanged();
}
public void addItems(List<Status> newStatuses) {
public void addItems(List<Status> newStatuses, @Nullable String fromId) {
if (fromId != null) {
bottomId = fromId;
}
int end = statuses.size();
statuses.addAll(newStatuses);
notifyItemRangeInserted(end, newStatuses.size());
Status last = statuses.get(end - 1);
if (last != null && !findStatus(newStatuses, last.id)) {
statuses.addAll(newStatuses);
notifyItemRangeInserted(end, newStatuses.size());
}
}
private static boolean findStatus(List<Status> statuses, String id) {
for (Status status : statuses) {
if (status.id.equals(id)) {
return true;
}
}
return false;
}
public void clear() {
@ -166,8 +174,8 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
return null;
}
public void setFooterState(FooterState newFooterState) {
FooterState oldValue = footerState;
public void setFooterState(FooterViewHolder.State newFooterState) {
FooterViewHolder.State oldValue = footerState;
footerState = newFooterState;
if (footerState != oldValue) {
notifyItemChanged(statuses.size());
@ -177,4 +185,14 @@ public class TimelineAdapter extends RecyclerView.Adapter implements AdapterItem
public void setMediaPreviewEnabled(boolean enabled) {
mediaPreviewEnabled = enabled;
}
@Nullable
public String getBottomId() {
return bottomId;
}
@Nullable
public String getTopId() {
return topId;
}
}

@ -36,6 +36,7 @@ import com.keylesspalace.tusky.adapter.AccountAdapter;
import com.keylesspalace.tusky.adapter.BlocksAdapter;
import com.keylesspalace.tusky.adapter.FollowAdapter;
import com.keylesspalace.tusky.adapter.FollowRequestsAdapter;
import com.keylesspalace.tusky.adapter.FooterViewHolder;
import com.keylesspalace.tusky.adapter.MutesAdapter;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.entity.Account;
@ -43,6 +44,7 @@ import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.util.HttpHeaderLink;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
@ -71,6 +73,10 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi
private AccountAdapter adapter;
private TabLayout.OnTabSelectedListener onTabSelectedListener;
private MastodonApi api;
private boolean bottomLoading;
private int bottomFetches;
private boolean topLoading;
private int topFetches;
public static AccountListFragment newInstance(Type type) {
Bundle arguments = new Bundle();
@ -160,13 +166,7 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi
scrollListener = new EndlessOnScrollListener(layoutManager) {
@Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
AccountAdapter adapter = (AccountAdapter) view.getAdapter();
Account account = adapter.getItem(adapter.getItemCount() - 2);
if (account != null) {
fetchAccounts(account.id, null);
} else {
fetchAccounts();
}
AccountListFragment.this.onLoadMore(view);
}
};
recyclerView.addOnScrollListener(scrollListener);
@ -181,78 +181,6 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi
super.onDestroyView();
}
private void fetchAccounts(final String fromId, String uptoId) {
Callback<List<Account>> cb = new Callback<List<Account>>() {
@Override
public void onResponse(Call<List<Account>> call, Response<List<Account>> response) {
if (response.isSuccessful()) {
onFetchAccountsSuccess(response.body(), fromId);
} else {
onFetchAccountsFailure(new Exception(response.message()));
}
}
@Override
public void onFailure(Call<List<Account>> call, Throwable t) {
onFetchAccountsFailure((Exception) t);
}
};
Call<List<Account>> listCall;
switch (type) {
default:
case FOLLOWS: {
listCall = api.accountFollowing(accountId, fromId, uptoId, null);
break;
}
case FOLLOWERS: {
listCall = api.accountFollowers(accountId, fromId, uptoId, null);
break;
}
case BLOCKS: {
listCall = api.blocks(fromId, uptoId, null);
break;
}
case MUTES: {
listCall = api.mutes(fromId, uptoId, null);
break;
}
case FOLLOW_REQUESTS: {
listCall = api.followRequests(fromId, uptoId, null);
break;
}
}
callList.add(listCall);
listCall.enqueue(cb);
}
private void fetchAccounts() {
fetchAccounts(null, null);
}
private static boolean findAccount(List<Account> accounts, String id) {
for (Account account : accounts) {
if (account.id.equals(id)) {
return true;
}
}
return false;
}
private void onFetchAccountsSuccess(List<Account> accounts, String fromId) {
if (fromId != null) {
if (accounts.size() > 0 && !findAccount(accounts, fromId)) {
adapter.addItems(accounts);
}
} else {
adapter.update(accounts);
}
}
private void onFetchAccountsFailure(Exception exception) {
Log.e(TAG, "Fetch failure: " + exception.getMessage());
}
@Override
public void onViewAccount(String id) {
Intent intent = new Intent(getContext(), AccountActivity.class);
@ -431,7 +359,12 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi
}
private void onRespondToFollowRequestFailure(boolean accept, String accountId) {
String verb = (accept) ? "accept" : "reject";
String verb;
if (accept) {
verb = "accept";
} else {
verb = "reject";
}
String message = String.format("Failed to %s account id %s.", verb, accountId);
Log.e(TAG, message);
}
@ -444,4 +377,143 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi
layoutManager.scrollToPositionWithOffset(0, 0);
scrollListener.reset();
}
private enum FetchEnd {
TOP,
BOTTOM
}
private Call<List<Account>> getFetchCallByListType(Type type, String fromId, String uptoId) {
switch (type) {
default:
case FOLLOWS: return api.accountFollowing(accountId, fromId, uptoId, null);
case FOLLOWERS: return api.accountFollowers(accountId, fromId, uptoId, null);
case BLOCKS: return api.blocks(fromId, uptoId, null);
case MUTES: return api.mutes(fromId, uptoId, null);
case FOLLOW_REQUESTS: return api.followRequests(fromId, uptoId, null);
}
}
private void fetchAccounts(String fromId, String uptoId, final FetchEnd fetchEnd) {
/* If there is a fetch already ongoing, record however many fetches are requested and
* fulfill them after it's complete. */
if (fetchEnd == FetchEnd.TOP && topLoading) {
topFetches++;
return;
}
if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) {
bottomFetches++;
return;
}
if (fromId != null || adapter.getItemCount() <= 1) {
/* When this is called by the EndlessScrollListener it cannot refresh the footer state
* using adapter.notifyItemChanged. So its necessary to postpone doing so until a
* convenient time for the UI thread using a Runnable. */
recyclerView.post(new Runnable() {
@Override
public void run() {
adapter.setFooterState(FooterViewHolder.State.LOADING);
}
});
}
Callback<List<Account>> cb = new Callback<List<Account>>() {
@Override
public void onResponse(Call<List<Account>> call, Response<List<Account>> response) {
if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link");
onFetchAccountsSuccess(response.body(), linkHeader, fetchEnd);
} else {
onFetchAccountsFailure(new Exception(response.message()), fetchEnd);
}
}
@Override
public void onFailure(Call<List<Account>> call, Throwable t) {
onFetchAccountsFailure((Exception) t, fetchEnd);
}
};
Call<List<Account>> listCall = getFetchCallByListType(type, fromId, uptoId);
callList.add(listCall);
listCall.enqueue(cb);
}
private void onFetchAccountsSuccess(List<Account> accounts, String linkHeader,
FetchEnd fetchEnd) {
List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader);
switch (fetchEnd) {
case TOP: {
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(accounts, null, uptoId);
break;
}
case BOTTOM: {
HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next");
String fromId = null;
if (next != null) {
fromId = next.uri.getQueryParameter("max_id");
}
if (adapter.getItemCount() > 1) {
adapter.addItems(accounts, fromId);
} else {
/* If this is the first fetch, also save the id from the "previous" link and
* treat this operation as a refresh so the scroll position doesn't get pushed
* down to the end. */
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(accounts, fromId, uptoId);
}
break;
}
}
fulfillAnyQueuedFetches(fetchEnd);
if (accounts.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(FooterViewHolder.State.EMPTY);
} else {
adapter.setFooterState(FooterViewHolder.State.END);
}
}
private void onFetchAccountsFailure(Exception exception, FetchEnd fetchEnd) {
Log.e(TAG, "Fetch failure: " + exception.getMessage());
fulfillAnyQueuedFetches(fetchEnd);
}
private void onRefresh() {
fetchAccounts(null, adapter.getTopId(), FetchEnd.TOP);
}
private void onLoadMore(RecyclerView recyclerView) {
AccountAdapter adapter = (AccountAdapter) recyclerView.getAdapter();
fetchAccounts(adapter.getBottomId(), null, FetchEnd.BOTTOM);
}
private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) {
switch (fetchEnd) {
case BOTTOM: {
bottomLoading = false;
if (bottomFetches > 0) {
bottomFetches--;
onLoadMore(recyclerView);
}
break;
}
case TOP: {
topLoading = false;
if (topFetches > 0) {
topFetches--;
onRefresh();
}
break;
}
}
}
}

@ -34,12 +34,14 @@ import android.view.View;
import android.view.ViewGroup;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.adapter.FooterViewHolder;
import com.keylesspalace.tusky.adapter.NotificationsAdapter;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.HttpHeaderLink;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
@ -55,15 +57,23 @@ public class NotificationsFragment extends SFragment implements
SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = "Notifications"; // logging tag
private enum FetchEnd {
TOP,
BOTTOM,
}
private SwipeRefreshLayout swipeRefreshLayout;
private LinearLayoutManager layoutManager;
private RecyclerView recyclerView;
private EndlessOnScrollListener scrollListener;
private NotificationsAdapter adapter;
private TabLayout.OnTabSelectedListener onTabSelectedListener;
private Call<List<Notification>> listCall;
private boolean hideFab;
private TimelineReceiver timelineReceiver;
private boolean topLoading;
private int topFetches;
private boolean bottomLoading;
private int bottomFetches;
public static NotificationsFragment newInstance() {
NotificationsFragment fragment = new NotificationsFragment();
@ -157,27 +167,13 @@ public class NotificationsFragment extends SFragment implements
@Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
NotificationsAdapter adapter = (NotificationsAdapter) view.getAdapter();
Notification notification = adapter.getItem(adapter.getItemCount() - 2);
if (notification != null) {
sendFetchNotificationsRequest(notification.id, null);
} else {
sendFetchNotificationsRequest();
}
NotificationsFragment.this.onLoadMore(view);
}
};
recyclerView.addOnScrollListener(scrollListener);
}
@Override
public void onDestroy() {
super.onDestroy();
if (listCall != null) {
listCall.cancel();
}
}
@Override
public void onDestroyView() {
TabLayout tabLayout = (TabLayout) getActivity().findViewById(R.id.tab_layout);
@ -189,88 +185,9 @@ public class NotificationsFragment extends SFragment implements
super.onDestroyView();
}
private void jumpToTop() {
layoutManager.scrollToPosition(0);
scrollListener.reset();
}
private void sendFetchNotificationsRequest(final String fromId, String uptoId) {
if (fromId != null || adapter.getItemCount() <= 1) {
adapter.setFooterState(NotificationsAdapter.FooterState.LOADING);
}
listCall = mastodonAPI.notifications(fromId, uptoId, null);
listCall.enqueue(new Callback<List<Notification>>() {
@Override
public void onResponse(Call<List<Notification>> call,
Response<List<Notification>> response) {
if (response.isSuccessful()) {
onFetchNotificationsSuccess(response.body(), fromId);
} else {
onFetchNotificationsFailure(new Exception(response.message()));
}
}
@Override
public void onFailure(Call<List<Notification>> call, Throwable t) {
onFetchNotificationsFailure((Exception) t);
}
});
callList.add(listCall);
}
private void sendFetchNotificationsRequest() {
sendFetchNotificationsRequest(null, null);
}
private static boolean findNotification(List<Notification> notifications, String id) {
for (Notification notification : notifications) {
if (notification.id.equals(id)) {
return true;
}
}
return false;
}
private void onFetchNotificationsSuccess(List<Notification> notifications, String fromId) {
if (fromId != null) {
if (notifications.size() > 0 && !findNotification(notifications, fromId)) {
adapter.addItems(notifications);
// Set last update id for pull notifications so that we don't get notified
// about things we already loaded here
SharedPreferences preferences = getActivity()
.getSharedPreferences(getString(R.string.preferences_file_key),
Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
editor.putString("lastUpdateId", notifications.get(0).id);
editor.apply();
}
} else {
adapter.update(notifications);
}
if (notifications.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(NotificationsAdapter.FooterState.EMPTY);
} else if (fromId != null) {
adapter.setFooterState(NotificationsAdapter.FooterState.END);
}
swipeRefreshLayout.setRefreshing(false);
}
private void onFetchNotificationsFailure(Exception exception) {
swipeRefreshLayout.setRefreshing(false);
Log.e(TAG, "Fetch failure: " + exception.getMessage());
}
@Override
public void onRefresh() {
Notification notification = adapter.getItem(0);
if (notification != null) {
sendFetchNotificationsRequest(null, notification.id);
} else {
sendFetchNotificationsRequest();
}
sendFetchNotificationsRequest(null, adapter.getTopId(), FetchEnd.TOP);
}
@Override
@ -308,6 +225,12 @@ public class NotificationsFragment extends SFragment implements
super.viewThread(notification.status);
}
@Override
public void onOpenReblog(int position) {
Notification notification = adapter.getItem(position);
if (notification != null) onViewAccount(notification.account.id);
}
@Override
public void onViewTag(String tag) {
super.viewTag(tag);
@ -334,8 +257,141 @@ public class NotificationsFragment extends SFragment implements
}
}
private void onLoadMore(RecyclerView view) {
NotificationsAdapter adapter = (NotificationsAdapter) view.getAdapter();
sendFetchNotificationsRequest(adapter.getBottomId(), null, FetchEnd.BOTTOM);
}
private void jumpToTop() {
layoutManager.scrollToPosition(0);
scrollListener.reset();
}
private void sendFetchNotificationsRequest(String fromId, String uptoId,
final FetchEnd fetchEnd) {
/* If there is a fetch already ongoing, record however many fetches are requested and
* fulfill them after it's complete. */
if (fetchEnd == FetchEnd.TOP && topLoading) {
topFetches++;
return;
}
if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) {
bottomFetches++;
return;
}
if (fromId != null || adapter.getItemCount() <= 1) {
/* When this is called by the EndlessScrollListener it cannot refresh the footer state
* using adapter.notifyItemChanged. So its necessary to postpone doing so until a
* convenient time for the UI thread using a Runnable. */
recyclerView.post(new Runnable() {
@Override
public void run() {
adapter.setFooterState(FooterViewHolder.State.LOADING);
}
});
}
Call<List<Notification>> call = mastodonApi.notifications(fromId, uptoId, null);
call.enqueue(new Callback<List<Notification>>() {
@Override
public void onResponse(Call<List<Notification>> call,
Response<List<Notification>> response) {
if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link");
onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd);
} else {
onFetchNotificationsFailure(new Exception(response.message()), fetchEnd);
}
}
@Override
public void onFailure(Call<List<Notification>> call, Throwable t) {
onFetchNotificationsFailure((Exception) t, fetchEnd);
}
});
callList.add(call);
}
private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader,
FetchEnd fetchEnd) {
List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader);
switch (fetchEnd) {
case TOP: {
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(notifications, null, uptoId);
break;
}
case BOTTOM: {
HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next");
String fromId = null;
if (next != null) {
fromId = next.uri.getQueryParameter("max_id");
}
if (adapter.getItemCount() > 1) {
adapter.addItems(notifications, fromId);
} else {
/* If this is the first fetch, also save the id from the "previous" link and
* treat this operation as a refresh so the scroll position doesn't get pushed
* down to the end. */
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(notifications, fromId, uptoId);
}
/* Set last update id for pull notifications so that we don't get notified
* about things we already loaded here */
getPrivatePreferences().edit()
.putString("lastUpdateId", fromId)
.apply();
break;
}
}
fulfillAnyQueuedFetches(fetchEnd);
if (notifications.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(FooterViewHolder.State.EMPTY);
} else {
adapter.setFooterState(FooterViewHolder.State.END);
}
swipeRefreshLayout.setRefreshing(false);
}
private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd) {
swipeRefreshLayout.setRefreshing(false);
Log.e(TAG, "Fetch failure: " + exception.getMessage());
fulfillAnyQueuedFetches(fetchEnd);
}
private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) {
switch (fetchEnd) {
case BOTTOM: {
bottomLoading = false;
if (bottomFetches > 0) {
bottomFetches--;
onLoadMore(recyclerView);
}
break;
}
case TOP: {
topLoading = false;
if (topFetches > 0) {
topFetches--;
onRefresh();
}
break;
}
}
}
private void fullyRefresh() {
adapter.clear();
sendFetchNotificationsRequest(null, null);
sendFetchNotificationsRequest(null, null, FetchEnd.TOP);
}
}

@ -57,10 +57,11 @@ import retrofit2.Response;
* overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear
* up what needs to be where. */
public abstract class SFragment extends BaseFragment {
protected static final int COMPOSE_RESULT = 1;
protected String loggedInAccountId;
protected String loggedInUsername;
protected MastodonApi mastodonAPI;
protected static int COMPOSE_RESULT = 1;
protected MastodonApi mastodonApi;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@ -75,7 +76,13 @@ public abstract class SFragment extends BaseFragment {
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
BaseActivity activity = (BaseActivity) getActivity();
mastodonAPI = activity.mastodonApi;
mastodonApi = activity.mastodonApi;
}
@Override
public void startActivity(Intent intent) {
super.startActivity(intent);
getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
}
protected void reply(Status status) {
@ -122,9 +129,9 @@ public abstract class SFragment extends BaseFragment {
Call<Status> call;
if (reblog) {
call = mastodonAPI.reblogStatus(id);
call = mastodonApi.reblogStatus(id);
} else {
call = mastodonAPI.unreblogStatus(id);
call = mastodonApi.unreblogStatus(id);
}
call.enqueue(cb);
callList.add(call);
@ -154,16 +161,21 @@ public abstract class SFragment extends BaseFragment {
Call<Status> call;
if (favourite) {
call = mastodonAPI.favouriteStatus(id);
call = mastodonApi.favouriteStatus(id);
} else {
call = mastodonAPI.unfavouriteStatus(id);
call = mastodonApi.unfavouriteStatus(id);
}
call.enqueue(cb);
callList.add(call);
}
protected void openReblog(@Nullable final Status status) {
if (status == null) return;
viewAccount(status.account.id);
}
private void mute(String id) {
Call<Relationship> call = mastodonAPI.muteAccount(id);
Call<Relationship> call = mastodonApi.muteAccount(id);
call.enqueue(new Callback<Relationship>() {
@Override
public void onResponse(Call<Relationship> call, Response<Relationship> response) {}
@ -179,7 +191,7 @@ public abstract class SFragment extends BaseFragment {
}
private void block(String id) {
Call<Relationship> call = mastodonAPI.blockAccount(id);
Call<Relationship> call = mastodonApi.blockAccount(id);
call.enqueue(new Callback<Relationship>() {
@Override
public void onResponse(Call<Relationship> call, retrofit2.Response<Relationship> response) {}
@ -195,7 +207,7 @@ public abstract class SFragment extends BaseFragment {
}
private void delete(String id) {
Call<ResponseBody> call = mastodonAPI.deleteStatus(id);
Call<ResponseBody> call = mastodonApi.deleteStatus(id);
call.enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, retrofit2.Response<ResponseBody> response) {}
@ -313,14 +325,8 @@ public abstract class SFragment extends BaseFragment {
startActivity(intent);
}
@Override
public void startActivity(Intent intent) {
super.startActivity(intent);
getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
}
protected void openReportPage(String accountId, String accountUsername, String statusId,
Spanned statusContent) {
Spanned statusContent) {
Intent intent = new Intent(getContext(), ReportActivity.class);
intent.putExtra("account_id", accountId);
intent.putExtra("account_username", accountUsername);

@ -35,10 +35,13 @@ import android.view.ViewGroup;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.FooterViewHolder;
import com.keylesspalace.tusky.adapter.TimelineAdapter;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
import com.keylesspalace.tusky.util.HttpHeaderLink;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
@ -64,6 +67,11 @@ public class TimelineFragment extends SFragment implements
FAVOURITES
}
private enum FetchEnd {
TOP,
BOTTOM,
}
private SwipeRefreshLayout swipeRefreshLayout;
private TimelineAdapter adapter;
private Kind kind;
@ -72,11 +80,14 @@ public class TimelineFragment extends SFragment implements
private LinearLayoutManager layoutManager;
private EndlessOnScrollListener scrollListener;
private TabLayout.OnTabSelectedListener onTabSelectedListener;
private SharedPreferences preferences;
private boolean filterRemoveReplies;
private boolean filterRemoveReblogs;
private boolean hideFab;
private TimelineReceiver timelineReceiver;
private boolean topLoading;
private int topFetches;
private boolean bottomLoading;
private int bottomFetches;
public static TimelineFragment newInstance(Kind kind) {
TimelineFragment fragment = new TimelineFragment();
@ -198,8 +209,6 @@ public class TimelineFragment extends SFragment implements
};
}
recyclerView.addOnScrollListener(scrollListener);
preferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
}
@Override
@ -212,20 +221,9 @@ public class TimelineFragment extends SFragment implements
super.onDestroyView();
}
@Override
public void onResume() {
super.onResume();
setFiltersFromSettings();
}
@Override
public void onRefresh() {
Status status = adapter.getItem(0);
if (status != null) {
sendFetchTimelineRequest(null, status.id);
} else {
sendFetchTimelineRequest(null, null);
}
sendFetchTimelineRequest(null, adapter.getTopId(), FetchEnd.TOP);
}
@Override
@ -248,6 +246,11 @@ public class TimelineFragment extends SFragment implements
super.more(adapter.getItem(position), view, adapter, position);
}
@Override
public void onOpenReblog(int position) {
super.openReblog(adapter.getItem(position));
}
@Override
public void onViewMedia(String[] urls, int urlIndex, Status.MediaAttachment.Type type) {
super.viewMedia(urls, urlIndex, type);
@ -290,22 +293,35 @@ public class TimelineFragment extends SFragment implements
fullyRefresh();
break;
}
case "tabFilterHomeReplies": {
boolean filter = sharedPreferences.getBoolean("tabFilterHomeReplies", true);
boolean oldRemoveReplies = filterRemoveReplies;
filterRemoveReplies = kind == Kind.HOME && !filter;
if (adapter.getItemCount() > 1 && oldRemoveReplies != filterRemoveReplies) {
fullyRefresh();
}
break;
}
case "tabFilterHomeBoosts": {
boolean filter = sharedPreferences.getBoolean("tabFilterHomeBoosts", true);
boolean oldRemoveReblogs = filterRemoveReblogs;
filterRemoveReblogs = kind == Kind.HOME && !filter;
if (adapter.getItemCount() > 1 && oldRemoveReblogs != filterRemoveReblogs) {
fullyRefresh();
}
break;
}
}
}
private void onLoadMore(RecyclerView view) {
TimelineAdapter adapter = (TimelineAdapter) view.getAdapter();
Status status = adapter.getItem(adapter.getItemCount() - 2);
if (status != null) {
sendFetchTimelineRequest(status.id, null);
} else {
sendFetchTimelineRequest(null, null);
}
sendFetchTimelineRequest(adapter.getBottomId(), null, FetchEnd.BOTTOM);
}
private void fullyRefresh() {
adapter.clear();
sendFetchTimelineRequest(null, null);
sendFetchTimelineRequest(null, null, FetchEnd.TOP);
}
private boolean jumpToTopAllowed() {
@ -321,108 +337,147 @@ public class TimelineFragment extends SFragment implements
scrollListener.reset();
}
private void sendFetchTimelineRequest(@Nullable final String fromId, @Nullable String uptoId) {
private Call<List<Status>> getFetchCallByTimelineType(Kind kind, String tagOrId, String fromId,
String uptoId) {
MastodonApi api = mastodonApi;
switch (kind) {
default:
case HOME: return api.homeTimeline(fromId, uptoId, null);
case PUBLIC_FEDERATED: return api.publicTimeline(null, fromId, uptoId, null);
case PUBLIC_LOCAL: return api.publicTimeline(true, fromId, uptoId, null);
case TAG: return api.hashtagTimeline(tagOrId, null, fromId, uptoId, null);
case USER: return api.accountStatuses(tagOrId, fromId, uptoId, null);
case FAVOURITES: return api.favourites(fromId, uptoId, null);
}
}
private void sendFetchTimelineRequest(@Nullable String fromId, @Nullable String uptoId,
final FetchEnd fetchEnd) {
/* If there is a fetch already ongoing, record however many fetches are requested and
* fulfill them after it's complete. */
if (fetchEnd == FetchEnd.TOP && topLoading) {
topFetches++;
return;
}
if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) {
bottomFetches++;
return;
}
if (fromId != null || adapter.getItemCount() <= 1) {
adapter.setFooterState(TimelineAdapter.FooterState.LOADING);
/* When this is called by the EndlessScrollListener it cannot refresh the footer state
* using adapter.notifyItemChanged. So its necessary to postpone doing so until a
* convenient time for the UI thread using a Runnable. */
recyclerView.post(new Runnable() {
@Override
public void run() {
adapter.setFooterState(FooterViewHolder.State.LOADING);
}
});
}
Callback<List<Status>> callback = new Callback<List<Status>>() {
@Override
public void onResponse(Call<List<Status>> call, Response<List<Status>> response) {
if (response.isSuccessful()) {
onFetchTimelineSuccess(response.body(), fromId);
String linkHeader = response.headers().get("Link");
onFetchTimelineSuccess(response.body(), linkHeader, fetchEnd);
} else {
onFetchTimelineFailure(new Exception(response.message()));
onFetchTimelineFailure(new Exception(response.message()), fetchEnd);
}
}
@Override
public void onFailure(Call<List<Status>> call, Throwable t) {
onFetchTimelineFailure((Exception) t);
onFetchTimelineFailure((Exception) t, fetchEnd);
}
};
Call<List<Status>> listCall;
switch (kind) {
default:
case HOME: {
listCall = mastodonAPI.homeTimeline(fromId, uptoId, null);
break;
}
case PUBLIC_FEDERATED: {
listCall = mastodonAPI.publicTimeline(null, fromId, uptoId, null);
break;
}
case PUBLIC_LOCAL: {
listCall = mastodonAPI.publicTimeline(true, fromId, uptoId, null);
break;
}
case TAG: {
listCall = mastodonAPI.hashtagTimeline(hashtagOrId, null, fromId, uptoId, null);
break;
}
case USER: {
listCall = mastodonAPI.accountStatuses(hashtagOrId, fromId, uptoId, null);
Call<List<Status>> listCall = getFetchCallByTimelineType(kind, hashtagOrId, fromId, uptoId);
callList.add(listCall);
listCall.enqueue(callback);
}
public void onFetchTimelineSuccess(List<Status> statuses, String linkHeader,
FetchEnd fetchEnd) {
filterStatuses(statuses);
List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader);
switch (fetchEnd) {
case TOP: {
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(statuses, null, uptoId);
break;
}
case FAVOURITES: {
listCall = mastodonAPI.favourites(fromId, uptoId, null);
case BOTTOM: {
HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next");
String fromId = null;
if (next != null) {
fromId = next.uri.getQueryParameter("max_id");
}
if (adapter.getItemCount() > 1) {
adapter.addItems(statuses, fromId);
} else {
/* If this is the first fetch, also save the id from the "previous" link and
* treat this operation as a refresh so the scroll position doesn't get pushed
* down to the end. */
HttpHeaderLink previous = HttpHeaderLink.findByRelationType(links, "prev");
String uptoId = null;
if (previous != null) {
uptoId = previous.uri.getQueryParameter("since_id");
}
adapter.update(statuses, fromId, uptoId);
}
break;
}
}
callList.add(listCall);
listCall.enqueue(callback);
fulfillAnyQueuedFetches(fetchEnd);
if (statuses.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(FooterViewHolder.State.EMPTY);
} else {
adapter.setFooterState(FooterViewHolder.State.END);
}
swipeRefreshLayout.setRefreshing(false);
}
public void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd) {
swipeRefreshLayout.setRefreshing(false);
Log.e(TAG, "Fetch Failure: " + exception.getMessage());
fulfillAnyQueuedFetches(fetchEnd);
}
private static boolean findStatus(List<Status> statuses, String id) {
for (Status status : statuses) {
if (status.id.equals(id)) {
return true;
private void fulfillAnyQueuedFetches(FetchEnd fetchEnd) {
switch (fetchEnd) {
case BOTTOM: {
bottomLoading = false;
if (bottomFetches > 0) {
bottomFetches--;
onLoadMore(recyclerView);
}
break;
}
case TOP: {
topLoading = false;
if (topFetches > 0) {
topFetches--;
onRefresh();
}
break;
}
}
return false;
}
protected void filterStatuses(List<Status> statuses) {
Iterator<Status> it = statuses.iterator();
while (it.hasNext()) {
Status status = it.next();
if ((status.inReplyToId != null && filterRemoveReplies) || (status.reblog != null && filterRemoveReblogs)) {
if ((status.inReplyToId != null && filterRemoveReplies)
|| (status.reblog != null && filterRemoveReblogs)) {
it.remove();
}
}
}
protected void setFiltersFromSettings() {
boolean oldRemoveReplies = filterRemoveReplies;
boolean oldRemoveReblogs = filterRemoveReblogs;
filterRemoveReplies = (kind == Kind.HOME && !preferences.getBoolean("tabFilterHomeReplies", true));
filterRemoveReblogs = (kind == Kind.HOME && !preferences.getBoolean("tabFilterHomeBoosts", true));
if (adapter.getItemCount() > 1 && (oldRemoveReblogs != filterRemoveReblogs || oldRemoveReplies != filterRemoveReplies)) {
fullyRefresh();
}
}
public void onFetchTimelineSuccess(List<Status> statuses, String fromId) {
filterStatuses(statuses);
if (fromId != null) {
if (statuses.size() > 0 && !findStatus(statuses, fromId)) {
adapter.addItems(statuses);
}
} else {
adapter.update(statuses);
}
if (statuses.size() == 0 && adapter.getItemCount() == 1) {
adapter.setFooterState(TimelineAdapter.FooterState.EMPTY);
} else if(fromId != null) {
adapter.setFooterState(TimelineAdapter.FooterState.END);
}
swipeRefreshLayout.setRefreshing(false);
}
public void onFetchTimelineFailure(Exception exception) {
swipeRefreshLayout.setRefreshing(false);
Log.e(TAG, "Fetch Failure: " + exception.getMessage());
}
}

@ -35,10 +35,8 @@ import android.view.ViewGroup;
import com.keylesspalace.tusky.adapter.ThreadAdapter;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.receiver.TimelineReceiver;
@ -56,7 +54,6 @@ public class ViewThreadFragment extends SFragment implements
private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
private ThreadAdapter adapter;
private MastodonApi mastodonApi;
private String thisThreadsStatusId;
private TimelineReceiver timelineReceiver;
@ -97,7 +94,6 @@ public class ViewThreadFragment extends SFragment implements
adapter.setMediaPreviewEnabled(mediaPreviewEnabled);
recyclerView.setAdapter(adapter);
mastodonApi = null;
thisThreadsStatusId = null;
timelineReceiver = new TimelineReceiver(adapter, this);
@ -117,15 +113,67 @@ public class ViewThreadFragment extends SFragment implements
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
/* BaseActivity's MastodonApi object isn't guaranteed to be valid until after its onCreate
* is run, so all calls that need it can't be done until here. */
mastodonApi = ((BaseActivity) getActivity()).mastodonApi;
thisThreadsStatusId = getArguments().getString("id");
onRefresh();
}
@Override
public void onRefresh() {
sendStatusRequest(thisThreadsStatusId);
sendThreadRequest(thisThreadsStatusId);
}
@Override
public void onReply(int position) {
super.reply(adapter.getItem(position));
}
@Override
public void onReblog(boolean reblog, int position) {
super.reblog(adapter.getItem(position), reblog, adapter, position);
}
@Override
public void onFavourite(boolean favourite, int position) {
super.favourite(adapter.getItem(position), favourite, adapter, position);
}
@Override
public void onMore(View view, int position) {
super.more(adapter.getItem(position), view, adapter, position);
}
@Override
public void onViewMedia(String[] urls, int urlIndex, Status.MediaAttachment.Type type) {
super.viewMedia(urls, urlIndex, type);
}
@Override
public void onViewThread(int position) {
Status status = adapter.getItem(position);
if (thisThreadsStatusId.equals(status.id)) {
// If already viewing this thread, don't reopen it.
return;
}
super.viewThread(status);
}
@Override
public void onOpenReblog(int position) {
// there should be no reblogs in the thread but let's implement it to be sure
super.openReblog(adapter.getItem(position));
}
@Override
public void onViewTag(String tag) {
super.viewTag(tag);
}
@Override
public void onViewAccount(String id) {
super.viewAccount(id);
}
private void sendStatusRequest(final String id) {
Call<Status> call = mastodonApi.status(id);
call.enqueue(new Callback<Status>() {
@ -155,7 +203,6 @@ public class ViewThreadFragment extends SFragment implements
if (response.isSuccessful()) {
swipeRefreshLayout.setRefreshing(false);
StatusContext context = response.body();
adapter.setContext(context.ancestors, context.descendants);
} else {
onThreadRequestFailure(id);
@ -187,55 +234,4 @@ public class ViewThreadFragment extends SFragment implements
Log.e(TAG, "Couldn't display thread fetch error message");
}
}
@Override
public void onRefresh() {
sendStatusRequest(thisThreadsStatusId);
sendThreadRequest(thisThreadsStatusId);
}
@Override
public void onReply(int position) {
super.reply(adapter.getItem(position));
}
@Override
public void onReblog(boolean reblog, int position) {
super.reblog(adapter.getItem(position), reblog, adapter, position);
}
@Override
public void onFavourite(boolean favourite, int position) {
super.favourite(adapter.getItem(position), favourite, adapter, position);
}
@Override
public void onMore(View view, int position) {
super.more(adapter.getItem(position), view, adapter, position);
}
@Override
public void onViewMedia(String[] urls, int urlIndex, Status.MediaAttachment.Type type) {
super.viewMedia(urls, urlIndex, type);
}
@Override
public void onViewThread(int position) {
Status status = adapter.getItem(position);
if (thisThreadsStatusId.equals(status.id)) {
// If already viewing this thread, don't reopen it.
return;
}
super.viewThread(status);
}
@Override
public void onViewTag(String tag) {
super.viewTag(tag);
}
@Override
public void onViewAccount(String id) {
super.viewAccount(id);
}
}

@ -26,4 +26,5 @@ public interface StatusActionListener extends LinkListener {
void onMore(View view, final int position);
void onViewMedia(String[] urls, int index, Status.MediaAttachment.Type type);
void onViewThread(int position);
void onOpenReblog(int position);
}

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.json;
import android.text.Spanned;
import android.text.SpannedString;
import com.emojione.Emojione;
import com.google.gson.JsonDeserializationContext;
@ -28,7 +29,13 @@ import java.lang.reflect.Type;
public class SpannedTypeAdapter implements JsonDeserializer<Spanned> {
@Override
public Spanned deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return HtmlUtils.fromHtml(Emojione.shortnameToUnicode(json.getAsString(), false));
public Spanned deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
String string = json.getAsString();
if (string != null) {
return HtmlUtils.fromHtml(Emojione.shortnameToUnicode(string, false));
} else {
return new SpannedString("");
}
}
}

@ -190,7 +190,10 @@ public interface MastodonApi {
@FormUrlEncoded
@POST("api/v1/reports")
Call<ResponseBody> report(@Field("account_id") String accountId, @Field("status_ids[]") List<String> statusIds, @Field("comment") String comment);
Call<ResponseBody> report(
@Field("account_id") String accountId,
@Field("status_ids[]") List<String> statusIds,
@Field("comment") String comment);
@GET("api/v1/search")
Call<SearchResults> search(@Query("q") String q, @Query("resolve") Boolean resolve);

@ -0,0 +1,148 @@
/* Written in 2017 by Andrew Dawson
*
* To the extent possible under law, the author(s) have dedicated all copyright and related and
* neighboring rights to this software to the public domain worldwide. This software is distributed
* without any warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication along with this software.
* If not, see <http://creativecommons.org/publicdomain/zero/1.0/>. */
package com.keylesspalace.tusky.util;
import android.net.Uri;
import android.support.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
public class HttpHeaderLink {
private static class Parameter {
public String name;
public String value;
}
private List<Parameter> parameters;
public Uri uri;
private HttpHeaderLink(String uri) {
this.uri = Uri.parse(uri);
this.parameters = new ArrayList<>();
}
private static int findAny(String s, int fromIndex, char[] set) {
for (int i = fromIndex; i < s.length(); i++) {
char c = s.charAt(i);
for (char member : set) {
if (c == member) {
return i;
}
}
}
return -1;
}
private static int findEndOfQuotedString(String line, int start) {
for (int i = start; i < line.length(); i++) {
char c = line.charAt(i);
if (c == '\\') {
i += 1;
} else if (c == '"') {
return i;
}
}
return -1;
}
private static class ValueResult {
String value;
int end;
ValueResult() {
end = -1;
}
void setValue(String value) {
value = value.trim();
if (!value.isEmpty()) {
this.value = value;
}
}
}
private static ValueResult parseValue(String line, int start) {
ValueResult result = new ValueResult();
int foundIndex = findAny(line, start, new char[] {';', ',', '"'});
if (foundIndex == -1) {
result.setValue(line.substring(start));
return result;
}
char c = line.charAt(foundIndex);
if (c == ';' || c == ',') {
result.end = foundIndex;
result.setValue(line.substring(start, foundIndex));
return result;
} else {
int quoteEnd = findEndOfQuotedString(line, foundIndex + 1);
if (quoteEnd == -1) {
quoteEnd = line.length();
}
result.end = quoteEnd;
result.setValue(line.substring(foundIndex + 1, quoteEnd));
return result;
}
}
private static int parseParameters(String line, int start, HttpHeaderLink link) {
for (int i = start; i < line.length(); i++) {
int foundIndex = findAny(line, i, new char[] {'=', ','});
if (foundIndex == -1) {
return -1;
} else if (line.charAt(foundIndex) == ',') {
return foundIndex;
}
Parameter parameter = new Parameter();
parameter.name = line.substring(line.indexOf(';', i) + 1, foundIndex).trim();
link.parameters.add(parameter);
ValueResult result = parseValue(line, foundIndex);
parameter.value = result.value;
if (result.end == -1) {
return -1;
} else {
i = result.end;
}
}
return -1;
}
public static List<HttpHeaderLink> parse(@Nullable String line) {
List<HttpHeaderLink> linkList = new ArrayList<>();
if (line != null) {
for (int i = 0; i < line.length(); i++) {
int uriEnd = line.indexOf('>', i);
String uri = line.substring(line.indexOf('<', i) + 1, uriEnd);
HttpHeaderLink link = new HttpHeaderLink(uri);
linkList.add(link);
int parseEnd = parseParameters(line, uriEnd, link);
if (parseEnd == -1) {
break;
} else {
i = parseEnd;
}
}
}
return linkList;
}
@Nullable
public static HttpHeaderLink findByRelationType(List<HttpHeaderLink> links,
String relationType) {
for (HttpHeaderLink link : links) {
for (Parameter parameter : link.parameters) {
if (parameter.name.equals("rel") && parameter.value.equals(relationType)) {
return link;
}
}
}
return null;
}
}

@ -47,8 +47,8 @@ public class LinkHelper {
}
public static void setClickableText(TextView view, Spanned content,
@Nullable Status.Mention[] mentions, boolean useCustomTabs,
final LinkListener listener) {
@Nullable Status.Mention[] mentions, boolean useCustomTabs,
final LinkListener listener) {
SpannableStringBuilder builder = new SpannableStringBuilder(content);
URLSpan[] urlSpans = content.getSpans(0, content.length(), URLSpan.class);
for (URLSpan span : urlSpans) {

@ -41,7 +41,7 @@ import com.squareup.picasso.Target;
import org.json.JSONArray;
import org.json.JSONException;
public class NotificationMaker {
class NotificationMaker {
public static final String TAG = "NotificationMaker";
@ -89,10 +89,12 @@ public class NotificationMaker {
TaskStackBuilder stackBuilder = TaskStackBuilder.create(context);
stackBuilder.addParentStack(MainActivity.class);
stackBuilder.addNextIntent(resultIntent);
PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT);
PendingIntent resultPendingIntent = stackBuilder.getPendingIntent(0,
PendingIntent.FLAG_UPDATE_CURRENT);
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, 0, deleteIntent, PendingIntent.FLAG_CANCEL_CURRENT);
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, 0, deleteIntent,
PendingIntent.FLAG_CANCEL_CURRENT);
final NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_notify)
@ -104,15 +106,16 @@ public class NotificationMaker {
builder.setContentTitle(titleForType(context, body))
.setContentText(truncateWithEllipses(bodyForType(body), 40));
Target mTarget = new Target() {
Target target = new Target() {
@Override
public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) {
builder.setLargeIcon(bitmap);
setupPreferences(preferences, builder);
((NotificationManager) (context.getSystemService(Context.NOTIFICATION_SERVICE)))
.notify(notifyId, builder.build());
NotificationManager notificationManager = (NotificationManager)
context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.notify(notifyId, builder.build());
}
@Override
@ -126,12 +129,15 @@ public class NotificationMaker {
.load(body.account.avatar)
.placeholder(R.drawable.avatar_default)
.transform(new RoundedTransformation(7, 0))
.into(mTarget);
.into(target);
} else {
setupPreferences(preferences, builder);
try {
builder.setContentTitle(String.format(context.getString(R.string.notification_title_summary), currentNotifications.length()))
.setContentText(truncateWithEllipses(joinNames(context, currentNotifications), 40));
String format = context.getString(R.string.notification_title_summary);
String title = String.format(format, currentNotifications.length());
String text = truncateWithEllipses(joinNames(context, currentNotifications), 40);
builder.setContentTitle(title)
.setContentText(text);
} catch (JSONException e) {
Log.d(TAG, Log.getStackTraceString(e));
}
@ -142,26 +148,23 @@ public class NotificationMaker {
builder.setCategory(android.app.Notification.CATEGORY_SOCIAL);
}
((NotificationManager) (context.getSystemService(Context.NOTIFICATION_SERVICE)))
.notify(notifyId, builder.build());
NotificationManager notificationManager =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.notify(notifyId, builder.build());
}
private static boolean filterNotification(SharedPreferences preferences,
Notification notification) {
Notification notification) {
switch (notification.type) {
default:
case MENTION: {
case MENTION:
return preferences.getBoolean("notificationFilterMentions", true);
}
case FOLLOW: {
case FOLLOW:
return preferences.getBoolean("notificationFilterFollows", true);
}
case REBLOG: {
case REBLOG:
return preferences.getBoolean("notificationFilterReblogs", true);
}
case FAVOURITE: {
case FAVOURITE:
return preferences.getBoolean("notificationFilterFavourites", true);
}
}
}
@ -174,7 +177,7 @@ public class NotificationMaker {
}
private static void setupPreferences(SharedPreferences preferences,
NotificationCompat.Builder builder) {
NotificationCompat.Builder builder) {
if (preferences.getBoolean("notificationAlertSound", true)) {
builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI);
}
@ -191,11 +194,14 @@ public class NotificationMaker {
@Nullable
private static String joinNames(Context context, JSONArray array) throws JSONException {
if (array.length() > 3) {
return String.format(context.getString(R.string.notification_summary_large), array.get(0), array.get(1), array.get(2), array.length() - 3);
return String.format(context.getString(R.string.notification_summary_large),
array.get(0), array.get(1), array.get(2), array.length() - 3);
} else if (array.length() == 3) {
return String.format(context.getString(R.string.notification_summary_medium), array.get(0), array.get(1), array.get(2));
return String.format(context.getString(R.string.notification_summary_medium),
array.get(0), array.get(1), array.get(2));
} else if (array.length() == 2) {
return String.format(context.getString(R.string.notification_summary_small), array.get(0), array.get(1));
return String.format(context.getString(R.string.notification_summary_small),
array.get(0), array.get(1));
}
return null;
@ -205,13 +211,17 @@ public class NotificationMaker {
private static String titleForType(Context context, Notification notification) {
switch (notification.type) {
case MENTION:
return String.format(context.getString(R.string.notification_mention_format), notification.account.getDisplayName());
return String.format(context.getString(R.string.notification_mention_format),
notification.account.getDisplayName());
case FOLLOW:
return String.format(context.getString(R.string.notification_follow_format), notification.account.getDisplayName());
return String.format(context.getString(R.string.notification_follow_format),
notification.account.getDisplayName());
case FAVOURITE:
return String.format(context.getString(R.string.notification_favourite_format), notification.account.getDisplayName());
return String.format(context.getString(R.string.notification_favourite_format),
notification.account.getDisplayName());
case REBLOG:
return String.format(context.getString(R.string.notification_reblog_format), notification.account.getDisplayName());
return String.format(context.getString(R.string.notification_reblog_format),
notification.account.getDisplayName());
}
return null;
}
@ -226,7 +236,6 @@ public class NotificationMaker {
case REBLOG:
return notification.status.content.toString();
}
return null;
}
}

@ -19,7 +19,17 @@ import android.text.Spannable;
import android.text.Spanned;
import android.text.style.ForegroundColorSpan;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class SpanUtils {
private static final String TAG_REGEX = "(?:^|[^/)\\w])#([\\w_]*[\\p{Alpha}_][\\w_]*)";
private static Pattern TAG_PATTERN = Pattern.compile(TAG_REGEX, Pattern.CASE_INSENSITIVE);
private static final String MENTION_REGEX =
"(?:^|[^/[:word:]])@([a-z0-9_]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)";
private static Pattern MENTION_PATTERN =
Pattern.compile(MENTION_REGEX, Pattern.CASE_INSENSITIVE);
private static class FindCharsResult {
int charIndex;
int stringIndex;
@ -63,35 +73,29 @@ public class SpanUtils {
}
private static int findEndOfHashtag(String string, int fromIndex) {
final int length = string.length();
for (int i = fromIndex + 1; i < length;) {
int codepoint = string.codePointAt(i);
if (Character.isWhitespace(codepoint)) {
return i;
} else if (codepoint == '#') {
return -1;
}
i += Character.charCount(codepoint);
Matcher matcher = TAG_PATTERN.matcher(string);
if (fromIndex >= 1) {
fromIndex--;
}
boolean found = matcher.find(fromIndex);
if (found) {
return matcher.end();
} else {
return -1;
}
return length;
}
private static int findEndOfMention(String string, int fromIndex) {
int atCount = 0;
final int length = string.length();
for (int i = fromIndex + 1; i < length;) {
int codepoint = string.codePointAt(i);
if (Character.isWhitespace(codepoint)) {
return i;
} else if (codepoint == '@') {
atCount += 1;
if (atCount >= 2) {
return -1;
}
}
i += Character.charCount(codepoint);
Matcher matcher = MENTION_PATTERN.matcher(string);
if (fromIndex >= 1) {
fromIndex--;
}
boolean found = matcher.find(fromIndex);
if (found) {
return matcher.end();
} else {
return -1;
}
return length;
}
public static void highlightSpans(Spannable text, int colour) {

@ -1,20 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
android:id="@+id/footer_container">
<LinearLayout
<ProgressBar
android:id="@+id/footer_progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_gravity="center">
<ProgressBar
android:id="@+id/footer_progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
android:layout_centerInParent="true"
android:indeterminate="true" />
</LinearLayout>
<TextView
android:id="@+id/footer_end_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/footer_empty"
android:textAlignment="center"
android:layout_centerInParent="true"
android:drawablePadding="16dp" />
</RelativeLayout>

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:srcCompat="@drawable/elephant_friend" />
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/footer_empty"
android:textAlignment="center" />
</LinearLayout>

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
</LinearLayout>

@ -40,10 +40,7 @@
<string name="status_content_warning_show_more">Pokaż więcej</string>
<string name="status_content_warning_show_less">Ukryj</string>
<string name="footer_end_of_statuses">koniec statusów</string>
<string name="footer_end_of_notifications">koniec powiadomień</string>
<string name="footer_end_of_accounts">koniec listy kont</string>
<string name="footer_empty">Brak wpisów! Pociągnij, aby odświeżyć.</string>
<string name="footer_empty">Pusto! Pociągnij, aby odświeżyć.</string>
<string name="notification_reblog_format">%s podbił twój post</string>
<string name="notification_favourite_format">%s dodał twój post do ulubionych</string>
@ -91,7 +88,7 @@
<string name="action_mute">Wycisz</string>
<string name="action_unmute">Cofnij wyciszenie</string>
<string name="action_mention">Wspomnij</string>
<!--<string name="action_mark_sensitive">Mark media sensitive</string>-->
<string name="action_hide_media">Ukryj zawartość multimedialną</string>
<string name="action_compose_options">Opcje</string>
<string name="action_open_drawer">Otwórz szufladę</string>
<string name="action_clear">Wyczyść</string>
@ -114,10 +111,10 @@
<string name="hint_domain">Jaka instancja?</string>
<string name="hint_compose">Co ci chodzi po głowie?</string>
<string name="hint_content_warning">Ostrzeenie o zawartości</string>
<string name="hint_content_warning">Ostrzeżenie o zawartości</string>
<string name="hint_display_name">Nazwa wyświetlana</string>
<string name="hint_note">Biografia</string>
<string name="hint_search">Szukaj kont i tagów</string>
<string name="hint_search">Szukaj…</string>
<string name="search_no_results">Brak wyników</string>
@ -168,6 +165,7 @@
<string name="pref_title_status_tabs">Karty</string>
<string name="pref_title_show_boosts">Pokazuj podbicia</string>
<string name="pref_title_show_replies">Pokazuj odpowiedzi</string>
<string name="pref_title_show_media_preview">Pokazuj podgląd zawartości multimedialnej</string>
<string name="notification_mention_format">%s wspomniał o tobie</string>
<string name="notification_summary_large">%1$s, %2$s, %3$s i %4$d innych</string>
@ -191,6 +189,8 @@
<string name="status_share_content">Udostępnij zawartość postu</string>
<string name="status_share_link">Udostępnij link do postu</string>
<string name="status_media_images">Obrazy</string>
<string name="status_media_video">Wideo</string>
<string name="state_follow_requested">Wysłano prośbę o obserwację</string>
</resources>

@ -41,10 +41,7 @@
<string name="status_content_warning_show_more">Show More</string>
<string name="status_content_warning_show_less">Show Less</string>
<string name="footer_end_of_statuses">end of the statuses</string>
<string name="footer_end_of_notifications">end of the notifications</string>
<string name="footer_end_of_accounts">end of the accounts</string>
<string name="footer_empty">There are no toots here so far. Pull down to refresh!</string>
<string name="footer_empty">Nothing here. Pull down to refresh!</string>
<string name="notification_reblog_format">%s boosted your toot</string>
<string name="notification_favourite_format">%s favourited your toot</string>
@ -117,7 +114,7 @@
<string name="hint_content_warning">Content warning</string>
<string name="hint_display_name">Display name</string>
<string name="hint_note">Bio</string>
<string name="hint_search">Search accounts and tags</string>
<string name="hint_search">Search…</string>
<string name="search_no_results">No results</string>

Loading…
Cancel
Save