From 9ce95d6de74ed85235b87af43ded5ebf06701b7b Mon Sep 17 00:00:00 2001 From: Torsten Grote <t@grobox.de> Date: Mon, 10 Oct 2016 10:04:28 -0300 Subject: [PATCH] Refactor Forum Activity and adapters to be re-used for private groups --- briar-android/res/layout/activity_forum.xml | 6 +- .../android/forum/ForumActivity.java | 233 +++------- .../android/forum/ForumEntry.java | 90 +--- .../android/forum/NestedForumAdapter.java | 436 +----------------- .../android/forum/NestedForumHolder.java | 13 + .../conversation/GroupMessageItem.java | 22 + .../{util => threaded}/NestedTreeList.java | 2 +- .../android/threaded/ThreadItem.java | 95 ++++ .../android/threaded/ThreadItemAdapter.java | 298 ++++++++++++ .../threaded/ThreadItemViewHolder.java | 182 ++++++++ .../android/threaded/ThreadListActivity.java | 205 ++++++++ .../android/forum/ForumActivityTest.java | 8 +- .../android/forum/TestForumActivity.java | 2 +- 13 files changed, 894 insertions(+), 698 deletions(-) create mode 100644 briar-android/src/org/briarproject/android/forum/NestedForumHolder.java create mode 100644 briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageItem.java rename briar-android/src/org/briarproject/android/{util => threaded}/NestedTreeList.java (96%) create mode 100644 briar-android/src/org/briarproject/android/threaded/ThreadItem.java create mode 100644 briar-android/src/org/briarproject/android/threaded/ThreadItemAdapter.java create mode 100644 briar-android/src/org/briarproject/android/threaded/ThreadItemViewHolder.java create mode 100644 briar-android/src/org/briarproject/android/threaded/ThreadListActivity.java diff --git a/briar-android/res/layout/activity_forum.xml b/briar-android/res/layout/activity_forum.xml index b796da899f..81c7e4a2bb 100644 --- a/briar-android/res/layout/activity_forum.xml +++ b/briar-android/res/layout/activity_forum.xml @@ -7,12 +7,12 @@ android:orientation="vertical"> <org.briarproject.android.view.BriarRecyclerView - android:id="@+id/forum_discussion_list" + android:id="@+id/list" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" - app:scrollToEnd="false" - app:emptyText="@string/no_forum_posts"/> + app:emptyText="@string/no_forum_posts" + app:scrollToEnd="false"/> <org.briarproject.android.view.TextInputView android:id="@+id/text_input_container" diff --git a/briar-android/src/org/briarproject/android/forum/ForumActivity.java b/briar-android/src/org/briarproject/android/forum/ForumActivity.java index 1cb6662fec..0d74863a87 100644 --- a/briar-android/src/org/briarproject/android/forum/ForumActivity.java +++ b/briar-android/src/org/briarproject/android/forum/ForumActivity.java @@ -3,36 +3,28 @@ package org.briarproject.android.forum; import android.content.DialogInterface; import android.content.Intent; import android.os.Bundle; +import android.support.annotation.LayoutRes; import android.support.annotation.Nullable; -import android.support.design.widget.Snackbar; import android.support.v4.app.ActivityCompat; import android.support.v4.app.ActivityOptionsCompat; -import android.support.v4.content.ContextCompat; import android.support.v7.app.AlertDialog; import android.support.v7.widget.LinearLayoutManager; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; -import android.view.View; import android.widget.Toast; import org.briarproject.R; import org.briarproject.android.ActivityComponent; -import org.briarproject.android.BriarActivity; -import org.briarproject.android.api.AndroidNotificationManager; import org.briarproject.android.controller.handler.UiResultExceptionHandler; import org.briarproject.android.controller.handler.UiResultHandler; import org.briarproject.android.forum.ForumController.ForumPostListener; -import org.briarproject.android.forum.NestedForumAdapter.OnNestedForumListener; import org.briarproject.android.sharing.ShareForumActivity; import org.briarproject.android.sharing.SharingStatusForumActivity; -import org.briarproject.android.view.BriarRecyclerView; -import org.briarproject.android.view.TextInputView; -import org.briarproject.android.view.TextInputView.TextInputListener; +import org.briarproject.android.threaded.ThreadListActivity; import org.briarproject.api.db.DbException; import org.briarproject.api.forum.Forum; import org.briarproject.api.forum.ForumPostHeader; -import org.briarproject.api.sync.GroupId; import org.briarproject.util.StringUtils; import java.util.ArrayList; @@ -42,56 +34,32 @@ import javax.inject.Inject; import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP; import static android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP; -import static android.view.View.GONE; -import static android.view.View.VISIBLE; +import static android.support.v4.app.ActivityOptionsCompat.makeCustomAnimation; import static android.widget.Toast.LENGTH_SHORT; -public class ForumActivity extends BriarActivity implements - ForumPostListener, TextInputListener, OnNestedForumListener { +public class ForumActivity extends ThreadListActivity<ForumEntry, NestedForumAdapter> + implements ForumPostListener { static final String FORUM_NAME = "briar.FORUM_NAME"; private static final int REQUEST_FORUM_SHARED = 3; - private static final String KEY_INPUT_VISIBILITY = "inputVisibility"; - private static final String KEY_REPLY_ID = "replyId"; - - @Inject - AndroidNotificationManager notificationManager; @Inject protected ForumController forumController; - // Protected access for testing - protected NestedForumAdapter forumAdapter; - - private BriarRecyclerView recyclerView; - private TextInputView textInput; - - private volatile GroupId groupId = null; + @Override + public void injectActivity(ActivityComponent component) { + component.inject(this); + } @Override public void onCreate(final Bundle state) { super.onCreate(state); - setContentView(R.layout.activity_forum); - Intent i = getIntent(); - byte[] b = i.getByteArrayExtra(GROUP_ID); - if (b == null) throw new IllegalStateException(); - groupId = new GroupId(b); String forumName = i.getStringExtra(FORUM_NAME); if (forumName != null) setTitle(forumName); - textInput = (TextInputView) findViewById(R.id.text_input_container); - textInput.setVisibility(GONE); - textInput.setListener(this); - recyclerView = - (BriarRecyclerView) findViewById(R.id.forum_discussion_list); - LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this); - recyclerView.setLayoutManager(linearLayoutManager); - forumAdapter = new NestedForumAdapter(this, this, linearLayoutManager); - recyclerView.setAdapter(forumAdapter); - forumController.loadForum(groupId, new UiResultExceptionHandler<List<ForumEntry>, DbException>( this) { @@ -101,14 +69,15 @@ public class ForumActivity extends BriarActivity implements if (forum != null) setTitle(forum.getName()); List<ForumEntry> entries = new ArrayList<>(result); if (entries.isEmpty()) { - recyclerView.showData(); + list.showData(); } else { - forumAdapter.setEntries(entries); + adapter.setItems(entries); + list.showData(); if (state != null) { byte[] replyId = state.getByteArray(KEY_REPLY_ID); if (replyId != null) - forumAdapter.setReplyEntryById(replyId); + adapter.setReplyItemById(replyId); } } } @@ -122,36 +91,20 @@ public class ForumActivity extends BriarActivity implements } @Override - protected void onRestoreInstanceState(Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - textInput.setVisibility( - savedInstanceState.getBoolean(KEY_INPUT_VISIBILITY) ? - VISIBLE : GONE); + protected @LayoutRes int getLayout() { + return R.layout.activity_forum; } - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putBoolean(KEY_INPUT_VISIBILITY, - textInput.getVisibility() == VISIBLE); - ForumEntry replyEntry = forumAdapter.getReplyEntry(); - if (replyEntry != null) { - outState.putByteArray(KEY_REPLY_ID, - replyEntry.getMessageId().getBytes()); - } + protected NestedForumAdapter createAdapter( + LinearLayoutManager layoutManager) { + return new NestedForumAdapter(this, layoutManager); } @Override - public void injectActivity(ActivityComponent component) { - component.inject(this); - } - - private void displaySnackbarShort(int stringId) { - Snackbar snackbar = - Snackbar.make(recyclerView, stringId, Snackbar.LENGTH_SHORT); - snackbar.getView().setBackgroundResource(R.color.briar_primary); - snackbar.show(); + public void onResume() { + super.onResume(); + notificationManager.clearForumPostNotification(groupId); } @Override @@ -172,34 +125,10 @@ public class ForumActivity extends BriarActivity implements return super.onCreateOptionsMenu(menu); } - @Override - public void onBackPressed() { - if (textInput.getVisibility() == VISIBLE) { - textInput.setVisibility(GONE); - forumAdapter.setReplyEntry(null); - } else { - super.onBackPressed(); - } - } - - private void showTextInput(@Nullable ForumEntry replyEntry) { - // An animation here would be an overkill because of the keyboard - // popping up. - // only clear the text when the input container was not visible - if (textInput.getVisibility() != VISIBLE) { - textInput.setVisibility(VISIBLE); - textInput.setText(""); - } - textInput.showSoftKeyboard(); - textInput.setHint(replyEntry == null ? R.string.forum_new_message_hint : - R.string.forum_message_reply_hint); - forumAdapter.setReplyEntry(replyEntry); - } - @Override public boolean onOptionsItemSelected(final MenuItem item) { - ActivityOptionsCompat options = ActivityOptionsCompat - .makeCustomAnimation(this, android.R.anim.slide_in_left, + ActivityOptionsCompat options = + makeCustomAnimation(this, android.R.anim.slide_in_left, android.R.anim.slide_out_right); // Handle presses on the action bar items switch (item.getItemId()) { @@ -228,31 +157,33 @@ public class ForumActivity extends BriarActivity implements } } - @Override - public void onResume() { - super.onResume(); - notificationManager.blockNotification(groupId); - notificationManager.clearForumPostNotification(groupId); - recyclerView.startPeriodicUpdate(); + protected void markItemRead(ForumEntry entry) { + forumController.entryRead(entry); } @Override - public void onPause() { - super.onPause(); - notificationManager.unblockNotification(groupId); - recyclerView.stopPeriodicUpdate(); + public void onForumPostReceived(ForumPostHeader header) { + forumController.loadPost(header, + new UiResultExceptionHandler<ForumEntry, DbException>(this) { + @Override + public void onResultUi(final ForumEntry result) { + addItem(result, false); + } + + @Override + public void onExceptionUi(DbException exception) { + // TODO add proper exception handling + } + }); } @Override - public void onSendClick(String text) { - if (text.trim().length() == 0) - return; - ForumEntry replyEntry = forumAdapter.getReplyEntry(); - UiResultExceptionHandler<ForumEntry, DbException> resultHandler = + protected void sendItem(String text, @Nullable ForumEntry replyItem) { + UiResultExceptionHandler<ForumEntry, DbException> handler = new UiResultExceptionHandler<ForumEntry, DbException>(this) { @Override public void onResultUi(ForumEntry result) { - onForumEntryAdded(result, true); + addItem(result, true); } @Override @@ -261,17 +192,28 @@ public class ForumActivity extends BriarActivity implements finish(); } }; - - if (replyEntry == null) { + if (replyItem == null) { // root post - forumController.createPost(StringUtils.toUtf8(text), resultHandler); + forumController.createPost(StringUtils.toUtf8(text), handler); } else { forumController.createPost(StringUtils.toUtf8(text), - replyEntry.getId(), resultHandler); + replyItem.getId(), handler); } - textInput.hideSoftKeyboard(); - textInput.setVisibility(GONE); - forumAdapter.setReplyEntry(null); + } + + @Override + public void onForumRemoved() { + supportFinishAfterTransition(); + } + + @Override + protected int getItemPostedString() { + return R.string.forum_new_entry_posted; + } + + @Override + protected int getItemReceivedString() { + return R.string.forum_new_entry_received; } private void showUnsubscribeDialog() { @@ -304,61 +246,4 @@ public class ForumActivity extends BriarActivity implements builder.show(); } - @Override - public void onEntryVisible(ForumEntry forumEntry) { - if (!forumEntry.isRead()) { - forumEntry.setRead(true); - forumController.entryRead(forumEntry); - } - } - - @Override - public void onReplyClick(ForumEntry forumEntry) { - showTextInput(forumEntry); - } - - private void onForumEntryAdded(final ForumEntry entry, boolean isLocal) { - forumAdapter.addEntry(entry); - if (isLocal && forumAdapter.isVisible(entry)) { - displaySnackbarShort(R.string.forum_new_entry_posted); - } else { - Snackbar snackbar = Snackbar.make(recyclerView, - isLocal ? R.string.forum_new_entry_posted : - R.string.forum_new_entry_received, - Snackbar.LENGTH_LONG); - snackbar.getView().setBackgroundResource(R.color.briar_primary); - snackbar.setActionTextColor(ContextCompat - .getColor(ForumActivity.this, - R.color.briar_button_positive)); - snackbar.setAction(R.string.show, new View.OnClickListener() { - @Override - public void onClick(View v) { - forumAdapter.scrollToEntry(entry); - } - }); - snackbar.getView().setBackgroundResource(R.color.briar_primary); - snackbar.show(); - } - } - - @Override - public void onForumPostReceived(ForumPostHeader header) { - forumController.loadPost(header, - new UiResultExceptionHandler<ForumEntry, DbException>(this) { - @Override - public void onResultUi(final ForumEntry result) { - onForumEntryAdded(result, false); - } - - @Override - public void onExceptionUi(DbException exception) { - // TODO add proper exception handling - } - }); - } - - @Override - public void onForumRemoved() { - finish(); - } } diff --git a/briar-android/src/org/briarproject/android/forum/ForumEntry.java b/briar-android/src/org/briarproject/android/forum/ForumEntry.java index 49e1ee32f5..02817e1ab9 100644 --- a/briar-android/src/org/briarproject/android/forum/ForumEntry.java +++ b/briar-android/src/org/briarproject/android/forum/ForumEntry.java @@ -1,102 +1,22 @@ package org.briarproject.android.forum; -import org.briarproject.api.clients.MessageTree; +import org.briarproject.android.threaded.ThreadItem; import org.briarproject.api.forum.ForumPostHeader; import org.briarproject.api.identity.Author; import org.briarproject.api.identity.Author.Status; import org.briarproject.api.sync.MessageId; /* This class is not thread safe */ -public class ForumEntry implements MessageTree.MessageNode { - - public final static int LEVEL_UNDEFINED = -1; - - private final MessageId messageId; - private final MessageId parentId; - private final String text; - private final long timestamp; - private final Author author; - private Status status; - private int level = LEVEL_UNDEFINED; - private boolean isShowingDescendants = true; - private int descendantCount = 0; - private boolean isRead = true; +public class ForumEntry extends ThreadItem { ForumEntry(ForumPostHeader h, String text) { - this(h.getId(), h.getParentId(), text, h.getTimestamp(), h.getAuthor(), - h.getAuthorStatus()); - this.isRead = h.isRead(); + super(h.getId(), h.getParentId(), text, h.getTimestamp(), h.getAuthor(), + h.getAuthorStatus(), h.isRead()); } public ForumEntry(MessageId messageId, MessageId parentId, String text, long timestamp, Author author, Status status) { - this.messageId = messageId; - this.parentId = parentId; - this.text = text; - this.timestamp = timestamp; - this.author = author; - this.status = status; - } - - public String getText() { - return text; - } - - public int getLevel() { - return level; - } - - @Override - public MessageId getId() { - return messageId; - } - - @Override - public MessageId getParentId() { - return parentId; - } - - public long getTimestamp() { - return timestamp; - } - - public Author getAuthor() { - return author; - } - - public Status getStatus() { - return status; + super(messageId, parentId, text, timestamp, author, status, true); } - boolean isShowingDescendants() { - return isShowingDescendants; - } - - public void setLevel(int level) { - this.level = level; - } - - void setShowingDescendants(boolean showingDescendants) { - this.isShowingDescendants = showingDescendants; - } - - MessageId getMessageId() { - return messageId; - } - - public boolean isRead() { - return isRead; - } - - void setRead(boolean read) { - isRead = read; - } - - public boolean hasDescendants() { - return descendantCount > 0; - } - - public void setDescendantCount(int descendantCount) { - this.descendantCount = descendantCount; - } } diff --git a/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java b/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java index f1e2f5de26..d1cb240083 100644 --- a/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java +++ b/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java @@ -1,289 +1,20 @@ package org.briarproject.android.forum; -import android.animation.Animator; -import android.animation.ArgbEvaluator; -import android.animation.ValueAnimator; -import android.content.Context; -import android.graphics.drawable.ColorDrawable; -import android.support.annotation.Nullable; -import android.support.v4.content.ContextCompat; +import android.support.annotation.UiThread; import android.support.v7.widget.LinearLayoutManager; -import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.TextView; import org.briarproject.R; -import org.briarproject.android.util.NestedTreeList; -import org.briarproject.android.view.AuthorView; -import org.briarproject.api.sync.MessageId; -import org.briarproject.util.StringUtils; +import org.briarproject.android.threaded.ThreadItemAdapter; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +@UiThread +public class NestedForumAdapter extends ThreadItemAdapter<ForumEntry> { -import static android.support.v7.widget.RecyclerView.NO_POSITION; -import static android.view.View.GONE; -import static android.view.View.INVISIBLE; -import static android.view.View.VISIBLE; - -public class NestedForumAdapter - extends RecyclerView.Adapter<NestedForumAdapter.NestedForumHolder> { - - private static final int UNDEFINED = -1; - - private final NestedTreeList<ForumEntry> forumEntries = - new NestedTreeList<>(); - private final Map<ForumEntry, ValueAnimator> animatingEntries = - new HashMap<>(); - // highlight not dependant on time - private ForumEntry replyEntry; - // temporary highlight - private ForumEntry addedEntry; - private final Context ctx; - private final OnNestedForumListener listener; - private final LinearLayoutManager layoutManager; - - public NestedForumAdapter(Context ctx, OnNestedForumListener listener, + public NestedForumAdapter(ThreadItemListener<ForumEntry> listener, LinearLayoutManager layoutManager) { - this.ctx = ctx; - this.listener = listener; - this.layoutManager = layoutManager; - } - - ForumEntry getReplyEntry() { - return replyEntry; - } - - void setEntries(List<ForumEntry> entries) { - forumEntries.clear(); - forumEntries.addAll(entries); - notifyItemRangeInserted(0, entries.size()); - } - - void addEntry(ForumEntry entry) { - forumEntries.add(entry); - addedEntry = entry; - if (entry.getParentId() == null) { - notifyItemInserted(getVisiblePos(entry)); - } else { - // Try to find the entry's parent and perform the proper ui update if - // it's present and visible. - for (int i = forumEntries.indexOf(entry) - 1; i >= 0; i--) { - ForumEntry higherEntry = forumEntries.get(i); - if (higherEntry.getLevel() < entry.getLevel()) { - // parent found - if (higherEntry.isShowingDescendants()) { - int parentVisiblePos = getVisiblePos(higherEntry); - if (parentVisiblePos != NO_POSITION) { - // parent is visible, we need to update its ui - notifyItemChanged(parentVisiblePos); - // new entry insert ui - int visiblePos = getVisiblePos(entry); - notifyItemInserted(visiblePos); - break; - } - } else { - // do not show the new entry if its parent is not showing - // descendants (this can be overridden by the user by - // pressing the snack bar) - break; - } - } - } - } - } - - void scrollToEntry(ForumEntry entry) { - int visiblePos = getVisiblePos(entry); - if (visiblePos == NO_POSITION && entry.getParentId() != null) { - // The entry is not visible due to being hidden by its parent entry. - // Find the parent and make it visible and traverse up the parent - // chain if necessary to make the entry visible - MessageId parentId = entry.getParentId(); - for (int i = forumEntries.indexOf(entry) - 1; i >= 0; i--) { - ForumEntry higherEntry = forumEntries.get(i); - if (higherEntry.getId().equals(parentId)) { - // parent found - showDescendants(higherEntry); - int parentPos = getVisiblePos(higherEntry); - if (parentPos != NO_POSITION) { - // parent or ancestor is visible, entry's visibility - // is ensured - notifyItemChanged(parentPos); - visiblePos = parentPos; - break; - } - // parent or ancestor is hidden, we need to continue up the - // dependency chain - parentId = higherEntry.getParentId(); - } - } - } - if (visiblePos != NO_POSITION) - layoutManager.scrollToPositionWithOffset(visiblePos, 0); - } - - private int getReplyCount(ForumEntry entry) { - int counter = 0; - int pos = forumEntries.indexOf(entry); - if (pos >= 0) { - int ancestorLvl = entry.getLevel(); - for (int i = pos + 1; i < forumEntries.size(); i++) { - int descendantLvl = forumEntries.get(i).getLevel(); - if (descendantLvl <= ancestorLvl) - break; - if (descendantLvl == ancestorLvl + 1) - counter++; - } - } - return counter; - } - - void setReplyEntryById(byte[] id) { - MessageId messageId = new MessageId(id); - for (ForumEntry entry : forumEntries) { - if (entry.getId().equals(messageId)) { - setReplyEntry(entry); - break; - } - } - } - - void setReplyEntry(@Nullable ForumEntry entry) { - if (replyEntry != null) { - notifyItemChanged(getVisiblePos(replyEntry)); - } - replyEntry = entry; - if (replyEntry != null) { - notifyItemChanged(getVisiblePos(replyEntry)); - } - } - - private List<Integer> getSubTreeIndexes(int pos, int levelLimit) { - List<Integer> indexList = new ArrayList<>(); - - for (int i = pos + 1; i < getItemCount(); i++) { - ForumEntry entry = getVisibleEntry(i); - if (entry != null && entry.getLevel() > levelLimit) { - indexList.add(i); - } else { - break; - } - } - return indexList; - } - - void showDescendants(ForumEntry forumEntry) { - forumEntry.setShowingDescendants(true); - int visiblePos = getVisiblePos(forumEntry); - List<Integer> indexList = - getSubTreeIndexes(visiblePos, forumEntry.getLevel()); - if (!indexList.isEmpty()) { - if (indexList.size() == 1) { - notifyItemInserted(indexList.get(0)); - } else { - notifyItemRangeInserted(indexList.get(0), - indexList.size()); - } - } - } - - void hideDescendants(ForumEntry forumEntry) { - int visiblePos = getVisiblePos(forumEntry); - List<Integer> indexList = - getSubTreeIndexes(visiblePos, forumEntry.getLevel()); - if (!indexList.isEmpty()) { - // stop animating children - for (int index : indexList) { - ValueAnimator anim = - animatingEntries.get(forumEntries.get(index)); - if (anim != null && anim.isRunning()) { - anim.cancel(); - } - } - if (indexList.size() == 1) { - notifyItemRemoved(indexList.get(0)); - } else { - notifyItemRangeRemoved(indexList.get(0), - indexList.size()); - } - } - forumEntry.setShowingDescendants(false); - } - - - /** - * - * @param position is visible entry index - * @return the visible entry at index position from an ordered list of visible - * entries, or null if position is larger then the number of visible entries. - */ - @Nullable - ForumEntry getVisibleEntry(int position) { - int levelLimit = UNDEFINED; - for (ForumEntry forumEntry : forumEntries) { - if (levelLimit >= 0) { - // skip hidden entries that their parent is hiding - if (forumEntry.getLevel() > levelLimit) { - continue; - } - levelLimit = UNDEFINED; - } - if (!forumEntry.isShowingDescendants()) { - levelLimit = forumEntry.getLevel(); - } - if (position-- == 0) { - return forumEntry; - } - } - return null; - } - - private void animateFadeOut(final NestedForumHolder ui, - final ForumEntry addedEntry) { - ui.setIsRecyclable(false); - ValueAnimator anim = new ValueAnimator(); - animatingEntries.put(addedEntry, anim); - ColorDrawable viewColor = (ColorDrawable) ui.cell.getBackground(); - anim.setIntValues(viewColor.getColor(), ContextCompat - .getColor(ctx, R.color.window_background)); - anim.setEvaluator(new ArgbEvaluator()); - anim.addListener(new Animator.AnimatorListener() { - @Override - public void onAnimationStart(Animator animation) { - - } - - @Override - public void onAnimationEnd(Animator animation) { - ui.setIsRecyclable(true); - animatingEntries.remove(addedEntry); - } - - @Override - public void onAnimationCancel(Animator animation) { - ui.setIsRecyclable(true); - animatingEntries.remove(addedEntry); - } - - @Override - public void onAnimationRepeat(Animator animation) { - - } - }); - anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator valueAnimator) { - ui.cell.setBackgroundColor( - (Integer) valueAnimator.getAnimatedValue()); - } - }); - anim.setDuration(5000); - anim.start(); + super(listener, layoutManager); } @Override @@ -294,159 +25,4 @@ public class NestedForumAdapter return new NestedForumHolder(v); } - @Override - public void onBindViewHolder( - final NestedForumHolder ui, final int position) { - final ForumEntry entry = getVisibleEntry(position); - if (entry == null) return; - listener.onEntryVisible(entry); - - ui.textView.setText(StringUtils.trim(entry.getText())); - - if (position == 0) { - ui.topDivider.setVisibility(View.INVISIBLE); - } else { - ui.topDivider.setVisibility(View.VISIBLE); - } - - for (int i = 0; i < ui.lvls.length; i++) { - ui.lvls[i].setVisibility(i < entry.getLevel() ? VISIBLE : GONE); - } - if (entry.getLevel() > 5) { - ui.lvlText.setVisibility(VISIBLE); - ui.lvlText.setText("" + entry.getLevel()); - } else { - ui.lvlText.setVisibility(GONE); - } - ui.author.setAuthor(entry.getAuthor()); - ui.author.setDate(entry.getTimestamp()); - ui.author.setAuthorStatus(entry.getStatus()); - - int replies = getReplyCount(entry); - if (replies == 0) { - ui.repliesText.setText(""); - } else { - ui.repliesText.setText( - ctx.getResources() - .getQuantityString(R.plurals.message_replies, - replies, replies)); - } - - if (entry.hasDescendants()) { - ui.chevron.setVisibility(VISIBLE); - if (entry.isShowingDescendants()) { - ui.chevron.setSelected(false); - } else { - ui.chevron.setSelected(true); - } - ui.chevron.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - ui.chevron.setSelected(!ui.chevron.isSelected()); - if (ui.chevron.isSelected()) { - hideDescendants(entry); - } else { - showDescendants(entry); - } - } - }); - } else { - ui.chevron.setVisibility(INVISIBLE); - } - if (entry.equals(replyEntry)) { - ui.cell.setBackgroundColor(ContextCompat - .getColor(ctx, R.color.forum_cell_highlight)); - } else if (entry.equals(addedEntry)) { - - ui.cell.setBackgroundColor(ContextCompat - .getColor(ctx, R.color.forum_cell_highlight)); - animateFadeOut(ui, addedEntry); - addedEntry = null; - } else { - ui.cell.setBackgroundColor(ContextCompat - .getColor(ctx, R.color.window_background)); - } - ui.replyButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - listener.onReplyClick(entry); - scrollToEntry(entry); - } - }); - } - - public boolean isVisible(ForumEntry entry) { - return getVisiblePos(entry) != NO_POSITION; - } - - /** - * @param sEntry the ForumEntry to find the visible positoin of, or null to - * return the total cound of visible elements - * @return the visible position of sEntry, or the total number of visible - * elements if sEntry is null. If sEntry is not visible a NO_POSITION is - * returned. - */ - private int getVisiblePos(@Nullable ForumEntry sEntry) { - int visibleCounter = 0; - int levelLimit = UNDEFINED; - for (ForumEntry fEntry : forumEntries) { - if (levelLimit >= 0) { - if (fEntry.getLevel() > levelLimit) { - // skip all the entries below a non visible branch - continue; - } - levelLimit = UNDEFINED; - } - if (sEntry != null && sEntry.equals(fEntry)) { - return visibleCounter; - } else if (!fEntry.isShowingDescendants()) { - levelLimit = fEntry.getLevel(); - } - visibleCounter++; - } - return sEntry == null ? visibleCounter : NO_POSITION; - } - - @Override - public int getItemCount() { - return getVisiblePos(null); - } - - static class NestedForumHolder extends RecyclerView.ViewHolder { - - private final TextView textView, lvlText, repliesText; - private final AuthorView author; - private final View[] lvls; - private final View chevron, replyButton; - private final ViewGroup cell; - private final View topDivider; - - private NestedForumHolder(View v) { - super(v); - - textView = (TextView) v.findViewById(R.id.text); - lvlText = (TextView) v.findViewById(R.id.nested_line_text); - author = (AuthorView) v.findViewById(R.id.author); - repliesText = (TextView) v.findViewById(R.id.replies); - int[] nestedLineIds = { - R.id.nested_line_1, R.id.nested_line_2, R.id.nested_line_3, - R.id.nested_line_4, R.id.nested_line_5 - }; - lvls = new View[nestedLineIds.length]; - for (int i = 0; i < lvls.length; i++) { - lvls[i] = v.findViewById(nestedLineIds[i]); - } - chevron = v.findViewById(R.id.chevron); - replyButton = v.findViewById(R.id.btn_reply); - cell = (ViewGroup) v.findViewById(R.id.forum_cell); - topDivider = v.findViewById(R.id.top_divider); - } - - } - - interface OnNestedForumListener { - void onEntryVisible(ForumEntry forumEntry); - - void onReplyClick(ForumEntry forumEntry); - } } diff --git a/briar-android/src/org/briarproject/android/forum/NestedForumHolder.java b/briar-android/src/org/briarproject/android/forum/NestedForumHolder.java new file mode 100644 index 0000000000..8263ccb4aa --- /dev/null +++ b/briar-android/src/org/briarproject/android/forum/NestedForumHolder.java @@ -0,0 +1,13 @@ +package org.briarproject.android.forum; + +import android.view.View; + +import org.briarproject.android.threaded.ThreadItemViewHolder; + +public class NestedForumHolder extends ThreadItemViewHolder<ForumEntry> { + + public NestedForumHolder(View v) { + super(v); + } + +} diff --git a/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageItem.java b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageItem.java new file mode 100644 index 0000000000..ee84c1fb00 --- /dev/null +++ b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageItem.java @@ -0,0 +1,22 @@ +package org.briarproject.android.privategroup.conversation; + +import org.briarproject.android.threaded.ThreadItem; +import org.briarproject.api.identity.Author; +import org.briarproject.api.identity.Author.Status; +import org.briarproject.api.privategroup.GroupMessageHeader; +import org.briarproject.api.sync.MessageId; + +class GroupMessageItem extends ThreadItem { + + public GroupMessageItem(MessageId messageId, MessageId parentId, + String text, long timestamp, Author author, Status status, + boolean isRead) { + super(messageId, parentId, text, timestamp, author, status, isRead); + } + + public GroupMessageItem(GroupMessageHeader h, String text) { + this(h.getId(), h.getParentId(), text, h.getTimestamp(), h.getAuthor(), + h.getAuthorStatus(), h.isRead()); + } + +} diff --git a/briar-android/src/org/briarproject/android/util/NestedTreeList.java b/briar-android/src/org/briarproject/android/threaded/NestedTreeList.java similarity index 96% rename from briar-android/src/org/briarproject/android/util/NestedTreeList.java rename to briar-android/src/org/briarproject/android/threaded/NestedTreeList.java index d190a9f6de..aec3c58beb 100644 --- a/briar-android/src/org/briarproject/android/util/NestedTreeList.java +++ b/briar-android/src/org/briarproject/android/threaded/NestedTreeList.java @@ -1,4 +1,4 @@ -package org.briarproject.android.util; +package org.briarproject.android.threaded; import android.support.annotation.UiThread; diff --git a/briar-android/src/org/briarproject/android/threaded/ThreadItem.java b/briar-android/src/org/briarproject/android/threaded/ThreadItem.java new file mode 100644 index 0000000000..a5a2207599 --- /dev/null +++ b/briar-android/src/org/briarproject/android/threaded/ThreadItem.java @@ -0,0 +1,95 @@ +package org.briarproject.android.threaded; + +import org.briarproject.api.clients.MessageTree.MessageNode; +import org.briarproject.api.identity.Author; +import org.briarproject.api.identity.Author.Status; +import org.briarproject.api.sync.MessageId; + +import static org.briarproject.android.threaded.ThreadItemAdapter.UNDEFINED; + +/* This class is not thread safe */ +public abstract class ThreadItem implements MessageNode { + + private final MessageId messageId; + private final MessageId parentId; + private final String text; + private final long timestamp; + private final Author author; + private final Status status; + private int level = UNDEFINED; + private boolean isShowingDescendants = true; + private int descendantCount = 0; + private boolean isRead; + + public ThreadItem(MessageId messageId, MessageId parentId, String text, + long timestamp, Author author, Status status, boolean isRead) { + this.messageId = messageId; + this.parentId = parentId; + this.text = text; + this.timestamp = timestamp; + this.author = author; + this.status = status; + this.isRead = isRead; + } + + public String getText() { + return text; + } + + public int getLevel() { + return level; + } + + @Override + public MessageId getId() { + return messageId; + } + + @Override + public MessageId getParentId() { + return parentId; + } + + @Override + public long getTimestamp() { + return timestamp; + } + + public Author getAuthor() { + return author; + } + + public Status getStatus() { + return status; + } + + public boolean isShowingDescendants() { + return isShowingDescendants; + } + + @Override + public void setLevel(int level) { + this.level = level; + } + + public void setShowingDescendants(boolean showingDescendants) { + this.isShowingDescendants = showingDescendants; + } + + public boolean isRead() { + return isRead; + } + + public void setRead(boolean read) { + isRead = read; + } + + public boolean hasDescendants() { + return descendantCount > 0; + } + + @Override + public void setDescendantCount(int descendantCount) { + this.descendantCount = descendantCount; + } +} diff --git a/briar-android/src/org/briarproject/android/threaded/ThreadItemAdapter.java b/briar-android/src/org/briarproject/android/threaded/ThreadItemAdapter.java new file mode 100644 index 0000000000..82e1fca18f --- /dev/null +++ b/briar-android/src/org/briarproject/android/threaded/ThreadItemAdapter.java @@ -0,0 +1,298 @@ +package org.briarproject.android.threaded; + +import android.animation.ValueAnimator; +import android.support.annotation.Nullable; +import android.support.annotation.UiThread; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; + +import org.briarproject.api.sync.MessageId; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static android.support.v7.widget.RecyclerView.NO_POSITION; + +@UiThread +public abstract class ThreadItemAdapter<I extends ThreadItem> + extends RecyclerView.Adapter<ThreadItemViewHolder<I>> { + + static final int UNDEFINED = -1; + + private final NestedTreeList<I> items = + new NestedTreeList<>(); + private final Map<I, ValueAnimator> animatingItems = + new HashMap<>(); + // highlight not dependant on time + private I replyItem; + // temporary highlight + private I addedEntry; + + private final ThreadItemListener<I> listener; + private final LinearLayoutManager layoutManager; + + public ThreadItemAdapter(ThreadItemListener<I> listener, + LinearLayoutManager layoutManager) { + this.listener = listener; + this.layoutManager = layoutManager; + } + + @Override + public void onBindViewHolder(ThreadItemViewHolder<I> ui, int position) { + final I item = getVisibleItem(position); + if (item == null) return; + listener.onItemVisible(item); + ui.bind(this, listener, item, position); + } + + @Override + public int getItemCount() { + return getVisiblePos(null); + } + + public I getReplyItem() { + return replyItem; + } + + public void setItems(List<I> items) { + this.items.clear(); + this.items.addAll(items); + notifyDataSetChanged(); + } + + public void add(I item) { + items.add(item); + addedEntry = item; + if (item.getParentId() == null) { + notifyItemInserted(getVisiblePos(item)); + } else { + // Try to find the item's parent and perform the proper ui update if + // it's present and visible. + for (int i = items.indexOf(item) - 1; i >= 0; i--) { + I higherItem = items.get(i); + if (higherItem.getLevel() < item.getLevel()) { + // parent found + if (higherItem.isShowingDescendants()) { + int parentVisiblePos = getVisiblePos(higherItem); + if (parentVisiblePos != NO_POSITION) { + // parent is visible, we need to update its ui + notifyItemChanged(parentVisiblePos); + // new item insert ui + int visiblePos = getVisiblePos(item); + notifyItemInserted(visiblePos); + break; + } + } else { + // do not show the new item if its parent is not showing + // descendants (this can be overridden by the user by + // pressing the snack bar) + break; + } + } + } + } + } + + public void scrollTo(I item) { + int visiblePos = getVisiblePos(item); + if (visiblePos == NO_POSITION && item.getParentId() != null) { + // The item is not visible due to being hidden by its parent item. + // Find the parent and make it visible and traverse up the parent + // chain if necessary to make the item visible + MessageId parentId = item.getParentId(); + for (int i = items.indexOf(item) - 1; i >= 0; i--) { + I higherItem = items.get(i); + if (higherItem.getId().equals(parentId)) { + // parent found + showDescendants(higherItem); + int parentPos = getVisiblePos(higherItem); + if (parentPos != NO_POSITION) { + // parent or ancestor is visible, entry's visibility + // is ensured + notifyItemChanged(parentPos); + visiblePos = parentPos; + break; + } + // parent or ancestor is hidden, we need to continue up the + // dependency chain + parentId = higherItem.getParentId(); + } + } + } + if (visiblePos != NO_POSITION) + layoutManager.scrollToPositionWithOffset(visiblePos, 0); + } + + int getReplyCount(I item) { + int counter = 0; + int pos = items.indexOf(item); + if (pos >= 0) { + int ancestorLvl = item.getLevel(); + for (int i = pos + 1; i < items.size(); i++) { + int descendantLvl = items.get(i).getLevel(); + if (descendantLvl <= ancestorLvl) + break; + if (descendantLvl == ancestorLvl + 1) + counter++; + } + } + return counter; + } + + public void setReplyItem(@Nullable I entry) { + if (replyItem != null) { + notifyItemChanged(getVisiblePos(replyItem)); + } + replyItem = entry; + if (replyItem != null) { + notifyItemChanged(getVisiblePos(replyItem)); + } + } + + public void setReplyItemById(byte[] id) { + MessageId messageId = new MessageId(id); + for (I item : items) { + if (item.getId().equals(messageId)) { + setReplyItem(item); + break; + } + } + } + + private List<Integer> getSubTreeIndexes(int pos, int levelLimit) { + List<Integer> indexList = new ArrayList<>(); + + for (int i = pos + 1; i < getItemCount(); i++) { + I item = getVisibleItem(i); + if (item != null && item.getLevel() > levelLimit) { + indexList.add(i); + } else { + break; + } + } + return indexList; + } + + public void showDescendants(I item) { + item.setShowingDescendants(true); + int visiblePos = getVisiblePos(item); + List<Integer> indexList = + getSubTreeIndexes(visiblePos, item.getLevel()); + if (!indexList.isEmpty()) { + if (indexList.size() == 1) { + notifyItemInserted(indexList.get(0)); + } else { + notifyItemRangeInserted(indexList.get(0), + indexList.size()); + } + } + } + + public void hideDescendants(I item) { + int visiblePos = getVisiblePos(item); + List<Integer> indexList = + getSubTreeIndexes(visiblePos, item.getLevel()); + if (!indexList.isEmpty()) { + // stop animating children + for (int index : indexList) { + ValueAnimator anim = animatingItems.get(items.get(index)); + if (anim != null && anim.isRunning()) { + anim.cancel(); + } + } + if (indexList.size() == 1) { + notifyItemRemoved(indexList.get(0)); + } else { + notifyItemRangeRemoved(indexList.get(0), + indexList.size()); + } + } + item.setShowingDescendants(false); + } + + + /** + * Returns the visible item at the given position + * @param position is visible entry index + * @return the visible entry at index position from an ordered list of + * visible entries, or null if position is larger than + * the number of visible entries. + */ + @Nullable + public I getVisibleItem(int position) { + int levelLimit = UNDEFINED; + for (I item : items) { + if (levelLimit >= 0) { + // skip hidden entries that their parent is hiding + if (item.getLevel() > levelLimit) { + continue; + } + levelLimit = UNDEFINED; + } + if (!item.isShowingDescendants()) { + levelLimit = item.getLevel(); + } + if (position-- == 0) { + return item; + } + } + return null; + } + + public boolean isVisible(I item) { + return getVisiblePos(item) != NO_POSITION; + } + + /** + * Returns the visible position of the given ThreadItem + * @param item the ThreadItem to find the visible position of, or null to + * return the total count of visible elements + * @return the visible position of item, or the total number of visible + * elements if sEntry is null. If item is not visible NO_POSITION is + * returned. + */ + private int getVisiblePos(@Nullable I item) { + int visibleCounter = 0; + int levelLimit = UNDEFINED; + for (I iItem : items) { + if (levelLimit >= 0) { + if (iItem.getLevel() > levelLimit) { + // skip all the entries below a non visible branch + continue; + } + levelLimit = UNDEFINED; + } + if (item != null && item.equals(iItem)) { + return visibleCounter; + } else if (!iItem.isShowingDescendants()) { + levelLimit = iItem.getLevel(); + } + visibleCounter++; + } + return item == null ? visibleCounter : NO_POSITION; + } + + I getAddedItem() { + return addedEntry; + } + + void clearAddedItem() { + addedEntry = null; + } + + void addAnimatingItem(I item, ValueAnimator anim) { + animatingItems.put(item, anim); + } + + void removeAnimatingItem(I item) { + animatingItems.remove(item); + } + + public interface ThreadItemListener<I> { + void onItemVisible(I item); + + void onReplyClick(I item); + } +} diff --git a/briar-android/src/org/briarproject/android/threaded/ThreadItemViewHolder.java b/briar-android/src/org/briarproject/android/threaded/ThreadItemViewHolder.java new file mode 100644 index 0000000000..27ca9d80dc --- /dev/null +++ b/briar-android/src/org/briarproject/android/threaded/ThreadItemViewHolder.java @@ -0,0 +1,182 @@ +package org.briarproject.android.threaded; + +import android.animation.Animator; +import android.animation.ArgbEvaluator; +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.support.annotation.UiThread; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.briarproject.R; +import org.briarproject.android.threaded.ThreadItemAdapter.ThreadItemListener; +import org.briarproject.android.view.AuthorView; +import org.briarproject.util.StringUtils; + +import static android.view.View.GONE; +import static android.view.View.INVISIBLE; +import static android.view.View.VISIBLE; + +@UiThread +public abstract class ThreadItemViewHolder<I extends ThreadItem> + extends RecyclerView.ViewHolder { + + private final static int ANIMATION_DURATION = 5000; + + private final TextView textView, lvlText, repliesText; + private final AuthorView author; + private final View[] lvls; + private final View chevron, replyButton; + private final ViewGroup cell; + private final View topDivider; + + public ThreadItemViewHolder(View v) { + super(v); + + textView = (TextView) v.findViewById(R.id.text); + lvlText = (TextView) v.findViewById(R.id.nested_line_text); + author = (AuthorView) v.findViewById(R.id.author); + repliesText = (TextView) v.findViewById(R.id.replies); + int[] nestedLineIds = { + R.id.nested_line_1, R.id.nested_line_2, R.id.nested_line_3, + R.id.nested_line_4, R.id.nested_line_5 + }; + lvls = new View[nestedLineIds.length]; + for (int i = 0; i < lvls.length; i++) { + lvls[i] = v.findViewById(nestedLineIds[i]); + } + chevron = v.findViewById(R.id.chevron); + replyButton = v.findViewById(R.id.btn_reply); + cell = (ViewGroup) v.findViewById(R.id.forum_cell); + topDivider = v.findViewById(R.id.top_divider); + } + + // TODO improve encapsulation, so we don't need to pass the adapter here + public void bind(final ThreadItemAdapter<I> adapter, + final ThreadItemListener listener, final I item, int pos) { + + textView.setText(StringUtils.trim(item.getText())); + + if (pos == 0) { + topDivider.setVisibility(View.INVISIBLE); + } else { + topDivider.setVisibility(View.VISIBLE); + } + + for (int i = 0; i < lvls.length; i++) { + lvls[i].setVisibility(i < item.getLevel() ? VISIBLE : GONE); + } + if (item.getLevel() > 5) { + lvlText.setVisibility(VISIBLE); + lvlText.setText("" + item.getLevel()); + } else { + lvlText.setVisibility(GONE); + } + author.setAuthor(item.getAuthor()); + author.setDate(item.getTimestamp()); + author.setAuthorStatus(item.getStatus()); + + int replies = adapter.getReplyCount(item); + if (replies == 0) { + repliesText.setText(""); + } else { + repliesText.setText(getContext().getResources() + .getQuantityString(R.plurals.message_replies, replies, + replies)); + } + + if (item.hasDescendants()) { + chevron.setVisibility(VISIBLE); + if (item.isShowingDescendants()) { + chevron.setSelected(false); + } else { + chevron.setSelected(true); + } + chevron.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + chevron.setSelected(!chevron.isSelected()); + if (chevron.isSelected()) { + adapter.hideDescendants(item); + } else { + adapter.showDescendants(item); + } + } + }); + } else { + chevron.setVisibility(INVISIBLE); + } + if (item.equals(adapter.getReplyItem())) { + cell.setBackgroundColor(ContextCompat + .getColor(getContext(), R.color.forum_cell_highlight)); + } else if (item.equals(adapter.getAddedItem())) { + cell.setBackgroundColor(ContextCompat + .getColor(getContext(), R.color.forum_cell_highlight)); + animateFadeOut(adapter, adapter.getAddedItem()); + adapter.clearAddedItem(); + } else { + cell.setBackgroundColor(ContextCompat + .getColor(getContext(), R.color.window_background)); + } + replyButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + listener.onReplyClick(item); + adapter.scrollTo(item); + } + }); + } + + private void animateFadeOut(final ThreadItemAdapter<I> adapter, + final I addedItem) { + + setIsRecyclable(false); + ValueAnimator anim = new ValueAnimator(); + adapter.addAnimatingItem(addedItem, anim); + ColorDrawable viewColor = (ColorDrawable) cell.getBackground(); + anim.setIntValues(viewColor.getColor(), ContextCompat + .getColor(getContext(), R.color.window_background)); + anim.setEvaluator(new ArgbEvaluator()); + anim.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animation) { + + } + + @Override + public void onAnimationEnd(Animator animation) { + setIsRecyclable(true); + adapter.removeAnimatingItem(addedItem); + } + + @Override + public void onAnimationCancel(Animator animation) { + setIsRecyclable(true); + adapter.removeAnimatingItem(addedItem); + } + + @Override + public void onAnimationRepeat(Animator animation) { + + } + }); + anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + cell.setBackgroundColor( + (Integer) valueAnimator.getAnimatedValue()); + } + }); + anim.setDuration(ANIMATION_DURATION); + anim.start(); + } + + private Context getContext() { + return textView.getContext(); + } + +} diff --git a/briar-android/src/org/briarproject/android/threaded/ThreadListActivity.java b/briar-android/src/org/briarproject/android/threaded/ThreadListActivity.java new file mode 100644 index 0000000000..ea18e720f5 --- /dev/null +++ b/briar-android/src/org/briarproject/android/threaded/ThreadListActivity.java @@ -0,0 +1,205 @@ +package org.briarproject.android.threaded; + +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.CallSuper; +import android.support.annotation.LayoutRes; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.design.widget.Snackbar; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.LinearLayoutManager; +import android.view.MenuItem; +import android.view.View; + +import org.briarproject.R; +import org.briarproject.android.BriarActivity; +import org.briarproject.android.api.AndroidNotificationManager; +import org.briarproject.android.threaded.ThreadItemAdapter.ThreadItemListener; +import org.briarproject.android.view.BriarRecyclerView; +import org.briarproject.android.view.TextInputView; +import org.briarproject.android.view.TextInputView.TextInputListener; +import org.briarproject.api.sync.GroupId; + +import javax.inject.Inject; + +import static android.support.design.widget.Snackbar.make; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +public abstract class ThreadListActivity<I extends ThreadItem, A extends ThreadItemAdapter<I>> + extends BriarActivity + implements TextInputListener, ThreadItemListener<I> { + + protected static final String KEY_INPUT_VISIBILITY = "inputVisibility"; + protected static final String KEY_REPLY_ID = "replyId"; + + @Inject + protected AndroidNotificationManager notificationManager; + + protected A adapter; + protected BriarRecyclerView list; + protected TextInputView textInput; + protected GroupId groupId; + + @CallSuper + @Override + public void onCreate(final Bundle state) { + super.onCreate(state); + + setContentView(getLayout()); + + Intent i = getIntent(); + byte[] b = i.getByteArrayExtra(GROUP_ID); + if (b == null) throw new IllegalStateException("No GroupId in intent."); + groupId = new GroupId(b); + + textInput = (TextInputView) findViewById(R.id.text_input_container); + textInput.setVisibility(GONE); + textInput.setListener(this); + list = (BriarRecyclerView) findViewById(R.id.list); + LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this); + list.setLayoutManager(linearLayoutManager); + adapter = createAdapter(linearLayoutManager); + list.setAdapter(adapter); + } + + protected abstract @LayoutRes int getLayout(); + + protected abstract A createAdapter(LinearLayoutManager layoutManager); + + @CallSuper + @Override + public void onResume() { + super.onResume(); + notificationManager.blockNotification(groupId); + list.startPeriodicUpdate(); + } + + @CallSuper + @Override + public void onPause() { + super.onPause(); + notificationManager.unblockNotification(groupId); + list.stopPeriodicUpdate(); + } + + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + textInput.setVisibility( + savedInstanceState.getBoolean(KEY_INPUT_VISIBILITY) ? + VISIBLE : GONE); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(KEY_INPUT_VISIBILITY, + textInput.getVisibility() == VISIBLE); + ThreadItem replyItem = adapter.getReplyItem(); + if (replyItem != null) { + outState.putByteArray(KEY_REPLY_ID, + replyItem.getId().getBytes()); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + if (textInput.isKeyboardOpen()) textInput.hideSoftKeyboard(); + supportFinishAfterTransition(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + @Override + public void onBackPressed() { + if (textInput.getVisibility() == VISIBLE) { + textInput.setVisibility(GONE); + adapter.setReplyItem(null); + } else { + super.onBackPressed(); + } + } + + @Override + public void onItemVisible(I item) { + if (!item.isRead()) { + item.setRead(true); + markItemRead(item); + } + } + + protected abstract void markItemRead(I item); + + @Override + public void onReplyClick(I item) { + showTextInput(item); + } + + protected void displaySnackbarShort(int stringId) { + Snackbar snackbar = make(list, stringId, Snackbar.LENGTH_SHORT); + snackbar.getView().setBackgroundResource(R.color.briar_primary); + snackbar.show(); + } + + protected void showTextInput(@Nullable I replyItem) { + // An animation here would be an overkill because of the keyboard + // popping up. + // only clear the text when the input container was not visible + if (textInput.getVisibility() != VISIBLE) { + textInput.setVisibility(VISIBLE); + textInput.setText(""); + } + textInput.requestFocus(); + textInput.showSoftKeyboard(); + textInput.setHint(replyItem == null ? R.string.forum_new_message_hint : + R.string.forum_message_reply_hint); + adapter.setReplyItem(replyItem); + } + + @Override + public void onSendClick(String text) { + if (text.trim().length() == 0) + return; + I replyItem = adapter.getReplyItem(); + sendItem(text, replyItem); + textInput.hideSoftKeyboard(); + textInput.setVisibility(GONE); + adapter.setReplyItem(null); + } + + protected abstract void sendItem(String text, I replyToItem); + + protected void addItem(final I item, boolean isLocal) { + adapter.add(item); + if (isLocal && adapter.isVisible(item)) { + displaySnackbarShort(getItemPostedString()); + } else { + Snackbar snackbar = Snackbar.make(list, + isLocal ? getItemPostedString() : getItemReceivedString(), + Snackbar.LENGTH_LONG); + snackbar.getView().setBackgroundResource(R.color.briar_primary); + snackbar.setActionTextColor(ContextCompat + .getColor(ThreadListActivity.this, + R.color.briar_button_positive)); + snackbar.setAction(R.string.show, new View.OnClickListener() { + @Override + public void onClick(View v) { + adapter.scrollTo(item); + } + }); + snackbar.getView().setBackgroundResource(R.color.briar_primary); + snackbar.show(); + } + } + + protected abstract @StringRes int getItemPostedString(); + + protected abstract @StringRes int getItemReceivedString(); + +} diff --git a/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java b/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java index 3e5431ac94..7842c11e2a 100644 --- a/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java +++ b/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java @@ -126,9 +126,9 @@ public class ForumActivityTest { adapter.hideDescendants(dummyData.get(0)); assertEquals(2, adapter.getItemCount()); assertTrue(dummyData.get(0).getText() - .equals(adapter.getVisibleEntry(0).getText())); + .equals(adapter.getVisibleItem(0).getText())); assertTrue(dummyData.get(5).getText() - .equals(adapter.getVisibleEntry(1).getText())); + .equals(adapter.getVisibleItem(1).getText())); // Cascade re-open adapter.showDescendants(dummyData.get(0)); assertEquals(4, adapter.getItemCount()); @@ -137,8 +137,8 @@ public class ForumActivityTest { adapter.showDescendants(dummyData.get(2)); assertEquals(6, adapter.getItemCount()); assertTrue(dummyData.get(2).getText() - .equals(adapter.getVisibleEntry(2).getText())); + .equals(adapter.getVisibleItem(2).getText())); assertTrue(dummyData.get(4).getText() - .equals(adapter.getVisibleEntry(4).getText())); + .equals(adapter.getVisibleItem(4).getText())); } } diff --git a/briar-android/test/java/org/briarproject/android/forum/TestForumActivity.java b/briar-android/test/java/org/briarproject/android/forum/TestForumActivity.java index 8acbf3b8df..806fee299b 100644 --- a/briar-android/test/java/org/briarproject/android/forum/TestForumActivity.java +++ b/briar-android/test/java/org/briarproject/android/forum/TestForumActivity.java @@ -16,7 +16,7 @@ public class TestForumActivity extends ForumActivity { } public NestedForumAdapter getAdapter() { - return forumAdapter; + return adapter; } @Override -- GitLab