diff --git a/briar-android/src/org/briarproject/android/ActivityModule.java b/briar-android/src/org/briarproject/android/ActivityModule.java index 5627651aecb15d7f0ea76befa8c04d3c5b63302d..ff40912c11a418058129e70b78f733a07d05b2b5 100644 --- a/briar-android/src/org/briarproject/android/ActivityModule.java +++ b/briar-android/src/org/briarproject/android/ActivityModule.java @@ -23,9 +23,6 @@ import org.briarproject.android.controller.SetupControllerImpl; import org.briarproject.android.controller.TransportStateListener; import org.briarproject.android.forum.ForumController; import org.briarproject.android.forum.ForumControllerImpl; -import org.briarproject.android.forum.ForumTestControllerImpl; - -import javax.inject.Named; import dagger.Module; import dagger.Provides; @@ -103,14 +100,6 @@ public class ActivityModule { return forumController; } - @Named("ForumTestController") - @ActivityScope - @Provides - protected ForumController provideForumTestController( - ForumTestControllerImpl forumController) { - return forumController; - } - @ActivityScope @Provides BlogController provideBlogController(BlogControllerImpl blogController) { diff --git a/briar-android/src/org/briarproject/android/forum/ForumActivity.java b/briar-android/src/org/briarproject/android/forum/ForumActivity.java index 6b8c5a2b5e9d1e4cf0a09465b96b57c9693b604b..19ce6d499e232e43a5f2fcafbba4ad1325a7a738 100644 --- a/briar-android/src/org/briarproject/android/forum/ForumActivity.java +++ b/briar-android/src/org/briarproject/android/forum/ForumActivity.java @@ -1,27 +1,18 @@ package org.briarproject.android.forum; -import android.animation.Animator; -import android.animation.ArgbEvaluator; -import android.animation.ValueAnimator; import android.content.DialogInterface; import android.content.Intent; -import android.graphics.drawable.ColorDrawable; import android.os.Bundle; -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.support.v7.widget.RecyclerView; -import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; import android.widget.Toast; import org.briarproject.R; @@ -30,52 +21,46 @@ import org.briarproject.android.BriarActivity; import org.briarproject.android.api.AndroidNotificationManager; 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.AuthorView; import org.briarproject.android.view.BriarRecyclerView; import org.briarproject.android.view.TextInputView; import org.briarproject.android.view.TextInputView.TextInputListener; import org.briarproject.api.forum.Forum; +import org.briarproject.api.forum.ForumPost; +import org.briarproject.api.forum.ForumPostHeader; import org.briarproject.api.sync.GroupId; -import org.briarproject.api.sync.MessageId; import org.briarproject.util.StringUtils; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; 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.support.v7.widget.RecyclerView.NO_POSITION; import static android.view.View.GONE; -import static android.view.View.INVISIBLE; import static android.view.View.VISIBLE; import static android.widget.Toast.LENGTH_SHORT; public class ForumActivity extends BriarActivity implements - ForumPostListener, TextInputListener { + ForumPostListener, TextInputListener, OnNestedForumListener { static final String FORUM_NAME = "briar.FORUM_NAME"; private static final int REQUEST_FORUM_SHARED = 3; - private static final int UNDEFINED = -1; private static final String KEY_INPUT_VISIBILITY = "inputVisibility"; private static final String KEY_REPLY_ID = "replyId"; @Inject AndroidNotificationManager notificationManager; - // uncomment the next line for a test component with dummy data -// @Named("ForumTestController") @Inject protected ForumController forumController; // Protected access for testing - protected ForumAdapter forumAdapter; + protected NestedForumAdapter forumAdapter; private BriarRecyclerView recyclerView; private TextInputView textInput; @@ -96,42 +81,42 @@ public class ForumActivity extends BriarActivity implements String forumName = i.getStringExtra(FORUM_NAME); if (forumName != null) setTitle(forumName); - forumAdapter = new ForumAdapter(); - textInput = (TextInputView) findViewById(R.id.text_input_container); textInput.setVisibility(GONE); textInput.setListener(this); recyclerView = (BriarRecyclerView) findViewById(R.id.forum_discussion_list); - recyclerView.setAdapter(forumAdapter); - linearLayoutManager = new LinearLayoutManager(this); + LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this); recyclerView.setLayoutManager(linearLayoutManager); + forumAdapter = new NestedForumAdapter(this, this, linearLayoutManager); + recyclerView.setAdapter(forumAdapter); recyclerView.setEmptyText(R.string.no_forum_posts); - forumController.loadForum(groupId, new UiResultHandler<Boolean>(this) { - @Override - public void onResultUi(Boolean result) { - if (result) { - Forum forum = forumController.getForum(); - if (forum != null) setTitle(forum.getName()); - List<ForumEntry> entries = - forumController.getForumEntries(); - if (entries.isEmpty()) { - recyclerView.showData(); - } else { - forumAdapter.setEntries(entries); - if (state != null) { - byte[] replyId = state.getByteArray(KEY_REPLY_ID); - if (replyId != null) - forumAdapter.setReplyEntryById(replyId); + forumController.loadForum(groupId, + new UiResultHandler<List<ForumEntry>>(this) { + @Override + public void onResultUi(List<ForumEntry> result) { + if (result != null) { + Forum forum = forumController.getForum(); + if (forum != null) setTitle(forum.getName()); + List<ForumEntry> entries = new ArrayList<>(result); + if (entries.isEmpty()) { + recyclerView.showData(); + } else { + forumAdapter.setEntries(entries); + if (state != null) { + byte[] replyId = + state.getByteArray(KEY_REPLY_ID); + if (replyId != null) + forumAdapter.setReplyEntryById(replyId); + } + } + } else { + // TODO Improve UX ? + finish(); } } - } else { - // TODO Maybe an error dialog ? - finish(); - } - } - }); + }); } @Override @@ -265,12 +250,27 @@ public class ForumActivity extends BriarActivity implements return; if (forumController.getForum() == null) return; ForumEntry replyEntry = forumAdapter.getReplyEntry(); + UiResultHandler<ForumPost> resultHandler = + new UiResultHandler<ForumPost>(this) { + @Override + public void onResultUi(ForumPost result) { + forumController.storePost(result, + new UiResultHandler<ForumEntry>( + ForumActivity.this) { + @Override + public void onResultUi(ForumEntry result) { + onForumEntryAdded(result, true); + } + }); + } + }; if (replyEntry == null) { // root post - forumController.createPost(StringUtils.toUtf8(text)); + forumController.createPost(StringUtils.toUtf8(text), resultHandler); } else { - forumController.createPost(StringUtils.toUtf8(text), - replyEntry.getMessageId()); + forumController + .createPost(StringUtils.toUtf8(text), replyEntry.getId(), + resultHandler); } hideSoftKeyboard(textInput); textInput.setVisibility(GONE); @@ -308,412 +308,47 @@ public class ForumActivity extends BriarActivity implements } @Override - public void addLocalEntry(int index, ForumEntry entry) { - forumAdapter.addEntry(index, entry, true); - displaySnackbarShort(R.string.forum_new_entry_posted); + public void onEntryVisible(ForumEntry forumEntry) { + if (!forumEntry.isRead()) { + forumEntry.setRead(true); + forumController.entryRead(forumEntry); + } } @Override - public void addForeignEntry(final int index, final ForumEntry entry) { - forumAdapter.addEntry(index, entry, false); - Snackbar snackbar = - Snackbar.make(recyclerView, R.string.forum_new_entry_received, - Snackbar.LENGTH_LONG); - snackbar.setActionTextColor( - ContextCompat.getColor(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(); + public void onReplyClick(ForumEntry forumEntry) { + showTextInput(forumEntry); } - static class ForumViewHolder extends RecyclerView.ViewHolder { - - final TextView textView, lvlText, repliesText; - final AuthorView author; - final View[] lvls; - final View chevron, replyButton; - final ViewGroup cell; - final View topDivider; - - ForumViewHolder(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); - } - } - - public class ForumAdapter extends RecyclerView.Adapter<ForumViewHolder> { - - private final List<ForumEntry> forumEntries = new ArrayList<>(); - private final Map<ForumEntry, ValueAnimator> animatingEntries = - new HashMap<>(); - - // highlight not dependant on time - private ForumEntry replyEntry; - // temporary highlight - private ForumEntry addedEntry; - - private ForumEntry getReplyEntry() { - return replyEntry; - } - - void setEntries(List<ForumEntry> entries) { - forumEntries.clear(); - forumEntries.addAll(entries); - notifyItemRangeInserted(0, entries.size()); - } - - void addEntry(int index, ForumEntry entry, boolean isScrolling) { - forumEntries.add(index, entry); - boolean isShowingDescendants = false; - if (entry.getLevel() > 0) { - // update parent and make sure descendants are visible - // Note that the parent's visibility is guaranteed (otherwise - // the reply button would not be visible) - for (int i = index - 1; i >= 0; i--) { - ForumEntry higherEntry = forumEntries.get(i); - if (higherEntry.getLevel() < entry.getLevel()) { - // parent found - if (!higherEntry.isShowingDescendants()) { - isShowingDescendants = true; - showDescendants(higherEntry); - } - notifyItemChanged(getVisiblePos(higherEntry)); - break; - } - } - } - if (!isShowingDescendants) { - int visiblePos = getVisiblePos(entry); - notifyItemInserted(visiblePos); - if (isScrolling) - linearLayoutManager - .scrollToPositionWithOffset(visiblePos, 0); - } - addedEntry = entry; - } - - void scrollToEntry(ForumEntry entry) { - int visiblePos = getVisiblePos(entry); - linearLayoutManager.scrollToPositionWithOffset(visiblePos, 0); - } - - private boolean hasDescendants(ForumEntry forumEntry) { - int i = forumEntries.indexOf(forumEntry); - if (i >= 0 && i < forumEntries.size() - 1) { - if (forumEntries.get(i + 1).getLevel() > - forumEntry.getLevel()) { - return true; - } - } - return false; - } - - private boolean hasVisibleDescendants(ForumEntry forumEntry) { - int visiblePos = getVisiblePos(forumEntry); - int levelLimit = forumEntry.getLevel(); - // FIXME This loop doesn't really loop. @ernir please review! - for (int i = visiblePos + 1; i < getItemCount(); i++) { - ForumEntry entry = getVisibleEntry(i); - if (entry != null && entry.getLevel() <= levelLimit) - break; - return true; - } - return false; - } - - private int getReplyCount(ForumEntry entry) { - int counter = 0; - int pos = forumEntries.indexOf(entry); - if (pos >= 0) { - int ancestorLvl = forumEntries.get(pos).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.getMessageId().equals(messageId)) { - setReplyEntry(entry); - break; - } - } - } - - void setReplyEntry(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); - } - - - @Nullable - ForumEntry getVisibleEntry(int position) { - int levelLimit = UNDEFINED; - for (ForumEntry forumEntry : forumEntries) { - if (levelLimit >= 0) { - if (forumEntry.getLevel() > levelLimit) { - continue; - } - levelLimit = UNDEFINED; - } - if (!forumEntry.isShowingDescendants()) { - levelLimit = forumEntry.getLevel(); - } - if (position-- == 0) { - return forumEntry; - } - } - return null; - } - - private void animateFadeOut(final ForumViewHolder 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 + private void onForumEntryAdded(final ForumEntry entry, boolean isLocal) { + forumAdapter.addEntry(entry); + if (isLocal) { + displaySnackbarShort(R.string.forum_new_entry_posted); + } else { + Snackbar snackbar = Snackbar.make(recyclerView, + R.string.forum_new_entry_received, Snackbar.LENGTH_LONG); + snackbar.setActionTextColor(ContextCompat .getColor(ForumActivity.this, - 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(); - } - - @Override - public ForumViewHolder onCreateViewHolder(ViewGroup parent, - int viewType) { - View v = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.list_item_forum_post, parent, false); - return new ForumViewHolder(v); - } - - @Override - public void onBindViewHolder( - final ForumViewHolder ui, final int position) { - final ForumEntry data = getVisibleEntry(position); - if (data == null) return; - - if (!data.isRead()) { - data.setRead(true); - forumController.entryRead(data); - } - ui.textView.setText(StringUtils.trim(data.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 < data.getLevel() ? VISIBLE : GONE); - } - if (data.getLevel() > 5) { - ui.lvlText.setVisibility(VISIBLE); - ui.lvlText.setText("" + data.getLevel()); - } else { - ui.lvlText.setVisibility(GONE); - } - ui.author.setAuthor(data.getAuthor()); - ui.author.setDate(data.getTimestamp()); - ui.author.setAuthorStatus(data.getStatus()); - - int replies = getReplyCount(data); - if (replies == 0) { - ui.repliesText.setText(""); - } else { - ui.repliesText.setText(getResources() - .getQuantityString(R.plurals.message_replies, replies, - replies)); - } - - if (hasDescendants(data)) { - ui.chevron.setVisibility(VISIBLE); - if (hasVisibleDescendants(data)) { - 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(data); - } else { - showDescendants(data); - } - } - }); - } else { - ui.chevron.setVisibility(INVISIBLE); - } - if (data.equals(replyEntry)) { - ui.cell.setBackgroundColor(ContextCompat - .getColor(ForumActivity.this, - R.color.forum_cell_highlight)); - } else if (data.equals(addedEntry)) { - - ui.cell.setBackgroundColor(ContextCompat - .getColor(ForumActivity.this, - R.color.forum_cell_highlight)); - animateFadeOut(ui, addedEntry); - addedEntry = null; - } else { - ui.cell.setBackgroundColor(ContextCompat - .getColor(ForumActivity.this, - R.color.window_background)); - } - ui.replyButton.setOnClickListener(new View.OnClickListener() { + R.color.briar_button_positive)); + snackbar.setAction(R.string.show, new View.OnClickListener() { @Override public void onClick(View v) { - showTextInput(data); - linearLayoutManager - .scrollToPositionWithOffset(getVisiblePos(data), 0); + forumAdapter.scrollToEntry(entry); } }); + snackbar.getView().setBackgroundResource(R.color.briar_primary); + snackbar.show(); } + } - private int getVisiblePos(ForumEntry sEntry) { - int visibleCounter = 0; - int levelLimit = UNDEFINED; - for (ForumEntry fEntry : forumEntries) { - if (levelLimit >= 0) { - if (fEntry.getLevel() > levelLimit) { - continue; - } - levelLimit = UNDEFINED; - } - if (sEntry != null && sEntry.equals(fEntry)) { - return visibleCounter; - } else if (!fEntry.isShowingDescendants()) { - levelLimit = fEntry.getLevel(); - } - visibleCounter++; + @Override + public void onExternalEntryAdded(ForumPostHeader header) { + forumController.loadPost(header, new UiResultHandler<ForumEntry>(this) { + @Override + public void onResultUi(final ForumEntry result) { + onForumEntryAdded(result, false); } - return sEntry == null ? visibleCounter : NO_POSITION; - } + }); - @Override - public int getItemCount() { - return getVisiblePos(null); - } } - } diff --git a/briar-android/src/org/briarproject/android/forum/ForumController.java b/briar-android/src/org/briarproject/android/forum/ForumController.java index b492627e41e07a5ba18e7bd971484cc21cea91d1..79499d48dea283ef68a3146e73ee39742544387a 100644 --- a/briar-android/src/org/briarproject/android/forum/ForumController.java +++ b/briar-android/src/org/briarproject/android/forum/ForumController.java @@ -1,10 +1,13 @@ package org.briarproject.android.forum; import android.support.annotation.Nullable; +import android.support.annotation.UiThread; import org.briarproject.android.controller.ActivityLifecycleController; import org.briarproject.android.controller.handler.ResultHandler; import org.briarproject.api.forum.Forum; +import org.briarproject.api.forum.ForumPost; +import org.briarproject.api.forum.ForumPostHeader; import org.briarproject.api.sync.GroupId; import org.briarproject.api.sync.MessageId; @@ -13,12 +16,14 @@ import java.util.List; public interface ForumController extends ActivityLifecycleController { - void loadForum(GroupId groupId, ResultHandler<Boolean> resultHandler); + void loadForum(GroupId groupId, + ResultHandler<List<ForumEntry>> resultHandler); @Nullable Forum getForum(); - List<ForumEntry> getForumEntries(); + void loadPost(ForumPostHeader header, + ResultHandler<ForumEntry> resultHandler); void unsubscribe(ResultHandler<Boolean> resultHandler); @@ -26,14 +31,16 @@ public interface ForumController extends ActivityLifecycleController { void entriesRead(Collection<ForumEntry> messageIds); - void createPost(byte[] body); + void createPost(byte[] body, ResultHandler<ForumPost> resultHandler); - void createPost(byte[] body, MessageId parentId); + void createPost(byte[] body, MessageId parentId, + ResultHandler<ForumPost> resultHandler); - interface ForumPostListener { - void addLocalEntry(int index, ForumEntry entry); + void storePost(ForumPost post, ResultHandler<ForumEntry> resultHandler); - void addForeignEntry(int index, ForumEntry entry); + interface ForumPostListener { + @UiThread + void onExternalEntryAdded(ForumPostHeader header); } } diff --git a/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java b/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java index 2ec3bc09be9256e004949e1446c77f04aeffa0bc..51e23cfc4aaa07d3ce43bfb6ed1ea0f7007c8c5f 100644 --- a/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java +++ b/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java @@ -6,7 +6,6 @@ import android.support.annotation.Nullable; import org.briarproject.android.controller.DbControllerImpl; import org.briarproject.android.controller.handler.ResultHandler; import org.briarproject.api.FormatException; -import org.briarproject.api.clients.MessageTree; import org.briarproject.api.crypto.CryptoComponent; import org.briarproject.api.crypto.CryptoExecutor; import org.briarproject.api.crypto.KeyParser; @@ -26,7 +25,6 @@ import org.briarproject.api.identity.IdentityManager; import org.briarproject.api.identity.LocalAuthor; import org.briarproject.api.sync.GroupId; import org.briarproject.api.sync.MessageId; -import org.briarproject.clients.MessageTreeImpl; import org.briarproject.util.StringUtils; import java.security.GeneralSecurityException; @@ -35,9 +33,9 @@ import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Stack; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Logger; import javax.inject.Inject; @@ -69,12 +67,9 @@ public class ForumControllerImpl extends DbControllerImpl protected volatile IdentityManager identityManager; private final Map<MessageId, byte[]> bodyCache = new ConcurrentHashMap<>(); - private final MessageTree<ForumPostHeader> tree = new MessageTreeImpl<>(); - + private volatile AtomicLong newestTimeStamp = new AtomicLong(); private volatile LocalAuthor localAuthor = null; private volatile Forum forum = null; - // FIXME: This collection isn't thread-safe, isn't updated atomically - private volatile List<ForumEntry> forumEntries = null; private ForumPostListener listener; @@ -111,13 +106,21 @@ public class ForumControllerImpl extends DbControllerImpl @Override public void eventOccurred(Event e) { if (forum == null) return; - if (e instanceof ForumPostReceivedEvent) { - ForumPostReceivedEvent pe = (ForumPostReceivedEvent) e; + final ForumPostReceivedEvent pe = (ForumPostReceivedEvent) e; if (pe.getGroupId().equals(forum.getId())) { LOG.info("Forum Post received, adding..."); - // FIXME: Don't make blocking calls in event handlers - addNewPost(pe.getForumPostHeader()); + final ForumPostHeader fph = pe.getForumPostHeader(); + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + synchronized (this) { + if (fph.getTimestamp() > newestTimeStamp.get()) + newestTimeStamp.set(fph.getTimestamp()); + } + listener.onExternalEntryAdded(fph); + } + }); } } else if (e instanceof GroupRemovedEvent) { GroupRemovedEvent s = (GroupRemovedEvent) e; @@ -133,46 +136,37 @@ public class ForumControllerImpl extends DbControllerImpl } } - private void addNewPost(final ForumPostHeader h) { - if (forum == null) return; - runOnDbThread(new Runnable() { - @Override - public void run() { - if (!bodyCache.containsKey(h.getId())) { - try { - byte[] body = forumManager.getPostBody(h.getId()); - bodyCache.put(h.getId(), body); - } catch (DbException e) { - if (LOG.isLoggable(WARNING)) - LOG.log(WARNING, e.toString(), e); - return; - } - } + /** + * This should only be run from the DbThread. + * + * @throws DbException + */ + private void loadForum(GroupId groupId) throws DbException { + // Get Forum + long now = System.currentTimeMillis(); + forum = forumManager.getForum(groupId); + long duration = System.currentTimeMillis() - now; + if (LOG.isLoggable(INFO)) + LOG.info("Loading forum took " + duration + + " ms"); - tree.add(h); - forumEntries = null; - // FIXME we should not need to calculate the index here - // the index is essentially stored in two different locations - int i = 0; - for (ForumEntry entry : getForumEntries()) { - if (entry.getMessageId().equals(h.getId())) { - if (localAuthor != null && localAuthor.equals(h.getAuthor())) { - addLocalEntry(i, entry); - } else { - addForeignEntry(i, entry); - } - } - i++; - } - } - }); + // Get First Identity + now = System.currentTimeMillis(); + localAuthor = + identityManager.getLocalAuthors().iterator() + .next(); + duration = System.currentTimeMillis() - now; + if (LOG.isLoggable(INFO)) + LOG.info("Loading author took " + duration + + " ms"); } /** * This should only be run from the DbThread. + * * @throws DbException */ - private void loadPosts() throws DbException { + private Collection<ForumPostHeader> loadHeaders() throws DbException { if (forum == null) throw new RuntimeException("Forum has not been initialized"); @@ -180,59 +174,71 @@ public class ForumControllerImpl extends DbControllerImpl long now = System.currentTimeMillis(); Collection<ForumPostHeader> headers = forumManager.getPostHeaders(forum.getId()); - tree.add(headers); long duration = System.currentTimeMillis() - now; if (LOG.isLoggable(INFO)) LOG.info("Loading headers took " + duration + " ms"); + return headers; + } + /** + * This should only be run from the DbThread. + * + * @throws DbException + */ + private void loadBodies(Collection<ForumPostHeader> headers) + throws DbException { // Get Bodies - now = System.currentTimeMillis(); + long now = System.currentTimeMillis(); for (ForumPostHeader header : headers) { if (!bodyCache.containsKey(header.getId())) { byte[] body = forumManager.getPostBody(header.getId()); bodyCache.put(header.getId(), body); } } - duration = System.currentTimeMillis() - now; + long duration = System.currentTimeMillis() - now; if (LOG.isLoggable(INFO)) LOG.info("Loading bodies took " + duration + " ms"); } + private List<ForumEntry> buildForumEntries( + Collection<ForumPostHeader> headers) { + List<ForumEntry> entries = new ArrayList<>(); + for (ForumPostHeader h : headers) { + byte[] body = bodyCache.get(h.getId()); + entries.add(new ForumEntry(h, StringUtils.fromUtf8(body))); + } + return entries; + } + + private synchronized void checkNewestTimeStamp( + Collection<ForumPostHeader> headers) { + for (ForumPostHeader h : headers) { + if (h.getTimestamp() > newestTimeStamp.get()) + newestTimeStamp.set(h.getTimestamp()); + } + } + @Override public void loadForum(final GroupId groupId, - final ResultHandler<Boolean> resultHandler) { + final ResultHandler<List<ForumEntry>> resultHandler) { runOnDbThread(new Runnable() { @Override public void run() { - LOG.info("Loading forum..."); + if (LOG.isLoggable(INFO)) + LOG.info("Loading forum..."); try { if (forum == null) { - // Get Forum - long now = System.currentTimeMillis(); - forum = forumManager.getForum(groupId); - long duration = System.currentTimeMillis() - now; - if (LOG.isLoggable(INFO)) - LOG.info("Loading forum took " + duration + - " ms"); - - // Get First Identity - now = System.currentTimeMillis(); - localAuthor = - identityManager.getLocalAuthors().iterator() - .next(); - duration = System.currentTimeMillis() - now; - if (LOG.isLoggable(INFO)) - LOG.info("Loading author took " + duration + - " ms"); - - // Get Forum Posts and Bodies - loadPosts(); + loadForum(groupId); } - resultHandler.onResult(true); + // Get Forum Posts and Bodies + Collection<ForumPostHeader> headers = loadHeaders(); + checkNewestTimeStamp(headers); + loadBodies(headers); + resultHandler.onResult(buildForumEntries(headers)); } catch (DbException e) { if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e); - resultHandler.onResult(false); + resultHandler.onResult(null); } } }); @@ -245,31 +251,21 @@ public class ForumControllerImpl extends DbControllerImpl } @Override - public List<ForumEntry> getForumEntries() { - if (forumEntries != null) { - return forumEntries; - } - Collection<ForumPostHeader> headers = getHeaders(); - List<ForumEntry> entries = new ArrayList<>(); - Stack<MessageId> idStack = new Stack<>(); - - for (ForumPostHeader h : headers) { - if (h.getParentId() == null) { - idStack.clear(); - } else if (idStack.isEmpty() || - !idStack.contains(h.getParentId())) { - idStack.push(h.getParentId()); - } else if (!h.getParentId().equals(idStack.peek())) { - do { - idStack.pop(); - } while (!h.getParentId().equals(idStack.peek())); + public void loadPost(final ForumPostHeader header, + final ResultHandler<ForumEntry> resultHandler) { + runOnDbThread(new Runnable() { + @Override + public void run() { + LOG.info("Loading post..."); + try { + loadBodies(Collections.singletonList(header)); + resultHandler.onResult(new ForumEntry(header, StringUtils + .fromUtf8(bodyCache.get(header.getId())))); + } catch (DbException e) { + e.printStackTrace(); + } } - byte[] body = bodyCache.get(h.getId()); - entries.add(new ForumEntry(h, StringUtils.fromUtf8(body), - idStack.size())); - } - forumEntries = entries; - return entries; + }); } @Override @@ -307,7 +303,7 @@ public class ForumControllerImpl extends DbControllerImpl try { long now = System.currentTimeMillis(); for (ForumEntry fe : forumEntries) { - forumManager.setReadFlag(fe.getMessageId(), true); + forumManager.setReadFlag(fe.getId(), true); } long duration = System.currentTimeMillis() - now; if (LOG.isLoggable(INFO)) @@ -321,77 +317,67 @@ public class ForumControllerImpl extends DbControllerImpl } @Override - public void createPost(byte[] body) { - createPost(body, null); + public void createPost(byte[] body, + ResultHandler<ForumPost> resultHandler) { + createPost(body, null, resultHandler); } @Override - public void createPost(final byte[] body, final MessageId parentId) { + public void createPost(final byte[] body, final MessageId parentId, + final ResultHandler<ForumPost> resultHandler) { cryptoExecutor.execute(new Runnable() { @Override public void run() { + if (LOG.isLoggable(INFO)) + LOG.info("create post.."); long timestamp = System.currentTimeMillis(); - long newestTimeStamp = 0; - Collection<ForumPostHeader> headers = getHeaders(); - if (headers != null) { - for (ForumPostHeader h : headers) { - if (h.getTimestamp() > newestTimeStamp) - newestTimeStamp = h.getTimestamp(); - } - } - // Don't use an earlier timestamp than the newest post - if (timestamp < newestTimeStamp) { - timestamp = newestTimeStamp; - } + // FIXME next two lines Synchronized ? + // Only reading the atomic value, and even if it is changed + // between the first and second get, the condition will hold + if (timestamp < newestTimeStamp.get()) + timestamp = newestTimeStamp.get(); ForumPost p; try { KeyParser keyParser = crypto.getSignatureKeyParser(); byte[] b = localAuthor.getPrivateKey(); PrivateKey authorKey = keyParser.parsePrivateKey(b); p = forumPostFactory.createPseudonymousPost( - forum.getId(), timestamp, parentId, - localAuthor, "text/plain", body, - authorKey); + forum.getId(), timestamp, parentId, localAuthor, + "text/plain", body, authorKey); } catch (GeneralSecurityException | FormatException e) { throw new RuntimeException(e); } bodyCache.put(p.getMessage().getId(), body); - storePost(p); - // FIXME: Don't make DB calls on the crypto executor - addNewPost(p); - } - }); - } - - private void addLocalEntry(final int index, final ForumEntry entry) { - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - listener.addLocalEntry(index, entry); - } - }); - } - - private void addForeignEntry(final int index, final ForumEntry entry) { - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - listener.addForeignEntry(index, entry); + resultHandler.onResult(p); } }); } - private void storePost(final ForumPost p) { + public void storePost(final ForumPost p, + final ResultHandler<ForumEntry> resultHandler) { runOnDbThread(new Runnable() { @Override public void run() { try { + if (LOG.isLoggable(INFO)) + LOG.info("Store post..."); long now = System.currentTimeMillis(); forumManager.addLocalPost(p); long duration = System.currentTimeMillis() - now; if (LOG.isLoggable(INFO)) LOG.info( "Storing message took " + duration + " ms"); + + ForumPostHeader h = + new ForumPostHeader(p.getMessage().getId(), + p.getParent(), + p.getMessage().getTimestamp(), + p.getAuthor(), VERIFIED, + true); + + resultHandler.onResult(new ForumEntry(h, StringUtils + .fromUtf8(bodyCache.get(p.getMessage().getId())))); + } catch (DbException e) { if (LOG.isLoggable(WARNING)) LOG.log(WARNING, e.toString(), e); @@ -400,16 +386,4 @@ public class ForumControllerImpl extends DbControllerImpl }); } - private void addNewPost(final ForumPost p) { - ForumPostHeader h = - new ForumPostHeader(p.getMessage().getId(), p.getParent(), - p.getMessage().getTimestamp(), p.getAuthor(), VERIFIED, - false); - addNewPost(h); - } - - private Collection<ForumPostHeader> getHeaders() { - return tree.depthFirstOrder(); - } - } diff --git a/briar-android/src/org/briarproject/android/forum/ForumEntry.java b/briar-android/src/org/briarproject/android/forum/ForumEntry.java index f809d72394ddf9144ff1fc0642b32d34459673dd..889ef9802e5be778d8210707903ff99a7b74bb2f 100644 --- a/briar-android/src/org/briarproject/android/forum/ForumEntry.java +++ b/briar-android/src/org/briarproject/android/forum/ForumEntry.java @@ -1,33 +1,36 @@ package org.briarproject.android.forum; +import org.briarproject.api.clients.MessageTree; import org.briarproject.api.forum.ForumPostHeader; import org.briarproject.api.identity.Author; import org.briarproject.api.identity.Author.Status; -import org.briarproject.api.identity.AuthorId; import org.briarproject.api.sync.MessageId; -public class ForumEntry { +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 int level; private final long timestamp; private final Author author; private Status status; + private int level = LEVEL_UNDEFINED; private boolean isShowingDescendants = true; private boolean isRead = true; - ForumEntry(ForumPostHeader h, String text, int level) { - this(h.getId(), text, level, h.getTimestamp(), h.getAuthor(), + ForumEntry(ForumPostHeader h, String text) { + this(h.getId(), h.getParentId(), text, h.getTimestamp(), h.getAuthor(), h.getAuthorStatus()); this.isRead = h.isRead(); } - public ForumEntry(MessageId messageId, String text, int level, + public ForumEntry(MessageId messageId, MessageId parentId, String text, long timestamp, Author author, Status status) { this.messageId = messageId; + this.parentId = parentId; this.text = text; - this.level = level; this.timestamp = timestamp; this.author = author; this.status = status; @@ -41,6 +44,16 @@ public class ForumEntry { return level; } + @Override + public MessageId getId() { + return messageId; + } + + @Override + public MessageId getParentId() { + return parentId; + } + public long getTimestamp() { return timestamp; } @@ -57,6 +70,10 @@ public class ForumEntry { return isShowingDescendants; } + void setLevel(int level) { + this.level = level; + } + void setShowingDescendants(boolean showingDescendants) { this.isShowingDescendants = showingDescendants; } diff --git a/briar-android/src/org/briarproject/android/forum/ForumTestControllerImpl.java b/briar-android/src/org/briarproject/android/forum/ForumTestControllerImpl.java deleted file mode 100644 index a5b89405bf703b0bcf619a9226f58b371f4fb840..0000000000000000000000000000000000000000 --- a/briar-android/src/org/briarproject/android/forum/ForumTestControllerImpl.java +++ /dev/null @@ -1,175 +0,0 @@ -package org.briarproject.android.forum; - -import org.briarproject.android.controller.handler.ResultHandler; -import org.briarproject.api.UniqueId; -import org.briarproject.api.forum.Forum; -import org.briarproject.api.identity.Author; -import org.briarproject.api.identity.AuthorFactory; -import org.briarproject.api.sync.GroupId; -import org.briarproject.api.sync.MessageId; - -import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.logging.Logger; - -import javax.inject.Inject; - -import static org.briarproject.api.identity.Author.Status.UNVERIFIED; - -public class ForumTestControllerImpl implements ForumController { - - @Inject - AuthorFactory authorFactory; - - private static final Logger LOG = - Logger.getLogger(ForumControllerImpl.class.getName()); - - private final Author[] AUTHORS = { - authorFactory.createAuthor("Guðmundur", new byte[42]), - authorFactory.createAuthor("Jónas", new byte[42]), - authorFactory.createAuthor( - "Geir Þorsteinn GÃsli Máni Halldórsson Guðjónsson Mogensen", - new byte[42]), - authorFactory.createAuthor("Baldur Friðrik", new byte[42]), - authorFactory.createAuthor("Anna KatrÃn", new byte[42]), - authorFactory.createAuthor("Þór", new byte[42]), - authorFactory.createAuthor("Anna Þorbjörg", new byte[42]), - authorFactory.createAuthor("Guðrún", new byte[42]), - authorFactory.createAuthor("Helga", new byte[42]), - authorFactory.createAuthor("Haraldur", new byte[42]) - }; - - private final static String SAGA = - "Það er upphaf á sögu þessari að Hákon konungur " + - "Aðalsteinsfóstri réð fyrir Noregi og var þetta á ofanverðum " + - "hans dögum. Þorkell hét maður; hann var kallaður skerauki; " + - "hann bjó à Súrnadal og var hersir að nafnbót. Hann átti sér " + - "konu er Ãsgerður hét og sonu þrjá barna; hét einn Ari, annar " + - "GÃsli, þriðji Þorbjörn, hann var þeirra yngstur, og uxu allir " + - "upp heima þar. " + - "Maður er nefndur Ãsi; hann bjó à firði er Fibuli heitir á " + - "Norðmæri; kona hans hét Ingigerður en Ingibjörg dóttir. Ari, " + - "sonur Þorkels Sýrdæls, biður hennar og var hún honum gefin " + - "með miklu fé. Kolur hét þræll er à brott fór með henni."; - - private ForumEntry[] forumEntries; - - @Inject - ForumTestControllerImpl() { - - } - - private void textRandomize(SecureRandom random, int[] i) { - for (int e = 0; e < forumEntries.length; e++) { - // select a random white-space for the cut-off - do { - i[e] = Math.abs(random.nextInt() % (SAGA.length())); - } while (SAGA.charAt(i[e]) != ' '); - } - } - - private int levelRandomize(SecureRandom random, int[] l) { - int maxl = 0; - int lastl = 0; - l[0] = 0; - for (int e = 1; e < forumEntries.length; e++) { - // select random level 1-10 - do { - l[e] = Math.abs(random.nextInt() % 10); - } while (l[e] > lastl + 1); - lastl = l[e]; - if (lastl > maxl) - maxl = lastl; - } - return maxl; - } - - @Override - public void loadForum(GroupId groupId, - ResultHandler<Boolean> resultHandler) { - SecureRandom random = new SecureRandom(); - forumEntries = new ForumEntry[100]; - // string cut off index - int[] i = new int[forumEntries.length]; - // entry discussion level - int[] l = new int[forumEntries.length]; - - textRandomize(random, i); - int maxLevel; - // make sure we get a deep discussion - do { - maxLevel = levelRandomize(random, l); - } while (maxLevel < 6); - for (int e = 0; e < forumEntries.length; e++) { - int authorIndex = Math.abs(random.nextInt() % AUTHORS.length); - long timestamp = - System.currentTimeMillis() - Math.abs(random.nextInt()); - byte[] b = new byte[UniqueId.LENGTH]; - random.nextBytes(b); - forumEntries[e] = - new ForumEntry(new MessageId(b), SAGA.substring(0, i[e]), - l[e], timestamp, AUTHORS[authorIndex], UNVERIFIED); - } - LOG.info("forum entries: " + forumEntries.length); - resultHandler.onResult(true); - } - - @Override - public Forum getForum() { - return null; - } - - @Override - public List<ForumEntry> getForumEntries() { - return forumEntries == null ? null : - new ArrayList<>(Arrays.asList(forumEntries)); - } - - @Override - public void unsubscribe(ResultHandler<Boolean> resultHandler) { - - } - - @Override - public void entryRead(ForumEntry forumEntry) { - - } - - @Override - public void entriesRead(Collection<ForumEntry> messageIds) { - - } - - @Override - public void createPost(byte[] body) { - - } - - @Override - public void createPost(byte[] body, MessageId parentId) { - - } - - @Override - public void onActivityCreate() { - - } - - @Override - public void onActivityResume() { - - } - - @Override - public void onActivityPause() { - - } - - @Override - public void onActivityDestroy() { - - } -} diff --git a/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java b/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java new file mode 100644 index 0000000000000000000000000000000000000000..175ce5a162828b9c0c0cdfab00ac3add46ff12f9 --- /dev/null +++ b/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java @@ -0,0 +1,450 @@ +package org.briarproject.android.forum; + +import android.animation.Animator; +import android.animation.ArgbEvaluator; +import android.animation.ValueAnimator; +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.os.Build; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +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 java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Stack; + +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, + LinearLayoutManager layoutManager) { + this.ctx = ctx; + this.listener = listener; + this.layoutManager = layoutManager; + } + + ForumEntry getReplyEntry() { + return replyEntry; + } + + private void setForumEntryLevels() { + Stack<MessageId> idStack = new Stack<>(); + for (ForumEntry forumEntry : forumEntries) { + if (forumEntry.getParentId() == null) { + idStack.clear(); + } else if (idStack.isEmpty() || + !idStack.contains(forumEntry.getParentId())) { + idStack.push(forumEntry.getParentId()); + } else if (!forumEntry.getParentId().equals(idStack.peek())) { + do { + idStack.pop(); + } while (!forumEntry.getParentId().equals(idStack.peek())); + } + forumEntry.setLevel(idStack.size()); + } + } + + void setEntries(List<ForumEntry> entries) { + forumEntries.clear(); + forumEntries.addAll(entries); + setForumEntryLevels(); + notifyItemRangeInserted(0, entries.size()); + } + + void addEntry(ForumEntry entry) { + boolean isShowingDescendants = false; + forumEntries.add(entry); + setForumEntryLevels(); + if (entry.getLevel() > 0) { + // update parent and make sure descendants are visible + // Note that the parent's visibility is guaranteed (otherwise + // the reply button would not be 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()) { + isShowingDescendants = true; + showDescendants(higherEntry); + } + notifyItemChanged(getVisiblePos(higherEntry)); + break; + } + } + } + if (!isShowingDescendants) { + int visiblePos = getVisiblePos(entry); + notifyItemInserted(visiblePos); + } + addedEntry = entry; + } + + void scrollToEntry(ForumEntry entry) { + layoutManager + .scrollToPositionWithOffset(getVisiblePos(entry), 0); + } + + private boolean hasDescendants(ForumEntry forumEntry) { + int i = forumEntries.indexOf(forumEntry); + if (i >= 0 && i < forumEntries.size() - 1) { + if (forumEntries.get(i + 1).getLevel() > + forumEntry.getLevel()) { + return true; + } + } + return false; + } + + private boolean hasVisibleDescendants(ForumEntry forumEntry) { + int visiblePos = getVisiblePos(forumEntry); + int levelLimit = forumEntry.getLevel(); + if (visiblePos + 1 < getItemCount()) { + ForumEntry entry = getVisibleEntry(visiblePos + 1); + if (entry == null || entry.getLevel() > levelLimit) + return true; + } + return false; + } + + private int getReplyCount(ForumEntry entry) { + int counter = 0; + int pos = forumEntries.indexOf(entry); + if (pos >= 0) { + int ancestorLvl = forumEntries.get(pos).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(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()) { + if (Build.VERSION.SDK_INT >= 11) { + // 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); + } + + + @Nullable + ForumEntry getVisibleEntry(int position) { + int levelLimit = UNDEFINED; + for (ForumEntry forumEntry : forumEntries) { + if (levelLimit >= 0) { + if (forumEntry.getLevel() > levelLimit) { + continue; + } + levelLimit = UNDEFINED; + } + if (!forumEntry.isShowingDescendants()) { + levelLimit = forumEntry.getLevel(); + } + if (position-- == 0) { + return forumEntry; + } + } + return null; + } + + @TargetApi(11) + 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(); + } + + @Override + public NestedForumHolder onCreateViewHolder(ViewGroup parent, + int viewType) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.list_item_forum_post, parent, false); + 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 (hasDescendants(entry)) { + ui.chevron.setVisibility(VISIBLE); + if (hasVisibleDescendants(entry)) { + 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)); + if (Build.VERSION.SDK_INT >= 11) { + 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); + } + }); + } + + private int getVisiblePos(ForumEntry sEntry) { + int visibleCounter = 0; + int levelLimit = UNDEFINED; + for (ForumEntry fEntry : forumEntries) { + if (levelLimit >= 0) { + if (fEntry.getLevel() > levelLimit) { + 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 { + + final TextView textView, lvlText, repliesText; + final AuthorView author; + final View[] lvls; + final View chevron, replyButton; + final ViewGroup cell; + final View topDivider; + + 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/util/NestedTreeList.java b/briar-android/src/org/briarproject/android/util/NestedTreeList.java new file mode 100644 index 0000000000000000000000000000000000000000..23ba02298d1bc36b162a29bb5257b32de83bb60b --- /dev/null +++ b/briar-android/src/org/briarproject/android/util/NestedTreeList.java @@ -0,0 +1,49 @@ +package org.briarproject.android.util; + +import org.briarproject.api.clients.MessageTree; +import org.briarproject.clients.MessageTreeImpl; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; + +/* This class is not thread safe */ +public class NestedTreeList<T extends MessageTree.MessageNode> + implements Iterable<T> { + + private final MessageTree<T> tree = new MessageTreeImpl<>(); + private List<T> depthFirstCollection = new ArrayList<>(); + + public void addAll(Collection<T> collection) { + tree.add(collection); + depthFirstCollection = new ArrayList<>(tree.depthFirstOrder()); + } + + public void add(T elem) { + tree.add(elem); + depthFirstCollection = new ArrayList<>(tree.depthFirstOrder()); + } + + public void clear() { + tree.clear(); + depthFirstCollection.clear(); + } + + public T get(int index) { + return depthFirstCollection.get(index); + } + + public int indexOf(T elem) { + return depthFirstCollection.indexOf(elem); + } + + public int size() { + return depthFirstCollection.size(); + } + + @Override + public Iterator<T> iterator() { + return depthFirstCollection.iterator(); + } +} 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 e61b557beb392dbfedac9dd9620f0fd5ade0417b..92023f7f9185fdfb103eb37c3dc350af95d6d9b1 100644 --- a/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java +++ b/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java @@ -50,6 +50,22 @@ public class ForumActivityTest { AUTHOR_1, AUTHOR_2, AUTHOR_3, AUTHOR_4, AUTHOR_5, AUTHOR_6 }; + private final static MessageId[] AUTHOR_IDS = new MessageId[AUTHORS.length]; + + static { + for (int i = 0; i < AUTHOR_IDS.length; i++) + AUTHOR_IDS[i] = new MessageId(TestUtils.getRandomId()); + } + + private final static MessageId[] PARENT_AUTHOR_IDS = { + null, + AUTHOR_IDS[0], + AUTHOR_IDS[1], + AUTHOR_IDS[2], + AUTHOR_IDS[0], + null + }; + /* 1 -> 2 @@ -64,7 +80,7 @@ public class ForumActivityTest { private TestForumActivity forumActivity; @Captor - private ArgumentCaptor<UiResultHandler<Boolean>> rc; + private ArgumentCaptor<UiResultHandler<List<ForumEntry>>> rc; @Before public void setUp() { @@ -82,9 +98,11 @@ public class ForumActivityTest { AuthorId authorId = new AuthorId(TestUtils.getRandomId()); byte[] publicKey = TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH); Author author = new Author(authorId, AUTHORS[i], publicKey); - forumEntries[i] = new ForumEntry( - new MessageId(TestUtils.getRandomId()), AUTHORS[i], - LEVELS[i], System.currentTimeMillis(), author, UNKNOWN); + forumEntries[i] = + new ForumEntry(AUTHOR_IDS[i], PARENT_AUTHOR_IDS[i], + AUTHORS[i], System.currentTimeMillis(), author, + UNKNOWN); + forumEntries[i].setLevel(LEVELS[i]); } return new ArrayList<>(Arrays.asList(forumEntries)); } @@ -93,13 +111,10 @@ public class ForumActivityTest { public void testNestedEntries() { ForumController mc = forumActivity.getController(); List<ForumEntry> dummyData = getDummyData(); - Mockito.when(mc.getForumEntries()).thenReturn(dummyData); - // Verify that the forum load is called once verify(mc, times(1)) .loadForum(Mockito.any(GroupId.class), rc.capture()); - rc.getValue().onResult(true); - verify(mc, times(1)).getForumEntries(); - ForumActivity.ForumAdapter adapter = forumActivity.getAdapter(); + rc.getValue().onResult(dummyData); + NestedForumAdapter adapter = forumActivity.getAdapter(); Assert.assertNotNull(adapter); // Cascade close assertEquals(6, adapter.getItemCount()); 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 43dc8998567c3098157617530bb90771df729a1f..d38e5a31f6dcf6adddb943297aad79fe40cdba24 100644 --- a/briar-android/test/java/org/briarproject/android/forum/TestForumActivity.java +++ b/briar-android/test/java/org/briarproject/android/forum/TestForumActivity.java @@ -15,7 +15,7 @@ public class TestForumActivity extends ForumActivity { return forumController; } - public ForumAdapter getAdapter() { + public NestedForumAdapter getAdapter() { return forumAdapter; } diff --git a/briar-api/src/org/briarproject/api/forum/ForumPostHeader.java b/briar-api/src/org/briarproject/api/forum/ForumPostHeader.java index ef08dc022fa32d758bca4d0280b8a6a541fa22f7..b98d23448ce6dd4ec3c43b164082b0e00e7c3d3a 100644 --- a/briar-api/src/org/briarproject/api/forum/ForumPostHeader.java +++ b/briar-api/src/org/briarproject/api/forum/ForumPostHeader.java @@ -1,12 +1,10 @@ package org.briarproject.api.forum; -import org.briarproject.api.clients.MessageTree; import org.briarproject.api.clients.PostHeader; import org.briarproject.api.identity.Author; import org.briarproject.api.sync.MessageId; -public class ForumPostHeader extends PostHeader - implements MessageTree.MessageNode { +public class ForumPostHeader extends PostHeader { public ForumPostHeader(MessageId id, MessageId parentId, long timestamp, Author author, Author.Status authorStatus, boolean read) { diff --git a/briar-core/src/org/briarproject/clients/MessageTreeImpl.java b/briar-core/src/org/briarproject/clients/MessageTreeImpl.java index 58e5c65a47ebd6f0498929f184684bbcfb7c8ffb..2c6eb1344a144d2f1955faba483e6c54e0d7f1a1 100644 --- a/briar-core/src/org/briarproject/clients/MessageTreeImpl.java +++ b/briar-core/src/org/briarproject/clients/MessageTreeImpl.java @@ -26,13 +26,13 @@ public class MessageTreeImpl<T extends MessageTree.MessageNode> }; @Override - public void clear() { + public synchronized void clear() { roots.clear(); nodeMap.clear(); } @Override - public void add(Collection<T> nodes) { + public synchronized void add(Collection<T> nodes) { // add all nodes to the node map for (T node : nodes) { nodeMap.put(node.getId(), new ArrayList<T>()); @@ -45,7 +45,7 @@ public class MessageTreeImpl<T extends MessageTree.MessageNode> } @Override - public void add(T node) { + public synchronized void add(T node) { add(Collections.singletonList(node)); } @@ -85,7 +85,7 @@ public class MessageTreeImpl<T extends MessageTree.MessageNode> } @Override - public void setComparator(Comparator<T> comparator) { + public synchronized void setComparator(Comparator<T> comparator) { this.comparator = comparator; // Sort all lists with the new comparator Collections.sort(roots, comparator); @@ -95,7 +95,7 @@ public class MessageTreeImpl<T extends MessageTree.MessageNode> } @Override - public Collection<T> depthFirstOrder() { + public synchronized Collection<T> depthFirstOrder() { List<T> orderedList = new ArrayList<T>(); for (T root : roots) { traverse(orderedList, root);