Skip to content
Snippets Groups Projects
Commit fd671930 authored by akwizgran's avatar akwizgran
Browse files

Merge branch '556-thread-safety-blocking-issues' into 'master'

Forum controller thread safety and tree safety

This branch solves the concurrent forum issues by code restructure and refactoring.

Closes #556 
Closes #552 

See merge request !262
parents ee989006 92f2e7b0
No related branches found
No related tags found
No related merge requests found
Showing
with 808 additions and 815 deletions
......@@ -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) {
......
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.ResultExceptionHandler;
import org.briarproject.android.controller.handler.ResultHandler;
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.api.sync.MessageId;
......@@ -13,12 +17,14 @@ import java.util.List;
public interface ForumController extends ActivityLifecycleController {
void loadForum(GroupId groupId, ResultHandler<Boolean> resultHandler);
void loadForum(GroupId groupId,
ResultExceptionHandler<List<ForumEntry>, DbException> resultHandler);
@Nullable
Forum getForum();
List<ForumEntry> getForumEntries();
void loadPost(ForumPostHeader header,
ResultExceptionHandler<ForumEntry, DbException> resultHandler);
void unsubscribe(ResultHandler<Boolean> resultHandler);
......@@ -26,14 +32,15 @@ public interface ForumController extends ActivityLifecycleController {
void entriesRead(Collection<ForumEntry> messageIds);
void createPost(byte[] body);
void createPost(byte[] body,
ResultExceptionHandler<ForumEntry, DbException> resultHandler);
void createPost(byte[] body, MessageId parentId);
void createPost(byte[] body, MessageId parentId,
ResultExceptionHandler<ForumEntry, DbException> resultHandler);
interface ForumPostListener {
void addLocalEntry(int index, ForumEntry entry);
void addForeignEntry(int index, ForumEntry entry);
@UiThread
void onExternalEntryAdded(ForumPostHeader header);
}
}
......@@ -4,9 +4,9 @@ import android.app.Activity;
import android.support.annotation.Nullable;
import org.briarproject.android.controller.DbControllerImpl;
import org.briarproject.android.controller.handler.ResultExceptionHandler;
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 +26,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,16 +34,16 @@ 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;
import static java.util.logging.Level.INFO;
import static java.util.logging.Level.WARNING;
import static org.briarproject.api.identity.Author.Status.VERIFIED;
import static org.briarproject.api.identity.Author.Status.OURSELVES;
public class ForumControllerImpl extends DbControllerImpl
implements ForumController, EventListener {
......@@ -69,12 +68,10 @@ 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 final 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 +108,18 @@ 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());
LOG.info("Forum post received, adding...");
final ForumPostHeader fph = pe.getForumPostHeader();
updateNewestTimestamp(fph.getTimestamp());
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
listener.onExternalEntryAdded(fph);
}
});
}
} else if (e instanceof GroupRemovedEvent) {
GroupRemovedEvent s = (GroupRemovedEvent) e;
......@@ -133,46 +135,33 @@ 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.getLocalAuthor();
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 +169,68 @@ 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 void updateNewestTimeStamp(Collection<ForumPostHeader> headers) {
for (ForumPostHeader h : headers) {
updateNewestTimestamp(h.getTimestamp());
}
}
@Override
public void loadForum(final GroupId groupId,
final ResultHandler<Boolean> resultHandler) {
final ResultExceptionHandler<List<ForumEntry>, DbException> resultHandler) {
runOnDbThread(new Runnable() {
@Override
public void run() {
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();
updateNewestTimeStamp(headers);
loadBodies(headers);
resultHandler.onResult(buildForumEntries(headers));
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
resultHandler.onResult(false);
resultHandler.onException(e);
}
}
});
......@@ -245,31 +243,23 @@ 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 ResultExceptionHandler<ForumEntry, DbException> 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) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
resultHandler.onException(e);
}
}
byte[] body = bodyCache.get(h.getId());
entries.add(new ForumEntry(h, StringUtils.fromUtf8(body),
idStack.size()));
}
forumEntries = entries;
return entries;
});
}
@Override
......@@ -307,7 +297,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,95 +311,73 @@ public class ForumControllerImpl extends DbControllerImpl
}
@Override
public void createPost(byte[] body) {
createPost(body, null);
public void createPost(byte[] body,
ResultExceptionHandler<ForumEntry, DbException> 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 ResultExceptionHandler<ForumEntry, DbException> resultHandler) {
cryptoExecutor.execute(new Runnable() {
@Override
public void run() {
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;
}
timestamp = Math.max(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);
storePost(p, resultHandler);
}
});
}
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);
}
});
}
private void storePost(final ForumPost p) {
private void storePost(final ForumPost p,
final ResultExceptionHandler<ForumEntry, DbException> resultHandler) {
runOnDbThread(new Runnable() {
@Override
public void run() {
try {
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");
LOG.info("Storing message took " + duration + " ms");
ForumPostHeader h =
new ForumPostHeader(p.getMessage().getId(),
p.getParent(),
p.getMessage().getTimestamp(),
p.getAuthor(), OURSELVES, 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);
resultHandler.onException(e);
}
}
});
}
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();
private void updateNewestTimestamp(long update) {
long newest = newestTimeStamp.get();
while (newest < update) {
if (newestTimeStamp.compareAndSet(newest, update)) return;
newest = newestTimeStamp.get();
}
}
}
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 {
/* 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 int level;
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;
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 +46,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 +72,10 @@ public class ForumEntry {
return isShowingDescendants;
}
public void setLevel(int level) {
this.level = level;
}
void setShowingDescendants(boolean showingDescendants) {
this.isShowingDescendants = showingDescendants;
}
......@@ -72,4 +91,12 @@ public class ForumEntry {
void setRead(boolean read) {
isRead = read;
}
public boolean hasDescendants() {
return descendantCount > 0;
}
public void setDescendantCount(int descendantCount) {
this.descendantCount = descendantCount;
}
}
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() {
}
}
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.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 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;
}
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(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();
}
@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 (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(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 {
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);
}
}
package org.briarproject.android.util;
import android.support.annotation.UiThread;
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;
@UiThread
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();
}
}
......@@ -7,7 +7,8 @@ import junit.framework.Assert;
import org.briarproject.BuildConfig;
import org.briarproject.TestUtils;
import org.briarproject.android.TestBriarApplication;
import org.briarproject.android.controller.handler.UiResultHandler;
import org.briarproject.android.controller.handler.UiResultExceptionHandler;
import org.briarproject.api.db.DbException;
import org.briarproject.api.identity.Author;
import org.briarproject.api.identity.AuthorId;
import org.briarproject.api.sync.GroupId;
......@@ -50,6 +51,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 +81,8 @@ public class ForumActivityTest {
private TestForumActivity forumActivity;
@Captor
private ArgumentCaptor<UiResultHandler<Boolean>> rc;
private ArgumentCaptor<UiResultExceptionHandler<List<ForumEntry>, DbException>>
rc;
@Before
public void setUp() {
......@@ -75,16 +93,17 @@ public class ForumActivityTest {
.withIntent(intent).create().resume().get();
}
private List<ForumEntry> getDummyData() {
ForumEntry[] forumEntries = new ForumEntry[6];
for (int i = 0; i < forumEntries.length; i++) {
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 +112,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());
......
......@@ -15,7 +15,7 @@ public class TestForumActivity extends ForumActivity {
return forumController;
}
public ForumAdapter getAdapter() {
public NestedForumAdapter getAdapter() {
return forumAdapter;
}
......
......@@ -16,6 +16,8 @@ public interface MessageTree<T extends MessageTree.MessageNode> {
interface MessageNode {
MessageId getId();
MessageId getParentId();
void setLevel(int level);
void setDescendantCount(int descendantCount);
long getTimestamp();
}
......
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) {
......
......@@ -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));
}
......@@ -77,15 +77,18 @@ public class MessageTreeImpl<T extends MessageTree.MessageNode>
}
private void traverse(List<T> list, T node) {
private void traverse(List<T> list, T node, int level) {
list.add(node);
for (T child : nodeMap.get(node.getId())) {
traverse(list, child);
List<T> children = nodeMap.get(node.getId());
node.setLevel(level);
node.setDescendantCount(children.size());
for (T child : children) {
traverse(list, child, level+1);
}
}
@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,10 +98,10 @@ 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);
traverse(orderedList, root, 0);
}
return Collections.unmodifiableList(orderedList);
}
......
......@@ -86,6 +86,16 @@ public class MessageTreeTest {
return parentId;
}
@Override
public void setLevel(int level) {
}
@Override
public void setDescendantCount(int descendantCount) {
}
@Override
public long getTimestamp() {
return timestamp;
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment