diff --git a/app/build.gradle b/app/build.gradle index 6a0789183..46db1be3b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java index 70884ae3b..bc3a0233c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java @@ -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) diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 81c8d69b3..77f72416b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -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 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); } }); diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.java b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.java index 1961606c6..455dcc647 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.java @@ -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); diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index 3a1b0b44c..ed2fc9eae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -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(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java index 48c6634bf..b47b67776 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.java @@ -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 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 newAccounts) { + public void update(@Nullable List 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 newAccounts) { + public void addItems(List 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 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; + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java index 6c197e27a..f6a5b2282 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java @@ -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); } }); } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java index 91a718ec8..f4e70fa7c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.java @@ -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); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java index 7df84c6cc..c494dddef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.java @@ -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); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java index 269ac3376..c4578e520 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.java @@ -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); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FooterViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/FooterViewHolder.java index 5ff1187e7..04ecf972d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FooterViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FooterViewHolder.java @@ -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; + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java index b1499c9e0..e7719775f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.java @@ -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); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 12570935f..fb0ed466a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -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 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 newNotifications) { + public void update(@Nullable List 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 new_notifications) { + public void addItems(List 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 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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index ec16f333f..9646e4f25 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -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()); + } + }); } } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java index 2b9dddfad..9f99f1631 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.java @@ -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); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java index 7424c5815..fa92e32f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TimelineAdapter.java @@ -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 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 newStatuses) { + public void update(@Nullable List 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 newStatuses) { + public void addItems(List 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 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; + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java index 7c371067c..35713ae4a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java @@ -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> cb = new Callback>() { - @Override - public void onResponse(Call> call, Response> response) { - if (response.isSuccessful()) { - onFetchAccountsSuccess(response.body(), fromId); - } else { - onFetchAccountsFailure(new Exception(response.message())); - } - } - - @Override - public void onFailure(Call> call, Throwable t) { - onFetchAccountsFailure((Exception) t); - } - }; - - Call> 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 accounts, String id) { - for (Account account : accounts) { - if (account.id.equals(id)) { - return true; - } - } - return false; - } - - private void onFetchAccountsSuccess(List 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> 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> cb = new Callback>() { + @Override + public void onResponse(Call> call, Response> 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> call, Throwable t) { + onFetchAccountsFailure((Exception) t, fetchEnd); + } + }; + Call> listCall = getFetchCallByListType(type, fromId, uptoId); + callList.add(listCall); + listCall.enqueue(cb); + } + + private void onFetchAccountsSuccess(List accounts, String linkHeader, + FetchEnd fetchEnd) { + List 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; + } + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 490c242a7..cf5204edb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -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> 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>() { - @Override - public void onResponse(Call> call, - Response> response) { - if (response.isSuccessful()) { - onFetchNotificationsSuccess(response.body(), fromId); - } else { - onFetchNotificationsFailure(new Exception(response.message())); - } - } - - @Override - public void onFailure(Call> call, Throwable t) { - onFetchNotificationsFailure((Exception) t); - } - }); - callList.add(listCall); - } - - private void sendFetchNotificationsRequest() { - sendFetchNotificationsRequest(null, null); - } - - private static boolean findNotification(List notifications, String id) { - for (Notification notification : notifications) { - if (notification.id.equals(id)) { - return true; - } - } - return false; - } - - private void onFetchNotificationsSuccess(List 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> call = mastodonApi.notifications(fromId, uptoId, null); + + call.enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> 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> call, Throwable t) { + onFetchNotificationsFailure((Exception) t, fetchEnd); + } + }); + callList.add(call); + } + + private void onFetchNotificationsSuccess(List notifications, String linkHeader, + FetchEnd fetchEnd) { + List 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); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index b827a6a89..4d7113dfa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -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 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 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 call = mastodonAPI.muteAccount(id); + Call call = mastodonApi.muteAccount(id); call.enqueue(new Callback() { @Override public void onResponse(Call call, Response response) {} @@ -179,7 +191,7 @@ public abstract class SFragment extends BaseFragment { } private void block(String id) { - Call call = mastodonAPI.blockAccount(id); + Call call = mastodonApi.blockAccount(id); call.enqueue(new Callback() { @Override public void onResponse(Call call, retrofit2.Response response) {} @@ -195,7 +207,7 @@ public abstract class SFragment extends BaseFragment { } private void delete(String id) { - Call call = mastodonAPI.deleteStatus(id); + Call call = mastodonApi.deleteStatus(id); call.enqueue(new Callback() { @Override public void onResponse(Call call, retrofit2.Response 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); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index a3cfeec69..b9e7487c8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -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> 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> callback = new Callback>() { @Override public void onResponse(Call> call, Response> 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> call, Throwable t) { - onFetchTimelineFailure((Exception) t); + onFetchTimelineFailure((Exception) t, fetchEnd); } }; - Call> 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); - break; - } - case FAVOURITES: { - listCall = mastodonAPI.favourites(fromId, uptoId, null); - break; - } - } + Call> listCall = getFetchCallByTimelineType(kind, hashtagOrId, fromId, uptoId); callList.add(listCall); listCall.enqueue(callback); } - private static boolean findStatus(List statuses, String id) { - for (Status status : statuses) { - if (status.id.equals(id)) { - return true; + public void onFetchTimelineSuccess(List statuses, String linkHeader, + FetchEnd fetchEnd) { + filterStatuses(statuses); + List 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 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; + } + } + 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 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 statuses) { Iterator 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 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()); - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index dc0dd0e38..3529533ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -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,77 +113,10 @@ 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(); } - private void sendStatusRequest(final String id) { - Call call = mastodonApi.status(id); - call.enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - int position = adapter.setStatus(response.body()); - recyclerView.scrollToPosition(position); - } else { - onThreadRequestFailure(id); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - onThreadRequestFailure(id); - } - }); - callList.add(call); - } - - private void sendThreadRequest(final String id) { - Call call = mastodonApi.statusContext(id); - call.enqueue(new Callback() { - @Override - public void onResponse(Call call, Response response) { - if (response.isSuccessful()) { - swipeRefreshLayout.setRefreshing(false); - StatusContext context = response.body(); - - adapter.setContext(context.ancestors, context.descendants); - } else { - onThreadRequestFailure(id); - } - } - - @Override - public void onFailure(Call call, Throwable t) { - onThreadRequestFailure(id); - } - }); - callList.add(call); - } - - private void onThreadRequestFailure(final String id) { - View view = getView(); - swipeRefreshLayout.setRefreshing(false); - if (view != null) { - Snackbar.make(view, R.string.error_generic, Snackbar.LENGTH_LONG) - .setAction(R.string.action_retry, new View.OnClickListener() { - @Override - public void onClick(View v) { - sendThreadRequest(id); - sendStatusRequest(id); - } - }) - .show(); - } else { - Log.e(TAG, "Couldn't display thread fetch error message"); - } - } - @Override public void onRefresh() { sendStatusRequest(thisThreadsStatusId); @@ -229,6 +158,12 @@ public class ViewThreadFragment extends SFragment implements 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); @@ -238,4 +173,65 @@ public class ViewThreadFragment extends SFragment implements public void onViewAccount(String id) { super.viewAccount(id); } + + private void sendStatusRequest(final String id) { + Call call = mastodonApi.status(id); + call.enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + int position = adapter.setStatus(response.body()); + recyclerView.scrollToPosition(position); + } else { + onThreadRequestFailure(id); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + onThreadRequestFailure(id); + } + }); + callList.add(call); + } + + private void sendThreadRequest(final String id) { + Call call = mastodonApi.statusContext(id); + call.enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + if (response.isSuccessful()) { + swipeRefreshLayout.setRefreshing(false); + StatusContext context = response.body(); + adapter.setContext(context.ancestors, context.descendants); + } else { + onThreadRequestFailure(id); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + onThreadRequestFailure(id); + } + }); + callList.add(call); + } + + private void onThreadRequestFailure(final String id) { + View view = getView(); + swipeRefreshLayout.setRefreshing(false); + if (view != null) { + Snackbar.make(view, R.string.error_generic, Snackbar.LENGTH_LONG) + .setAction(R.string.action_retry, new View.OnClickListener() { + @Override + public void onClick(View v) { + sendThreadRequest(id); + sendStatusRequest(id); + } + }) + .show(); + } else { + Log.e(TAG, "Couldn't display thread fetch error message"); + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index fa220385c..9c00fc42c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -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); } diff --git a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.java b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.java index 305adba41..ef6173680 100644 --- a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.java @@ -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 { @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(""); + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index 2fd66184b..8a039fa87 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -190,7 +190,10 @@ public interface MastodonApi { @FormUrlEncoded @POST("api/v1/reports") - Call report(@Field("account_id") String accountId, @Field("status_ids[]") List statusIds, @Field("comment") String comment); + Call report( + @Field("account_id") String accountId, + @Field("status_ids[]") List statusIds, + @Field("comment") String comment); @GET("api/v1/search") Call search(@Query("q") String q, @Query("resolve") Boolean resolve); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.java b/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.java new file mode 100644 index 000000000..0fccdcbfd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.java @@ -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 . */ + +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 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 parse(@Nullable String line) { + List 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 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; + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java index 55ff6b7cf..b1f31f6a2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NotificationMaker.java b/app/src/main/java/com/keylesspalace/tusky/util/NotificationMaker.java index 156a9542f..e347a5eae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NotificationMaker.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/NotificationMaker.java @@ -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; } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.java index 81a82d2b8..f22701179 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.java @@ -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) { diff --git a/app/src/main/res/layout/item_footer.xml b/app/src/main/res/layout/item_footer.xml index 69204b57d..a1aa501e6 100644 --- a/app/src/main/res/layout/item_footer.xml +++ b/app/src/main/res/layout/item_footer.xml @@ -1,20 +1,23 @@ - + android:id="@+id/footer_container"> - - - + android:layout_centerInParent="true" + android:indeterminate="true" /> - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_footer_empty.xml b/app/src/main/res/layout/item_footer_empty.xml deleted file mode 100644 index 1c5606d22..000000000 --- a/app/src/main/res/layout/item_footer_empty.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_footer_end.xml b/app/src/main/res/layout/item_footer_end.xml deleted file mode 100644 index 584de324b..000000000 --- a/app/src/main/res/layout/item_footer_end.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index f4069a21e..1f723f14c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -40,10 +40,7 @@ Pokaż więcej Ukryj - koniec statusów - koniec powiadomień - koniec listy kont - Brak wpisów! Pociągnij, aby odświeżyć. + Pusto! Pociągnij, aby odświeżyć. %s podbił twój post %s dodał twój post do ulubionych @@ -91,7 +88,7 @@ Wycisz Cofnij wyciszenie Wspomnij - + Ukryj zawartość multimedialną Opcje Otwórz szufladę Wyczyść @@ -114,10 +111,10 @@ Jaka instancja? Co ci chodzi po głowie? - Ostrzeenie o zawartości + Ostrzeżenie o zawartości Nazwa wyświetlana Biografia - Szukaj kont i tagów… + Szukaj… Brak wyników @@ -168,6 +165,7 @@ Karty Pokazuj podbicia Pokazuj odpowiedzi + Pokazuj podgląd zawartości multimedialnej %s wspomniał o tobie %1$s, %2$s, %3$s i %4$d innych @@ -191,6 +189,8 @@ Udostępnij zawartość postu Udostępnij link do postu + Obrazy + Wideo Wysłano prośbę o obserwację diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da6c5ac8f..518ce9f7c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,10 +41,7 @@ Show More Show Less - end of the statuses - end of the notifications - end of the accounts - There are no toots here so far. Pull down to refresh! + Nothing here. Pull down to refresh! %s boosted your toot %s favourited your toot @@ -117,7 +114,7 @@ Content warning Display name Bio - Search accounts and tags… + Search… No results