Merge branch '122-threaded-discussion-ui' into 'master'

122 threaded discussions

This branch contains the complete code for the nested forums (UI & back-end).

* This branch has an optional randomized set of dummy test data, uncomment one line in ForumActivity.java and then open up any forum.


See merge request !201
This commit is contained in:
Ernir Erlingsson
2016-05-31 16:06:22 +00:00
30 changed files with 1727 additions and 925 deletions

View File

@@ -129,16 +129,6 @@
/>
</activity>
<activity
android:name=".android.forum.ReadForumPostActivity"
android:label="@string/app_name"
android:parentActivityName=".android.NavDrawerActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".android.NavDrawerActivity"
/>
</activity>
<activity
android:name=".android.forum.ShareForumActivity"
android:label="@string/forums_share_toolbar_header"
@@ -159,17 +149,6 @@
/>
</activity>
<activity
android:name=".android.forum.WriteForumPostActivity"
android:label="@string/app_name"
android:parentActivityName=".android.NavDrawerActivity"
android:windowSoftInputMode="stateVisible">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".android.NavDrawerActivity"
/>
</activity>
<activity
android:name=".android.identity.CreateIdentityActivity"
android:label="@string/new_identity_title"

View File

@@ -0,0 +1,4 @@
<vector android:height="24dp" android:viewportHeight="48.0"
android:viewportWidth="48.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M9.1,19.3l14.9,11.8l14.9,-11.8l-1.9,-2.4l-13,10.4l-13,-10.4z"/>
</vector>

View File

@@ -0,0 +1,4 @@
<vector android:height="24dp" android:viewportHeight="48.0"
android:viewportWidth="48.0" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M38.9,28.7l-14.9,-11.8l-14.9,11.8l1.9,2.4l13,-10.4l13,10.4z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/window_background"/>
<stroke
android:width="2dp"
android:color="@color/forum_discussion_nested_line"/>
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true" android:drawable="@drawable/chevron48dp_down"/>
<item android:drawable="@drawable/chevron48dp_up"/>
</selector>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<org.briarproject.android.util.BriarRecyclerView
android:id="@+id/forum_discussion_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
app:scrollToEnd="false"/>
<include
layout="@layout/text_input_field"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>

View File

@@ -0,0 +1,161 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/forum_cell"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="match_parent">
<View
android:id="@+id/nested_line_1"
style="@style/DiscussionLevelIndicator"
android:layout_width="@dimen/forum_nested_line_width"
android:layout_height="match_parent"
android:visibility="gone"
tools:visibility="showingDescendants"/>
<View
android:id="@+id/nested_line_2"
style="@style/DiscussionLevelIndicator"
android:layout_width="@dimen/forum_nested_line_width"
android:layout_height="match_parent"
android:layout_toRightOf="@id/nested_line_1"
android:visibility="gone"/>
<View
android:id="@+id/nested_line_3"
style="@style/DiscussionLevelIndicator"
android:layout_width="@dimen/forum_nested_line_width"
android:layout_height="match_parent"
android:layout_toRightOf="@id/nested_line_2"
android:visibility="gone"/>
<View
android:id="@+id/nested_line_4"
style="@style/DiscussionLevelIndicator"
android:layout_width="@dimen/forum_nested_line_width"
android:layout_height="match_parent"
android:layout_toRightOf="@id/nested_line_3"
android:visibility="gone"/>
<View
android:id="@+id/nested_line_5"
style="@style/DiscussionLevelIndicator"
android:layout_width="@dimen/forum_nested_line_width"
android:layout_height="match_parent"
android:layout_toRightOf="@id/nested_line_4"
android:visibility="gone"/>
<TextView
android:id="@+id/nested_line_text"
android:layout_width="@dimen/forum_nested_indicator"
android:layout_height="@dimen/forum_nested_indicator"
android:layout_centerInParent="true"
android:background="@drawable/level_indicator_circle"
android:gravity="center"
android:textSize="@dimen/text_size_small"
android:visibility="gone"
/>
</RelativeLayout>
<RelativeLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/margin_medium"
android:layout_weight="1">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/margin_small"
android:layout_marginLeft="@dimen/margin_medium"
android:layout_marginRight="@dimen/margin_medium"
android:layout_marginTop="@dimen/margin_medium"
android:textIsSelectable="true"
android:textSize="@dimen/text_size_medium"
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."/>
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/avatar"
android:layout_width="@dimen/forum_avatar_size"
android:layout_height="@dimen/forum_avatar_size"
android:layout_alignLeft="@id/text"
android:layout_below="@id/text"
android:layout_marginRight="@dimen/margin_small"
android:layout_marginTop="@dimen/margin_small"
android:src="@drawable/ic_launcher"
app:civ_border_color="@color/briar_primary"
app:civ_border_width="@dimen/avatar_border_width"
tools:src="@drawable/ic_launcher"
/>
<ImageView
android:id="@+id/chevron"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_below="@id/text"
android:layout_marginRight="@dimen/margin_medium"
android:layout_marginTop="@dimen/margin_small"
android:clickable="true"
android:src="@drawable/selector_chevron"
android:tint="@color/briar_button_positive"
/>
<TextView
android:id="@+id/btn_reply"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/text"
android:layout_marginRight="@dimen/margin_medium"
android:layout_toLeftOf="@id/chevron"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:padding="@dimen/margin_medium"
android:text="@string/btn_reply"
android:textColor="@color/briar_button_positive"
android:textSize="@dimen/text_size_tiny"/>
<TextView
android:id="@+id/replies"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@id/btn_reply"
android:layout_toLeftOf="@id/btn_reply"
android:padding="@dimen/margin_medium"
android:textSize="@dimen/text_size_tiny"
tools:text="2 replies"/>
<TextView
android:id="@+id/date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBaseline="@id/replies"
android:layout_toLeftOf="@id/replies"
android:layout_toRightOf="@id/avatar"
android:ellipsize="end"
android:maxLines="1"
android:textSize="@dimen/text_size_tiny"
tools:text="09:09 John Smith"/>
<View
android:id="@+id/bottom_divider"
style="@style/Divider.ForumList"
android:layout_width="match_parent"
android:layout_height="@dimen/margin_separator"
android:layout_alignLeft="@id/text"
android:layout_below="@id/btn_reply"/>
</RelativeLayout>
</LinearLayout>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:id="@+id/text_input_container"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/button_bar_background"
android:elevation="@dimen/margin_tiny"
android:orientation="horizontal"
android:paddingLeft="@dimen/margin_large"
android:paddingStart="@dimen/margin_large">
<EditText
android:id="@+id/input_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:layout_weight="1"
android:inputType="textMultiLine|textCapSentences"
android:maxLines="5"/>
<ImageView
android:id="@+id/btn_send"
android:layout_width="38dp"
android:layout_height="38dp"
android:layout_margin="@dimen/margin_medium"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:contentDescription="@string/send"
android:onClick="sendMessage"
android:src="@drawable/social_send_now_white"
android:tint="@color/briar_primary"
/>
</LinearLayout>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="BriarRecyclerView">
<attr name="scrollToEnd" format="boolean" />
</declare-styleable>
</resources>

View File

@@ -43,4 +43,6 @@
<color name="spinner_border">#61000000</color> <!-- 38% Black -->
<color name="spinner_arrow">@color/briar_blue_dark</color>
<color name="forum_discussion_nested_line">#cfd2d4</color>
<color name="forum_cell_highlight">#ffffff</color>
</resources>

View File

@@ -41,5 +41,8 @@
<dimen name="message_bubble_margin_tail">14dp</dimen>
<dimen name="message_bubble_margin_non_tail">51dp</dimen>
<dimen name="message_bubble_timestamp_margin">15dp</dimen>
<dimen name="forum_nested_line_width">2dp</dimen>
<dimen name="forum_nested_indicator">24dp</dimen>
<dimen name="forum_avatar_size">20dp</dimen>
</resources>

View File

@@ -198,6 +198,17 @@
<string name="introduction_success_title">Introduced contact was added</string>
<string name="introduction_success_text">You have been introduced to %1$s.</string>
<!-- Forum -->
<string name="btn_reply">REPLY</string>
<plurals name="message_replies">
<item quantity="one">%1$d reply</item>
<item quantity="other">%1$d replies</item>
</plurals>
<string name="forum_new_entry_posted">Forum entry posted</string>
<string name="forum_new_entry_received">New forum entry</string>
<string name="forum_new_message_hint">New Entry</string>
<string name="forum_message_reply_hint">New Reply</string>
<!-- Dialogs -->
<string name="dialog_title_lost_password">Lost Password</string>
<string name="dialog_message_lost_password">Password recovery is not possible. Do you want to delete your account?\n\nCaution: This will permanently delete your identities, contacts and messages</string>
@@ -225,4 +236,6 @@
<!-- Progress titles -->
<string name="progress_title_logout">Signing out of Briar..</string>
<string name="progress_title_please_wait">Please wait..</string>
</resources>

View File

@@ -140,4 +140,9 @@
<item name="android:layout_marginBottom">16dp</item>
</style>
<style name="DiscussionLevelIndicator">
<item name="android:layout_marginLeft">4dp</item>
<item name="android:background">?android:attr/listDivider</item>
</style>
</resources>

View File

@@ -8,10 +8,8 @@ import org.briarproject.android.forum.ContactSelectorFragment;
import org.briarproject.android.forum.CreateForumActivity;
import org.briarproject.android.forum.ForumActivity;
import org.briarproject.android.forum.ForumSharingStatusActivity;
import org.briarproject.android.forum.ReadForumPostActivity;
import org.briarproject.android.forum.ShareForumActivity;
import org.briarproject.android.forum.ShareForumMessageFragment;
import org.briarproject.android.forum.WriteForumPostActivity;
import org.briarproject.android.fragment.BaseFragment;
import org.briarproject.android.identity.CreateIdentityActivity;
import org.briarproject.android.introduction.IntroductionActivity;
@@ -54,16 +52,12 @@ public interface ActivityComponent {
void inject(AvailableForumsActivity activity);
void inject(WriteForumPostActivity activity);
void inject(CreateForumActivity activity);
void inject(ShareForumActivity activity);
void inject(ForumSharingStatusActivity activity);
void inject(ReadForumPostActivity activity);
void inject(ForumActivity activity);
void inject(SettingsActivity activity);

View File

@@ -12,6 +12,8 @@ import org.briarproject.android.controller.ConfigController;
import org.briarproject.android.controller.ConfigControllerImpl;
import org.briarproject.android.controller.DbController;
import org.briarproject.android.controller.DbControllerImpl;
import org.briarproject.android.forum.ForumController;
import org.briarproject.android.forum.ForumControllerImpl;
import org.briarproject.android.controller.NavDrawerController;
import org.briarproject.android.controller.NavDrawerControllerImpl;
import org.briarproject.android.controller.PasswordController;
@@ -21,6 +23,7 @@ import org.briarproject.android.controller.SetupControllerImpl;
import org.briarproject.android.controller.TransportStateListener;
import org.briarproject.android.forum.ContactSelectorFragment;
import org.briarproject.android.forum.ForumListFragment;
import org.briarproject.android.forum.ForumTestControllerImpl;
import org.briarproject.android.forum.ShareForumMessageFragment;
import org.briarproject.android.fragment.BaseFragment;
import org.briarproject.android.introduction.ContactChooserFragment;
@@ -98,6 +101,22 @@ public class ActivityModule {
return dbController;
}
@ActivityScope
@Provides
protected ForumController provideForumController(
ForumControllerImpl forumController) {
activity.addLifecycleController(forumController);
return forumController;
}
@Named("ForumTestController")
@ActivityScope
@Provides
protected ForumController provideForumTestController(
ForumTestControllerImpl forumController) {
return forumController;
}
@ActivityScope
@Provides
protected NavDrawerController provideNavDrawerController(

View File

@@ -4,6 +4,7 @@ import android.app.Application;
import org.briarproject.android.api.AndroidNotificationManager;
import org.briarproject.android.api.ReferenceManager;
import org.briarproject.android.forum.ForumPersistentData;
import org.briarproject.api.crypto.CryptoComponent;
import org.briarproject.api.crypto.PublicKey;
import org.briarproject.api.crypto.SecretKey;
@@ -136,4 +137,10 @@ public class AppModule {
eventBus.addListener(notificationManager);
return notificationManager;
}
@Provides
@Singleton
ForumPersistentData provideForumPersistence(ForumPersistentData fpd) {
return fpd;
}
}

View File

@@ -3,18 +3,23 @@ package org.briarproject.android.forum;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
import android.support.annotation.NonNull;
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.text.format.DateUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
@@ -22,76 +27,61 @@ import org.briarproject.R;
import org.briarproject.android.ActivityComponent;
import org.briarproject.android.BriarActivity;
import org.briarproject.android.api.AndroidNotificationManager;
import org.briarproject.android.util.ListLoadingProgressBar;
import org.briarproject.api.db.DbException;
import org.briarproject.api.db.NoSuchGroupException;
import org.briarproject.api.db.NoSuchMessageException;
import org.briarproject.api.event.Event;
import org.briarproject.api.event.EventBus;
import org.briarproject.api.event.EventListener;
import org.briarproject.api.event.GroupRemovedEvent;
import org.briarproject.api.event.MessageStateChangedEvent;
import org.briarproject.api.forum.Forum;
import org.briarproject.api.forum.ForumManager;
import org.briarproject.api.forum.ForumPostHeader;
import org.briarproject.api.identity.Author;
import org.briarproject.android.controller.handler.ResultHandler;
import org.briarproject.android.controller.handler.UiResultHandler;
import org.briarproject.android.util.BriarRecyclerView;
import org.briarproject.android.util.CustomAnimations;
import org.briarproject.api.sync.GroupId;
import org.briarproject.api.sync.MessageId;
import org.briarproject.util.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import javax.inject.Inject;
import im.delight.android.identicons.IdenticonDrawable;
import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP;
import static android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP;
import static android.support.design.widget.Snackbar.LENGTH_LONG;
import static android.view.Gravity.CENTER;
import static android.view.Gravity.CENTER_HORIZONTAL;
import static android.view.View.GONE;
import static android.view.View.INVISIBLE;
import static android.view.View.VISIBLE;
import static android.widget.LinearLayout.VERTICAL;
import static android.widget.Toast.LENGTH_SHORT;
import static java.util.logging.Level.INFO;
import static java.util.logging.Level.WARNING;
import static org.briarproject.android.forum.ReadForumPostActivity.RESULT_PREV_NEXT;
import static org.briarproject.android.util.CommonLayoutParams.MATCH_MATCH;
import static org.briarproject.android.util.CommonLayoutParams.MATCH_WRAP_1;
import static org.briarproject.api.sync.ValidationManager.State.DELIVERED;
public class ForumActivity extends BriarActivity implements EventListener,
OnItemClickListener {
public class ForumActivity extends BriarActivity implements
ForumController.ForumPostListener {
public static final String FORUM_NAME = "briar.FORUM_NAME";
public static final String MIN_TIMESTAMP = "briar.MIN_TIMESTAMP";
private static final int REQUEST_READ = 2;
private static final int REQUEST_FORUM_SHARED = 3;
private static final Logger LOG =
Logger.getLogger(ForumActivity.class.getName());
@Inject protected AndroidNotificationManager notificationManager;
private Map<MessageId, byte[]> bodyCache = new HashMap<>();
private TextView empty = null;
private ForumAdapter adapter = null;
private ListView list = null;
private ListLoadingProgressBar loading = null;
public static final String FORUM_NAME = "briar.FORUM_NAME";
public static final String MIN_TIMESTAMP = "briar.MIN_TIMESTAMP";
private static final int REQUEST_FORUM_SHARED = 3;
@Inject
protected AndroidNotificationManager notificationManager;
// uncomment the next line for a test component with dummy data
// @Named("ForumTestController")
@Inject
protected ForumController forumController;
private BriarRecyclerView recyclerView;
private EditText textInput;
private ViewGroup inputContainer;
private LinearLayoutManager linearLayoutManager;
// Fields that are accessed from background threads must be volatile
@Inject protected volatile ForumManager forumManager;
@Inject protected volatile EventBus eventBus;
private volatile GroupId groupId = null;
private volatile Forum forum = null;
protected ForumAdapter forumAdapter;
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
setContentView(R.layout.activity_forum);
Intent i = getIntent();
byte[] b = i.getByteArrayExtra(GROUP_ID);
if (b == null) throw new IllegalStateException();
@@ -99,32 +89,30 @@ public class ForumActivity extends BriarActivity implements EventListener,
String forumName = i.getStringExtra(FORUM_NAME);
if (forumName != null) setTitle(forumName);
LinearLayout layout = new LinearLayout(this);
layout.setLayoutParams(MATCH_MATCH);
layout.setOrientation(VERTICAL);
layout.setGravity(CENTER_HORIZONTAL);
empty = new TextView(this);
empty.setLayoutParams(MATCH_WRAP_1);
empty.setGravity(CENTER);
empty.setTextSize(18);
empty.setText(R.string.no_forum_posts);
empty.setVisibility(GONE);
layout.addView(empty);
adapter = new ForumAdapter(this);
list = new ListView(this);
list.setLayoutParams(MATCH_WRAP_1);
list.setAdapter(adapter);
list.setOnItemClickListener(this);
list.setVisibility(GONE);
layout.addView(list);
// Show a progress bar while the list is loading
loading = new ListLoadingProgressBar(this);
layout.addView(loading);
setContentView(layout);
inputContainer = (ViewGroup) findViewById(R.id.text_input_container);
inputContainer.setVisibility(GONE);
textInput = (EditText) findViewById(R.id.input_text);
recyclerView =
(BriarRecyclerView) findViewById(R.id.forum_discussion_list);
linearLayoutManager = new LinearLayoutManager(this);
recyclerView.setLayoutManager(linearLayoutManager);
recyclerView.showProgressBar();
forumController
.loadForum(groupId, new UiResultHandler<Boolean>(this) {
@Override
public void onResultUi(Boolean result) {
if (result) {
setTitle(forumController.getForumName());
forumAdapter = new ForumAdapter(
forumController.getForumEntries());
recyclerView.setAdapter(forumAdapter);
recyclerView.showData();
} else {
// TODO Maybe an error dialog ?
finish();
}
}
});
}
@Override
@@ -132,14 +120,20 @@ public class ForumActivity extends BriarActivity implements EventListener,
component.inject(this);
}
private void displaySnackbar(int stringId) {
Snackbar snackbar =
Snackbar.make(recyclerView, stringId, Snackbar.LENGTH_SHORT);
snackbar.getView().setBackgroundResource(R.color.briar_primary);
snackbar.show();
}
@Override
public void onResume() {
super.onResume();
eventBus.addListener(this);
notificationManager.blockNotification(groupId);
notificationManager.clearForumPostNotification(groupId);
loadForum();
loadHeaders();
protected void onActivityResult(int request, int result, Intent data) {
super.onActivityResult(request, result, data);
if (request == REQUEST_FORUM_SHARED && result == RESULT_OK) {
displaySnackbar(R.string.forum_shared_snackbar);
}
}
@Override
@@ -151,6 +145,27 @@ public class ForumActivity extends BriarActivity implements EventListener,
return super.onCreateOptionsMenu(menu);
}
@Override
public void onBackPressed() {
if (inputContainer.getVisibility() == VISIBLE) {
inputContainer.setVisibility(GONE);
forumAdapter.setReplyEntry(null);
} else {
super.onBackPressed();
}
}
private void showTextInput(boolean isNewMessage) {
// An animation here would be an overkill because of the keyboard
// popping up.
inputContainer.setVisibility(View.VISIBLE);
textInput.setText("");
textInput.requestFocus();
textInput.setHint(isNewMessage ? R.string.forum_new_message_hint :
R.string.forum_message_reply_hint);
showSoftKeyboard(textInput);
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
ActivityOptionsCompat options = ActivityOptionsCompat
@@ -159,11 +174,9 @@ public class ForumActivity extends BriarActivity implements EventListener,
// Handle presses on the action bar items
switch (item.getItemId()) {
case R.id.action_forum_compose_post:
Intent i = new Intent(this, WriteForumPostActivity.class);
i.putExtra(GROUP_ID, groupId.getBytes());
i.putExtra(FORUM_NAME, forum.getName());
i.putExtra(MIN_TIMESTAMP, getMinTimestampForNewPost());
startActivity(i);
if (inputContainer.getVisibility() != VISIBLE) {
showTextInput(true);
}
return true;
case R.id.action_forum_share:
Intent i2 = new Intent(this, ShareForumActivity.class);
@@ -187,225 +200,34 @@ public class ForumActivity extends BriarActivity implements EventListener,
}
}
private void loadForum() {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
forum = forumManager.getForum(groupId);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Loading forum " + duration + " ms");
displayForumName();
} catch (NoSuchGroupException e) {
finishOnUiThread();
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void displayForumName() {
runOnUiThread(new Runnable() {
public void run() {
setTitle(forum.getName());
}
});
}
private void loadHeaders() {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
Collection<ForumPostHeader> headers =
forumManager.getPostHeaders(groupId);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Load took " + duration + " ms");
displayHeaders(headers);
} catch (NoSuchGroupException e) {
finishOnUiThread();
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void displayHeaders(final Collection<ForumPostHeader> headers) {
runOnUiThread(new Runnable() {
public void run() {
loading.setVisibility(GONE);
adapter.clear();
if (headers.isEmpty()) {
empty.setVisibility(VISIBLE);
list.setVisibility(GONE);
} else {
empty.setVisibility(GONE);
list.setVisibility(VISIBLE);
for (ForumPostHeader h : headers) {
ForumItem item = new ForumItem(h);
byte[] body = bodyCache.get(h.getId());
if (body == null) loadPostBody(h);
else item.setBody(body);
adapter.add(item);
}
adapter.sort(ForumItemComparator.INSTANCE);
// Scroll to the bottom
list.setSelection(adapter.getCount() - 1);
}
}
});
}
private void loadPostBody(final ForumPostHeader h) {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
byte[] body = forumManager.getPostBody(h.getId());
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Loading message took " + duration + " ms");
displayPost(h.getId(), body);
} catch (NoSuchMessageException e) {
// The item will be removed when we get the event
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void displayPost(final MessageId m, final byte[] body) {
runOnUiThread(new Runnable() {
public void run() {
bodyCache.put(m, body);
int count = adapter.getCount();
for (int i = 0; i < count; i++) {
ForumItem item = adapter.getItem(i);
if (item.getHeader().getId().equals(m)) {
item.setBody(body);
adapter.notifyDataSetChanged();
// Scroll to the bottom
list.setSelection(count - 1);
return;
}
}
}
});
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
super.onActivityResult(request, result, data);
if (request == REQUEST_READ && result == RESULT_PREV_NEXT) {
int position = data.getIntExtra("briar.POSITION", -1);
if (position >= 0 && position < adapter.getCount())
displayPost(position);
}
else if (request == REQUEST_FORUM_SHARED && result == RESULT_OK) {
Snackbar s = Snackbar.make(list, R.string.forum_shared_snackbar,
LENGTH_LONG);
s.getView().setBackgroundResource(R.color.briar_primary);
s.show();
}
public void onResume() {
super.onResume();
notificationManager.blockNotification(groupId);
notificationManager.clearForumPostNotification(groupId);
}
@Override
public void onPause() {
super.onPause();
eventBus.removeListener(this);
notificationManager.unblockNotification(groupId);
if (isFinishing()) markPostsRead();
}
private void markPostsRead() {
List<MessageId> unread = new ArrayList<>();
int count = adapter.getCount();
for (int i = 0; i < count; i++) {
ForumPostHeader h = adapter.getItem(i).getHeader();
if (!h.isRead()) unread.add(h.getId());
public void sendMessage(View view) {
String text = textInput.getText().toString();
if (text.trim().length() == 0)
return;
ForumEntry replyEntry = forumAdapter.getReplyEntry();
if (replyEntry == null) {
// root post
forumController.createPost(StringUtils.toUtf8(text));
} else {
forumController.createPost(StringUtils.toUtf8(text),
replyEntry.getMessageId());
}
if (unread.isEmpty()) return;
if (LOG.isLoggable(INFO))
LOG.info("Marking " + unread.size() + " posts read");
markPostsRead(Collections.unmodifiableList(unread));
}
private void markPostsRead(final Collection<MessageId> unread) {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
for (MessageId m : unread)
forumManager.setReadFlag(m, true);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Marking read took " + duration + " ms");
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
public void eventOccurred(Event e) {
if (e instanceof MessageStateChangedEvent) {
MessageStateChangedEvent m = (MessageStateChangedEvent) e;
if (m.getState() == DELIVERED &&
m.getMessage().getGroupId().equals(groupId)) {
LOG.info("Message added, reloading");
loadHeaders();
}
} else if (e instanceof GroupRemovedEvent) {
GroupRemovedEvent s = (GroupRemovedEvent) e;
if (s.getGroup().getId().equals(groupId)) {
LOG.info("Forum removed");
finishOnUiThread();
}
}
}
private long getMinTimestampForNewPost() {
// Don't use an earlier timestamp than the newest post
long timestamp = 0;
int count = adapter.getCount();
for (int i = 0; i < count; i++) {
long t = adapter.getItem(i).getHeader().getTimestamp();
if (t > timestamp) timestamp = t;
}
return timestamp + 1;
}
public void onItemClick(AdapterView<?> parent, View view, int position,
long id) {
displayPost(position);
}
private void displayPost(int position) {
ForumPostHeader header = adapter.getItem(position).getHeader();
Intent i = new Intent(this, ReadForumPostActivity.class);
i.putExtra(GROUP_ID, groupId.getBytes());
i.putExtra(FORUM_NAME, forum.getName());
i.putExtra("briar.MESSAGE_ID", header.getId().getBytes());
Author author = header.getAuthor();
if (author != null) {
i.putExtra("briar.AUTHOR_NAME", author.getName());
i.putExtra("briar.AUTHOR_ID", author.getId().getBytes());
}
i.putExtra("briar.AUTHOR_STATUS", header.getAuthorStatus().name());
i.putExtra("briar.CONTENT_TYPE", header.getContentType());
i.putExtra("briar.TIMESTAMP", header.getTimestamp());
i.putExtra(MIN_TIMESTAMP, getMinTimestampForNewPost());
i.putExtra("briar.POSITION", position);
startActivityForResult(i, REQUEST_READ);
hideSoftKeyboard(textInput);
inputContainer.setVisibility(GONE);
forumAdapter.setReplyEntry(null);
}
private void showUnsubscribeDialog() {
@@ -413,10 +235,19 @@ public class ForumActivity extends BriarActivity implements EventListener,
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
unsubscribe(forum);
Toast.makeText(ForumActivity.this,
R.string.forum_left_toast, LENGTH_SHORT)
.show();
forumController.unsubscribe(
new UiResultHandler<Boolean>(
ForumActivity.this) {
@Override
public void onResultUi(Boolean result) {
if (result) {
Toast.makeText(ForumActivity.this,
R.string.forum_left_toast,
LENGTH_SHORT)
.show();
}
}
});
}
};
AlertDialog.Builder builder =
@@ -429,21 +260,346 @@ public class ForumActivity extends BriarActivity implements EventListener,
builder.show();
}
private void unsubscribe(final Forum f) {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
forumManager.removeForum(f);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Removing forum took " + duration + " ms");
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
@Override
public void addLocalEntry(int index, ForumEntry entry) {
forumAdapter.addEntry(index, entry, true);
displaySnackbar(R.string.forum_new_entry_posted);
}
@Override
public void addForeignEntry(int index, ForumEntry entry) {
forumAdapter.addEntry(index, entry, false);
displaySnackbar(R.string.forum_new_entry_received);
}
static class ForumViewHolder extends RecyclerView.ViewHolder {
public final TextView textView, lvlText, dateText, repliesText;
public final View[] lvls;
public final ImageView avatar;
public final View chevron, replyButton;
public final ViewGroup cell;
public final View bottomDivider;
public ForumViewHolder(View v) {
super(v);
textView = (TextView) v.findViewById(R.id.text);
lvlText = (TextView) v.findViewById(R.id.nested_line_text);
dateText = (TextView) v.findViewById(R.id.date);
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]);
}
avatar = (ImageView) v.findViewById(R.id.avatar);
chevron = v.findViewById(R.id.chevron);
replyButton = v.findViewById(R.id.btn_reply);
cell = (ViewGroup) v.findViewById(R.id.forum_cell);
bottomDivider = v.findViewById(R.id.bottom_divider);
}
}
public class ForumAdapter extends RecyclerView.Adapter<ForumViewHolder> {
private final List<ForumEntry> forumEntries;
// highlight not depandant on time
private ForumEntry replyEntry;
// temporary highlight
private ForumEntry addedEntry;
public ForumAdapter(@NonNull List<ForumEntry> forumEntries) {
this.forumEntries = forumEntries;
}
private ForumEntry getReplyEntry() {
return replyEntry;
}
public 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);
}
break;
}
}
}
if (!isShowingDescendants) {
int visiblePos = getVisiblePos(entry);
notifyItemInserted(visiblePos);
if (isScrolling)
linearLayoutManager
.scrollToPositionWithOffset(visiblePos, 0);
}
addedEntry = entry;
}
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(int visiblePos) {
int levelLimit = forumEntries.get(visiblePos).getLevel();
for (int i = visiblePos + 1; i < getItemCount(); i++) {
ForumEntry entry = getVisibleEntry(i);
if (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;
}
public 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.getLevel() > levelLimit) {
indexList.add(i);
} else {
break;
}
}
return indexList;
}
public 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());
}
}
}
public void hideDescendants(ForumEntry forumEntry) {
int visiblePos = getVisiblePos(forumEntry);
List<Integer> indexList =
getSubTreeIndexes(visiblePos, forumEntry.getLevel());
if (!indexList.isEmpty()) {
if (indexList.size() == 1) {
notifyItemRemoved(indexList.get(0));
} else {
notifyItemRangeRemoved(indexList.get(0),
indexList.size());
}
}
forumEntry.setShowingDescendants(false);
}
public int getVisiblePos(ForumEntry entry) {
int visibleCounter = 0;
int levelLimit = -1;
for (int i = 0; i < forumEntries.size(); i++) {
ForumEntry forumEntry = forumEntries.get(i);
if (forumEntry.equals(entry)) {
return visibleCounter;
} else if (levelLimit >= 0 &&
levelLimit < forumEntry.getLevel()) {
// entry is in a hidden sub-tree
continue;
}
levelLimit = -1;
if (!forumEntry.isShowingDescendants()) {
levelLimit = forumEntry.getLevel();
}
visibleCounter++;
}
return -1;
}
@NonNull
public ForumEntry getVisibleEntry(int position) {
int levelLimit = -1;
for (ForumEntry forumEntry : forumEntries) {
if (levelLimit >= 0) {
if (forumEntry.getLevel() > levelLimit) {
continue;
}
levelLimit = -1;
}
if (!forumEntry.isShowingDescendants()) {
levelLimit = forumEntry.getLevel();
}
if (position-- == 0) {
return forumEntry;
}
}
return null;
}
@Override
public ForumViewHolder onCreateViewHolder(
ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.forum_discussion_cell, parent, false);
return new ForumViewHolder(v);
}
@Override
public void onBindViewHolder(
final ForumViewHolder ui, final int position) {
final ForumEntry data = getVisibleEntry(position);
if (!data.isRead()) {
data.setRead(true);
forumController.entryRead(data);
}
ui.textView.setText(data.getText());
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.dateText.setText(DateUtils
.getRelativeTimeSpanString(ForumActivity.this,
data.getTimestamp()) + " " + data.getAuthor());
int replies = getReplyCount(data);
if (replies == 0) {
ui.repliesText.setText("");
} else {
ui.repliesText.setText(getResources()
.getQuantityString(R.plurals.message_replies, replies,
replies));
}
ui.avatar.setImageDrawable(
new IdenticonDrawable(data.getAuthorId().getBytes()));
if (hasDescendants(data)) {
ui.chevron.setVisibility(VISIBLE);
if (hasVisibleDescendants(position)) {
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)) {
CustomAnimations.animateColorTransition(ui.cell, ContextCompat
.getColor(ForumActivity.this,
R.color.window_background), 3000,
new ResultHandler<Void>() {
@Override
public void onResult(Void result) {
ui.setIsRecyclable(true);
}
});
// don't allow cell recycling until the animation finishes
ui.setIsRecyclable(false);
addedEntry = null;
} else {
ui.cell.setBackgroundColor(ContextCompat
.getColor(ForumActivity.this,
R.color.window_background));
}
ui.replyButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (inputContainer.getVisibility() != VISIBLE) {
showTextInput(false);
}
setReplyEntry(data);
linearLayoutManager
.scrollToPositionWithOffset(position, 0);
}
});
}
@Override
public int getItemCount() {
int visibleCounter = 0;
int levelLimit = -1;
for (ForumEntry forumEntry : forumEntries) {
if (levelLimit >= 0) {
if (forumEntry.getLevel() > levelLimit) {
continue;
}
levelLimit = -1;
}
if (!forumEntry.isShowingDescendants()) {
levelLimit = forumEntry.getLevel();
}
visibleCounter++;
}
return visibleCounter;
}
}
}

View File

@@ -0,0 +1,27 @@
package org.briarproject.android.forum;
import org.briarproject.android.controller.ActivityLifecycleController;
import org.briarproject.android.controller.handler.UiResultHandler;
import org.briarproject.api.sync.GroupId;
import org.briarproject.api.sync.MessageId;
import java.util.Collection;
import java.util.List;
public interface ForumController extends ActivityLifecycleController {
void loadForum(GroupId groupId, UiResultHandler<Boolean> resultHandler);
String getForumName();
List<ForumEntry> getForumEntries();
void unsubscribe(UiResultHandler<Boolean> resultHandler);
void entryRead(ForumEntry forumEntry);
void entriesRead(Collection<ForumEntry> messageIds);
void createPost(byte[] body);
void createPost(byte[] body, MessageId parentId);
public interface ForumPostListener {
void addLocalEntry(int index, ForumEntry entry);
void addForeignEntry(int index, ForumEntry entry);
}
}

View File

@@ -0,0 +1,371 @@
package org.briarproject.android.forum;
import android.app.Activity;
import org.briarproject.android.controller.DbControllerImpl;
import org.briarproject.android.controller.handler.UiResultHandler;
import org.briarproject.api.FormatException;
import org.briarproject.api.crypto.CryptoComponent;
import org.briarproject.api.crypto.CryptoExecutor;
import org.briarproject.api.crypto.KeyParser;
import org.briarproject.api.crypto.PrivateKey;
import org.briarproject.api.db.DbException;
import org.briarproject.api.event.Event;
import org.briarproject.api.event.EventBus;
import org.briarproject.api.event.EventListener;
import org.briarproject.api.event.GroupRemovedEvent;
import org.briarproject.api.event.MessageStateChangedEvent;
import org.briarproject.api.forum.ForumManager;
import org.briarproject.api.forum.ForumPost;
import org.briarproject.api.forum.ForumPostFactory;
import org.briarproject.api.forum.ForumPostHeader;
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.util.StringUtils;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Stack;
import java.util.concurrent.Executor;
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.sync.ValidationManager.State.DELIVERED;
public class ForumControllerImpl extends DbControllerImpl
implements ForumController, EventListener {
private static final Logger LOG =
Logger.getLogger(ForumControllerImpl.class.getName());
@Inject
protected Activity activity;
@Inject
@CryptoExecutor
protected Executor cryptoExecutor;
@Inject
protected volatile ForumPostFactory forumPostFactory;
@Inject
protected volatile CryptoComponent crypto;
@Inject
protected volatile ForumManager forumManager;
@Inject
protected volatile EventBus eventBus;
@Inject
protected volatile IdentityManager identityManager;
@Inject
protected ForumPersistentData data;
private ForumPostListener listener;
private MessageId localAdd = null;
@Inject
ForumControllerImpl() {
}
@Override
public void onActivityCreate() {
if (activity instanceof ForumPostListener) {
listener = (ForumPostListener) activity;
} else {
throw new IllegalStateException(
"An activity that injects the ForumController must " +
"implement the ForumPostListener");
}
}
@Override
public void onActivityResume() {
eventBus.addListener(this);
}
@Override
public void onActivityPause() {
eventBus.removeListener(this);
}
@Override
public void onActivityDestroy() {
if (activity.isFinishing()) {
data.clearAll();
}
}
private void findSingleNewEntry() {
runOnDbThread(new Runnable() {
@Override
public void run() {
List<ForumEntry> oldEntries = getForumEntries();
data.clearHeaders();
try {
loadPosts();
List<ForumEntry> allEntries = getForumEntries();
int i = 0;
for (ForumEntry entry : allEntries) {
boolean isNew = true;
for (ForumEntry oldEntry : oldEntries) {
if (entry.getMessageId()
.equals(oldEntry.getMessageId())) {
isNew = false;
break;
}
}
if (isNew) {
if (localAdd != null &&
entry.getMessageId().equals(localAdd)) {
addLocalEntry(i, entry);
} else {
addForeignEntry(i, entry);
}
break;
}
i++;
}
} catch (DbException e) {
e.printStackTrace();
}
}
});
}
@Override
public void eventOccurred(Event e) {
if (e instanceof MessageStateChangedEvent) {
MessageStateChangedEvent m = (MessageStateChangedEvent) e;
if (m.getState() == DELIVERED &&
m.getMessage().getGroupId().equals(data.getGroupId())) {
LOG.info("Message added, reloading");
findSingleNewEntry();
}
} else if (e instanceof GroupRemovedEvent) {
GroupRemovedEvent s = (GroupRemovedEvent) e;
if (s.getGroup().getId().equals(data.getGroupId())) {
LOG.info("Forum removed");
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
activity.finish();
}
});
}
}
}
private void loadAuthor() throws DbException {
Collection<LocalAuthor> localAuthors =
identityManager.getLocalAuthors();
for (LocalAuthor author : localAuthors) {
if (author == null)
continue;
data.setLocalAuthor(author);
break;
}
}
private void loadPosts() throws DbException {
long now = System.currentTimeMillis();
Collection<ForumPostHeader> headers =
forumManager.getPostHeaders(data.getGroupId());
data.addHeaders(headers);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Loading headers took " + duration + " ms");
now = System.currentTimeMillis();
for (ForumPostHeader header : headers) {
if (data.getBody(header.getId()) == null) {
byte[] body = forumManager.getPostBody(header.getId());
data.addBody(header.getId(), body);
}
}
duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Loading bodies took " + duration + " ms");
}
@Override
public void loadForum(final GroupId groupId,
final UiResultHandler<Boolean> resultHandler) {
LOG.info("Loading forum...");
runOnDbThread(new Runnable() {
@Override
public void run() {
try {
if (data.getGroupId() == null ||
!data.getGroupId().equals(groupId)) {
data.setGroupId(groupId);
long now = System.currentTimeMillis();
data.setForum(forumManager.getForum(groupId));
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Loading forum took " + duration +
" ms");
now = System.currentTimeMillis();
loadAuthor();
duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Loading author took " + duration +
" ms");
loadPosts();
}
resultHandler.onResult(true);
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
resultHandler.onResult(false);
}
}
});
}
@Override
public String getForumName() {
return data.getForum() == null ? null : data.getForum().getName();
}
@Override
public List<ForumEntry> getForumEntries() {
Collection<ForumPostHeader> headers = data.getHeaders();
List<ForumEntry> forumEntries = 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()));
}
forumEntries.add(new ForumEntry(h,
StringUtils.fromUtf8(data.getBody(h.getId())),
idStack.size()));
}
return forumEntries;
}
@Override
public void unsubscribe(final UiResultHandler<Boolean> resultHandler) {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
forumManager.removeForum(data.getForum());
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Removing forum took " + duration + " ms");
resultHandler.onResult(true);
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
resultHandler.onResult(false);
}
}
});
}
@Override
public void entryRead(ForumEntry forumEntry) {
entriesRead(Collections.singletonList(forumEntry));
}
@Override
public void entriesRead(final Collection<ForumEntry> forumEntries) {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
for (ForumEntry fe : forumEntries) {
forumManager.setReadFlag(fe.getMessageId(), true);
}
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Marking read took " + duration + " ms");
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
@Override
public void createPost(byte[] body) {
createPost(body, null);
}
@Override
public void createPost(final byte[] body, final MessageId parentId) {
cryptoExecutor.execute(new Runnable() {
public void run() {
// Don't use an earlier timestamp than the newest post
long timestamp = System.currentTimeMillis();
ForumPost p;
try {
KeyParser keyParser = crypto.getSignatureKeyParser();
byte[] b = data.getLocalAuthor().getPrivateKey();
PrivateKey authorKey = keyParser.parsePrivateKey(b);
p = forumPostFactory.createPseudonymousPost(
data.getGroupId(), timestamp, parentId,
data.getLocalAuthor(), "text/plain", body,
authorKey);
} catch (GeneralSecurityException | FormatException e) {
throw new RuntimeException(e);
}
storePost(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);
}
});
}
private void storePost(final ForumPost p) {
runOnDbThread(new Runnable() {
public void run() {
try {
localAdd = p.getMessage().getId();
long now = System.currentTimeMillis();
forumManager.addLocalPost(p);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info(
"Storing message took " + duration + " ms");
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
}

View File

@@ -0,0 +1,73 @@
package org.briarproject.android.forum;
import org.briarproject.api.forum.ForumPostHeader;
import org.briarproject.api.identity.AuthorId;
import org.briarproject.api.sync.MessageId;
public class ForumEntry {
private final MessageId messageId;
private final String text;
private final int level;
private final long timestamp;
private final String author;
private final AuthorId authorId;
private boolean isShowingDescendants = true;
private boolean isRead = true;
public ForumEntry(ForumPostHeader h, String text, int level) {
this(h.getId(), text, level, h.getTimestamp(), h.getAuthor().getName(),
h.getAuthor().getId());
this.isRead = h.isRead();
}
public ForumEntry(MessageId messageId, String text, int level,
long timestamp, String author, AuthorId authorId) {
this.messageId = messageId;
this.text = text;
this.level = level;
this.timestamp = timestamp;
this.author = author;
this.authorId = authorId;
}
public String getText() {
return text;
}
public int getLevel() {
return level;
}
public long getTimestamp() {
return timestamp;
}
public String getAuthor() {
return author;
}
public AuthorId getAuthorId() {
return authorId;
}
public boolean isShowingDescendants() {
return isShowingDescendants;
}
public void setShowingDescendants(boolean showingDescendants) {
this.isShowingDescendants = showingDescendants;
}
public MessageId getMessageId() {
return messageId;
}
public boolean isRead() {
return isRead;
}
public void setRead(boolean read) {
isRead = read;
}
}

View File

@@ -0,0 +1,88 @@
package org.briarproject.android.forum;
import org.briarproject.api.clients.MessageTree;
import org.briarproject.api.forum.Forum;
import org.briarproject.api.forum.ForumPostHeader;
import org.briarproject.api.identity.LocalAuthor;
import org.briarproject.api.sync.GroupId;
import org.briarproject.api.sync.MessageId;
import org.briarproject.clients.MessageTreeImpl;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import javax.inject.Inject;
/**
* This class is a singleton that defines the data that should persist, i.e.
* still be present in memory after activity restarts. This class is not thread
* safe.
*/
public class ForumPersistentData {
protected volatile MessageTree<ForumPostHeader> tree =
new MessageTreeImpl<>();
private volatile Map<MessageId, byte[]> bodyCache = new HashMap<>();
private volatile LocalAuthor localAuthor;
private volatile Forum forum;
private volatile GroupId groupId;
@Inject
public ForumPersistentData() {
}
public void clearAll() {
tree.clear();
bodyCache.clear();
localAuthor = null;
forum = null;
groupId = null;
}
public void clearHeaders() {
tree.clear();
}
public void addHeaders(Collection<ForumPostHeader> headers) {
tree.add(headers);
}
public Collection<ForumPostHeader> getHeaders() {
return tree.depthFirstOrder();
}
public void addBody(MessageId messageId, byte[] body) {
bodyCache.put(messageId, body);
}
public byte[] getBody(MessageId messageId) {
return bodyCache.get(messageId);
}
public LocalAuthor getLocalAuthor() {
return localAuthor;
}
public void setLocalAuthor(
LocalAuthor localAuthor) {
this.localAuthor = localAuthor;
}
public Forum getForum() {
return forum;
}
public void setForum(Forum forum) {
this.forum = forum;
}
public GroupId getGroupId() {
return groupId;
}
public void setGroupId(GroupId groupId) {
this.groupId = groupId;
}
}

View File

@@ -0,0 +1,179 @@
package org.briarproject.android.forum;
import org.briarproject.android.controller.handler.UiResultHandler;
import org.briarproject.api.UniqueId;
import org.briarproject.api.identity.AuthorId;
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;
public class ForumTestControllerImpl implements ForumController {
private static final Logger LOG =
Logger.getLogger(ForumControllerImpl.class.getName());
private final static String[] AUTHORS = {
"Guðmundur",
"Jónas",
"Geir Þorsteinn Gísli Máni Halldórsson Guðjónsson Mogensen",
"Baldur Friðrik",
"Anna Katrín",
"Þór",
"Anna Þorbjörg",
"Guðrún",
"Helga",
"Haraldur"
};
private final static AuthorId[] AUTHOR_ID = new AuthorId[AUTHORS.length];
static {
SecureRandom random = new SecureRandom();
for (int i = 0; i < AUTHOR_ID.length; i++) {
byte[] b = new byte[UniqueId.LENGTH];
random.nextBytes(b);
AUTHOR_ID[i] = new AuthorId(b);
}
}
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
public 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,
UiResultHandler<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],
AUTHOR_ID[authorIndex]);
}
LOG.info("forum entries: " + forumEntries.length);
resultHandler.onResult(true);
}
@Override
public String getForumName() {
return "SAGA";
}
@Override
public List<ForumEntry> getForumEntries() {
return forumEntries == null ? null :
new ArrayList<ForumEntry>(Arrays.asList(forumEntries));
}
@Override
public void unsubscribe(UiResultHandler<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() {
}
}

View File

@@ -1,249 +0,0 @@
package org.briarproject.android.forum;
import android.content.Intent;
import android.content.res.Resources;
import android.os.Bundle;
import android.text.format.DateUtils;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import org.briarproject.R;
import org.briarproject.android.ActivityComponent;
import org.briarproject.android.AndroidComponent;
import org.briarproject.android.BriarActivity;
import org.briarproject.android.util.AuthorView;
import org.briarproject.android.util.ElasticHorizontalSpace;
import org.briarproject.android.util.HorizontalBorder;
import org.briarproject.android.util.LayoutUtils;
import org.briarproject.api.db.DbException;
import org.briarproject.api.db.NoSuchMessageException;
import org.briarproject.api.forum.ForumManager;
import org.briarproject.api.identity.Author;
import org.briarproject.api.identity.AuthorId;
import org.briarproject.api.sync.GroupId;
import org.briarproject.api.sync.MessageId;
import org.briarproject.util.StringUtils;
import java.util.logging.Logger;
import javax.inject.Inject;
import static android.view.Gravity.CENTER;
import static android.view.Gravity.CENTER_VERTICAL;
import static android.widget.LinearLayout.HORIZONTAL;
import static android.widget.LinearLayout.VERTICAL;
import static java.util.logging.Level.INFO;
import static java.util.logging.Level.WARNING;
import static org.briarproject.android.forum.ForumActivity.FORUM_NAME;
import static org.briarproject.android.forum.ForumActivity.MIN_TIMESTAMP;
import static org.briarproject.android.util.CommonLayoutParams.MATCH_MATCH;
import static org.briarproject.android.util.CommonLayoutParams.MATCH_WRAP;
import static org.briarproject.android.util.CommonLayoutParams.MATCH_WRAP_1;
import static org.briarproject.android.util.CommonLayoutParams.WRAP_WRAP_1;
public class ReadForumPostActivity extends BriarActivity
implements OnClickListener {
static final int RESULT_REPLY = RESULT_FIRST_USER;
static final int RESULT_PREV_NEXT = RESULT_FIRST_USER + 1;
private static final Logger LOG =
Logger.getLogger(ReadForumPostActivity.class.getName());
private GroupId groupId = null;
private String forumName = null;
private long minTimestamp = -1;
private ImageButton prevButton = null, nextButton = null;
private ImageButton replyButton = null;
private TextView content = null;
private int position = -1;
// Fields that are accessed from background threads must be volatile
@Inject protected volatile ForumManager forumManager;
private volatile MessageId messageId = null;
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
Intent i = getIntent();
byte[] b = i.getByteArrayExtra(GROUP_ID);
if (b == null) throw new IllegalStateException();
groupId = new GroupId(b);
forumName = i.getStringExtra(FORUM_NAME);
if (forumName == null) throw new IllegalStateException();
setTitle(forumName);
b = i.getByteArrayExtra("briar.MESSAGE_ID");
if (b == null) throw new IllegalStateException();
messageId = new MessageId(b);
String contentType = i.getStringExtra("briar.CONTENT_TYPE");
if (contentType == null) throw new IllegalStateException();
long timestamp = i.getLongExtra("briar.TIMESTAMP", -1);
if (timestamp == -1) throw new IllegalStateException();
minTimestamp = i.getLongExtra(MIN_TIMESTAMP, -1);
if (minTimestamp == -1) throw new IllegalStateException();
position = i.getIntExtra("briar.POSITION", -1);
if (position == -1) throw new IllegalStateException();
String authorName = i.getStringExtra("briar.AUTHOR_NAME");
AuthorId authorId = null;
b = i.getByteArrayExtra("briar.AUTHOR_ID");
if (b != null) authorId = new AuthorId(b);
String s = i.getStringExtra("briar.AUTHOR_STATUS");
if (s == null) throw new IllegalStateException();
Author.Status authorStatus = Author.Status.valueOf(s);
LinearLayout layout = new LinearLayout(this);
layout.setLayoutParams(MATCH_MATCH);
layout.setOrientation(VERTICAL);
ScrollView scrollView = new ScrollView(this);
scrollView.setLayoutParams(MATCH_WRAP_1);
LinearLayout message = new LinearLayout(this);
message.setOrientation(VERTICAL);
LinearLayout header = new LinearLayout(this);
header.setLayoutParams(MATCH_WRAP);
header.setOrientation(HORIZONTAL);
header.setGravity(CENTER_VERTICAL);
int pad = LayoutUtils.getPadding(this);
AuthorView authorView = new AuthorView(this);
authorView.setPadding(0, pad, pad, pad);
authorView.setLayoutParams(WRAP_WRAP_1);
authorView.init(authorName, authorId, authorStatus);
header.addView(authorView);
TextView date = new TextView(this);
date.setPadding(pad, pad, pad, pad);
date.setText(DateUtils.getRelativeTimeSpanString(this, timestamp));
header.addView(date);
message.addView(header);
if (contentType.equals("text/plain")) {
// Load and display the message body
content = new TextView(this);
content.setPadding(pad, 0, pad, pad);
message.addView(content);
loadPostBody();
}
scrollView.addView(message);
layout.addView(scrollView);
layout.addView(new HorizontalBorder(this));
LinearLayout footer = new LinearLayout(this);
footer.setLayoutParams(MATCH_WRAP);
footer.setOrientation(HORIZONTAL);
footer.setGravity(CENTER);
Resources res = getResources();
footer.setBackgroundColor(res.getColor(R.color.button_bar_background));
prevButton = new ImageButton(this);
prevButton.setBackgroundResource(0);
prevButton.setImageResource(R.drawable.navigation_previous_item);
prevButton.setOnClickListener(this);
footer.addView(prevButton);
footer.addView(new ElasticHorizontalSpace(this));
nextButton = new ImageButton(this);
nextButton.setBackgroundResource(0);
nextButton.setImageResource(R.drawable.navigation_next_item);
nextButton.setOnClickListener(this);
footer.addView(nextButton);
footer.addView(new ElasticHorizontalSpace(this));
replyButton = new ImageButton(this);
replyButton.setBackgroundResource(0);
replyButton.setImageResource(R.drawable.social_reply_all);
replyButton.setOnClickListener(this);
footer.addView(replyButton);
layout.addView(footer);
setContentView(layout);
}
@Override
public void injectActivity(ActivityComponent component) {
component.inject(this);
}
@Override
public void onPause() {
super.onPause();
if (isFinishing()) markPostRead();
}
private void markPostRead() {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
forumManager.setReadFlag(messageId, true);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Marking read took " + duration + " ms");
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void loadPostBody() {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
byte[] body = forumManager.getPostBody(messageId);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Loading post took " + duration + " ms");
displayPostBody(StringUtils.fromUtf8(body));
} catch (NoSuchMessageException e) {
finishOnUiThread();
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void displayPostBody(final String body) {
runOnUiThread(new Runnable() {
public void run() {
content.setText(body);
}
});
}
public void onClick(View view) {
if (view == prevButton) {
Intent i = new Intent();
i.putExtra("briar.POSITION", position - 1);
setResult(RESULT_PREV_NEXT, i);
finish();
} else if (view == nextButton) {
Intent i = new Intent();
i.putExtra("briar.POSITION", position + 1);
setResult(RESULT_PREV_NEXT, i);
finish();
} else if (view == replyButton) {
Intent i = new Intent(this, WriteForumPostActivity.class);
i.putExtra(GROUP_ID, groupId.getBytes());
i.putExtra(FORUM_NAME, forumName);
i.putExtra("briar.PARENT_ID", messageId.getBytes());
i.putExtra(MIN_TIMESTAMP, minTimestamp);
startActivity(i);
setResult(RESULT_REPLY);
finish();
}
}
}

View File

@@ -1,317 +0,0 @@
package org.briarproject.android.forum;
import android.content.Intent;
import android.os.Bundle;
import android.text.InputType;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemSelectedListener;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.RelativeLayout;
import android.widget.Spinner;
import android.widget.TextView;
import android.widget.Toast;
import org.briarproject.R;
import org.briarproject.android.ActivityComponent;
import org.briarproject.android.AndroidComponent;
import org.briarproject.android.BriarActivity;
import org.briarproject.android.identity.CreateIdentityActivity;
import org.briarproject.android.identity.LocalAuthorItem;
import org.briarproject.android.identity.LocalAuthorItemComparator;
import org.briarproject.android.identity.LocalAuthorSpinnerAdapter;
import org.briarproject.android.util.CommonLayoutParams;
import org.briarproject.android.util.LayoutUtils;
import org.briarproject.api.FormatException;
import org.briarproject.api.crypto.CryptoComponent;
import org.briarproject.api.crypto.CryptoExecutor;
import org.briarproject.api.crypto.KeyParser;
import org.briarproject.api.crypto.PrivateKey;
import org.briarproject.api.db.DbException;
import org.briarproject.api.forum.Forum;
import org.briarproject.api.forum.ForumManager;
import org.briarproject.api.forum.ForumPost;
import org.briarproject.api.forum.ForumPostFactory;
import org.briarproject.api.identity.AuthorId;
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.util.StringUtils;
import java.security.GeneralSecurityException;
import java.util.Collection;
import java.util.concurrent.Executor;
import java.util.logging.Logger;
import javax.inject.Inject;
import static android.text.InputType.TYPE_CLASS_TEXT;
import static android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES;
import static android.widget.LinearLayout.VERTICAL;
import static android.widget.RelativeLayout.ALIGN_PARENT_LEFT;
import static android.widget.RelativeLayout.ALIGN_PARENT_RIGHT;
import static android.widget.RelativeLayout.CENTER_VERTICAL;
import static android.widget.RelativeLayout.LEFT_OF;
import static android.widget.RelativeLayout.RIGHT_OF;
import static android.widget.Toast.LENGTH_LONG;
import static java.util.logging.Level.INFO;
import static java.util.logging.Level.WARNING;
import static org.briarproject.android.forum.ForumActivity.FORUM_NAME;
import static org.briarproject.android.forum.ForumActivity.MIN_TIMESTAMP;
import static org.briarproject.android.util.CommonLayoutParams.MATCH_WRAP;
public class WriteForumPostActivity extends BriarActivity
implements OnItemSelectedListener, OnClickListener {
private static final int REQUEST_CREATE_IDENTITY = 2;
private static final Logger LOG =
Logger.getLogger(WriteForumPostActivity.class.getName());
@Inject @CryptoExecutor protected Executor cryptoExecutor;
private LocalAuthorSpinnerAdapter adapter = null;
private Spinner spinner = null;
private ImageButton sendButton = null;
private EditText content = null;
private AuthorId localAuthorId = null;
private GroupId groupId = null;
// Fields that are accessed from background threads must be volatile
@Inject protected volatile IdentityManager identityManager;
@Inject protected volatile ForumManager forumManager;
@Inject protected volatile ForumPostFactory forumPostFactory;
@Inject protected volatile CryptoComponent crypto;
private volatile MessageId parentId = null;
private volatile long minTimestamp = -1;
private volatile LocalAuthor localAuthor = null;
private volatile Forum forum = null;
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
Intent i = getIntent();
byte[] b = i.getByteArrayExtra(GROUP_ID);
if (b == null) throw new IllegalStateException();
groupId = new GroupId(b);
String forumName = i.getStringExtra(FORUM_NAME);
if (forumName == null) throw new IllegalStateException();
setTitle(forumName);
minTimestamp = i.getLongExtra(MIN_TIMESTAMP, -1);
if (minTimestamp == -1) throw new IllegalStateException();
b = i.getByteArrayExtra("briar.PARENT_ID");
if (b != null) parentId = new MessageId(b);
if (state != null) {
b = state.getByteArray("briar.LOCAL_AUTHOR_ID");
if (b != null) localAuthorId = new AuthorId(b);
}
LinearLayout layout = new LinearLayout(this);
layout.setLayoutParams(MATCH_WRAP);
layout.setOrientation(VERTICAL);
int pad = LayoutUtils.getPadding(this);
layout.setPadding(pad, 0, pad, pad);
RelativeLayout header = new RelativeLayout(this);
TextView from = new TextView(this);
from.setId(1);
from.setTextSize(18);
from.setText(R.string.from);
RelativeLayout.LayoutParams left = CommonLayoutParams.relative();
left.addRule(ALIGN_PARENT_LEFT);
left.addRule(CENTER_VERTICAL);
header.addView(from, left);
adapter = new LocalAuthorSpinnerAdapter(this, true);
spinner = new Spinner(this);
spinner.setId(2);
spinner.setAdapter(adapter);
spinner.setOnItemSelectedListener(this);
RelativeLayout.LayoutParams between = CommonLayoutParams.relative();
between.addRule(CENTER_VERTICAL);
between.addRule(RIGHT_OF, 1);
between.addRule(LEFT_OF, 3);
header.addView(spinner, between);
sendButton = new ImageButton(this);
sendButton.setId(3);
sendButton.setBackgroundResource(0);
sendButton.setImageResource(R.drawable.social_send_now);
sendButton.setEnabled(false); // Enabled after loading the forum
sendButton.setOnClickListener(this);
RelativeLayout.LayoutParams right = CommonLayoutParams.relative();
right.addRule(ALIGN_PARENT_RIGHT);
right.addRule(CENTER_VERTICAL);
header.addView(sendButton, right);
layout.addView(header);
content = new EditText(this);
content.setId(4);
int inputType = TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE
| TYPE_TEXT_FLAG_CAP_SENTENCES;
content.setInputType(inputType);
content.setHint(R.string.forum_post_hint);
layout.addView(content);
setContentView(layout);
}
@Override
public void injectActivity(ActivityComponent component) {
component.inject(this);
}
@Override
public void onResume() {
super.onResume();
loadAuthorsAndForum();
}
private void loadAuthorsAndForum() {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
Collection<LocalAuthor> localAuthors =
identityManager.getLocalAuthors();
forum = forumManager.getForum(groupId);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Load took " + duration + " ms");
displayAuthorsAndForum(localAuthors);
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
private void displayAuthorsAndForum(
final Collection<LocalAuthor> localAuthors) {
runOnUiThread(new Runnable() {
public void run() {
if (localAuthors.isEmpty()) throw new IllegalStateException();
adapter.clear();
for (LocalAuthor a : localAuthors)
adapter.add(new LocalAuthorItem(a));
adapter.sort(LocalAuthorItemComparator.INSTANCE);
int count = adapter.getCount();
for (int i = 0; i < count; i++) {
LocalAuthorItem item = adapter.getItem(i);
if (item == LocalAuthorItem.ANONYMOUS) continue;
if (item == LocalAuthorItem.NEW) continue;
if (item.getLocalAuthor().getId().equals(localAuthorId)) {
localAuthor = item.getLocalAuthor();
spinner.setSelection(i);
break;
}
}
setTitle(forum.getName());
sendButton.setEnabled(true);
}
});
}
@Override
public void onSaveInstanceState(Bundle state) {
super.onSaveInstanceState(state);
if (localAuthorId != null) {
byte[] b = localAuthorId.getBytes();
state.putByteArray("briar.LOCAL_AUTHOR_ID", b);
}
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
super.onActivityResult(request, result, data);
if (request == REQUEST_CREATE_IDENTITY && result == RESULT_OK) {
byte[] b = data.getByteArrayExtra("briar.LOCAL_AUTHOR_ID");
if (b == null) throw new IllegalStateException();
localAuthorId = new AuthorId(b);
loadAuthorsAndForum();
}
}
public void onItemSelected(AdapterView<?> parent, View view, int position,
long id) {
LocalAuthorItem item = adapter.getItem(position);
if (item == LocalAuthorItem.ANONYMOUS) {
localAuthor = null;
localAuthorId = null;
} else if (item == LocalAuthorItem.NEW) {
localAuthor = null;
localAuthorId = null;
Intent i = new Intent(this, CreateIdentityActivity.class);
startActivityForResult(i, REQUEST_CREATE_IDENTITY);
} else {
localAuthor = item.getLocalAuthor();
localAuthorId = localAuthor.getId();
}
}
public void onNothingSelected(AdapterView<?> parent) {
localAuthor = null;
localAuthorId = null;
}
public void onClick(View view) {
if (forum == null) throw new IllegalStateException();
String body = content.getText().toString();
if (body.equals("")) return;
createPost(StringUtils.toUtf8(body));
Toast.makeText(this, R.string.post_sent_toast, LENGTH_LONG).show();
finish();
}
private void createPost(final byte[] body) {
cryptoExecutor.execute(new Runnable() {
public void run() {
// Don't use an earlier timestamp than the newest post
long timestamp = System.currentTimeMillis();
timestamp = Math.max(timestamp, minTimestamp);
ForumPost p;
try {
if (localAuthor == null) {
p = forumPostFactory.createAnonymousPost(groupId,
timestamp, parentId, "text/plain", body);
} else {
KeyParser keyParser = crypto.getSignatureKeyParser();
byte[] b = localAuthor.getPrivateKey();
PrivateKey authorKey = keyParser.parsePrivateKey(b);
p = forumPostFactory.createPseudonymousPost(groupId,
timestamp, parentId, localAuthor, "text/plain",
body, authorKey);
}
} catch (GeneralSecurityException e) {
throw new RuntimeException(e);
} catch (FormatException e) {
throw new RuntimeException(e);
}
storePost(p);
}
});
}
private void storePost(final ForumPost p) {
runOnDbThread(new Runnable() {
public void run() {
try {
long now = System.currentTimeMillis();
forumManager.addLocalPost(p);
long duration = System.currentTimeMillis() - now;
if (LOG.isLoggable(INFO))
LOG.info("Storing message took " + duration + " ms");
} catch (DbException e) {
if (LOG.isLoggable(WARNING))
LOG.log(WARNING, e.toString(), e);
}
}
});
}
}

View File

@@ -1,6 +1,7 @@
package org.briarproject.android.util;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Build;
import android.support.v7.widget.RecyclerView;
import android.util.AttributeSet;
@@ -18,6 +19,7 @@ public class BriarRecyclerView extends FrameLayout {
private TextView emptyView;
private ProgressBar progressBar;
private RecyclerView.AdapterDataObserver emptyObserver;
private boolean isScrollingToEnd = false;
public BriarRecyclerView(Context context) {
super(context);
@@ -25,6 +27,11 @@ public class BriarRecyclerView extends FrameLayout {
public BriarRecyclerView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray attributes = context.obtainStyledAttributes(attrs,
R.styleable.BriarRecyclerView);
isScrollingToEnd = attributes
.getBoolean(R.styleable.BriarRecyclerView_scrollToEnd, true);
}
public BriarRecyclerView(Context context, AttributeSet attrs,
@@ -44,7 +51,7 @@ public class BriarRecyclerView extends FrameLayout {
showProgressBar();
// scroll down when opening keyboard
if (Build.VERSION.SDK_INT >= 11) {
if (isScrollingToEnd && Build.VERSION.SDK_INT >= 11) {
recyclerView.addOnLayoutChangeListener(
new View.OnLayoutChangeListener() {
@Override

View File

@@ -1,11 +1,16 @@
package org.briarproject.android.util;
import android.animation.Animator;
import android.animation.ArgbEvaluator;
import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.view.View;
import android.view.ViewGroup;
import org.briarproject.android.controller.handler.ResultHandler;
import static android.view.View.GONE;
import static android.view.View.MeasureSpec.UNSPECIFIED;
import static android.view.View.VISIBLE;
@@ -21,6 +26,49 @@ public class CustomAnimations {
}
}
@SuppressLint("NewApi")
public static void animateColorTransition(final View view, int color,
int duration, final ResultHandler<Void> finishedCallback) {
// No soup for Gingerbread
if (Build.VERSION.SDK_INT < 11) {
return;
}
ValueAnimator anim = new ValueAnimator();
ColorDrawable viewColor = (ColorDrawable) view.getBackground();
anim.setIntValues(viewColor.getColor(), color);
anim.setEvaluator(new ArgbEvaluator());
anim.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
if (finishedCallback != null) finishedCallback.onResult(null);
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
view.setBackgroundColor((Integer)valueAnimator.getAnimatedValue());
}
});
anim.setDuration(duration);
anim.start();
}
private static void animateHeightGingerbread(ViewGroup viewGroup,
boolean isExtending) {
// No animations for Gingerbread

View File

@@ -0,0 +1,125 @@
package briarproject.activity;
import android.content.Intent;
import junit.framework.Assert;
import org.briarproject.BuildConfig;
import org.briarproject.TestUtils;
import org.briarproject.android.controller.handler.UiResultHandler;
import org.briarproject.android.forum.ForumActivity;
import org.briarproject.android.forum.ForumController;
import org.briarproject.android.forum.ForumEntry;
import org.briarproject.api.identity.AuthorId;
import org.briarproject.api.sync.GroupId;
import org.briarproject.api.sync.MessageId;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricGradleTestRunner;
import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static junit.framework.Assert.assertEquals;
import static junit.framework.Assert.assertTrue;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 21,
application = TestBriarApplication.class)
public class ForumActivityTest {
private final static String AUTHOR_1 = "Author 1";
private final static String AUTHOR_2 = "Author 2";
private final static String AUTHOR_3 = "Author 3";
private final static String AUTHOR_4 = "Author 4";
private final static String AUTHOR_5 = "Author 5";
private final static String AUTHOR_6 = "Author 6";
private final static String[] AUTHORS = {
AUTHOR_1, AUTHOR_2, AUTHOR_3, AUTHOR_4, AUTHOR_5, AUTHOR_6
};
/*
1
-> 2
-> 3
-> 4
5
6
*/
private final static int[] LEVELS = {
0, 1, 2, 3, 1, 0
};
private TestForumActivity forumActivity;
@Captor
private ArgumentCaptor<UiResultHandler<Boolean>> rc;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
Intent intent = new Intent();
intent.putExtra("briar.GROUP_ID", TestUtils.getRandomId());
forumActivity = Robolectric.buildActivity(TestForumActivity.class)
.withIntent(intent).create().resume().get();
}
private List<ForumEntry> getDummyData() {
ForumEntry[] forumEntries = new ForumEntry[6];
for (int i = 0; i < forumEntries.length; i++) {
forumEntries[i] =
new ForumEntry(new MessageId(TestUtils.getRandomId()),
AUTHORS[i], LEVELS[i], System.currentTimeMillis(),
AUTHORS[i], new AuthorId(TestUtils.getRandomId()));
}
return new ArrayList<ForumEntry>(Arrays.asList(forumEntries));
}
@Test
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();
Assert.assertNotNull(adapter);
// Cascade close
assertEquals(6, adapter.getItemCount());
adapter.hideDescendants(dummyData.get(2));
assertEquals(5, adapter.getItemCount());
adapter.hideDescendants(dummyData.get(1));
assertEquals(4, adapter.getItemCount());
adapter.hideDescendants(dummyData.get(0));
assertEquals(2, adapter.getItemCount());
assertTrue(dummyData.get(0).getText()
.equals(adapter.getVisibleEntry(0).getText()));
assertTrue(dummyData.get(5).getText()
.equals(adapter.getVisibleEntry(1).getText()));
// Cascade re-open
adapter.showDescendants(dummyData.get(0));
assertEquals(4, adapter.getItemCount());
adapter.showDescendants(dummyData.get(1));
assertEquals(5, adapter.getItemCount());
adapter.showDescendants(dummyData.get(2));
assertEquals(6, adapter.getItemCount());
assertTrue(dummyData.get(2).getText()
.equals(adapter.getVisibleEntry(2).getText()));
assertTrue(dummyData.get(4).getText()
.equals(adapter.getVisibleEntry(4).getText()));
}
}

View File

@@ -0,0 +1,42 @@
package briarproject.activity;
import org.briarproject.android.ActivityModule;
import org.briarproject.android.controller.BriarController;
import org.briarproject.android.controller.BriarControllerImpl;
import org.briarproject.android.forum.ForumActivity;
import org.briarproject.android.forum.ForumController;
import org.briarproject.android.forum.ForumControllerImpl;
import org.mockito.Mockito;
/**
* This class exposes the SetupController and offers the possibility to
* override it.
*/
public class TestForumActivity extends ForumActivity {
public ForumController getController() {
return forumController;
}
public ForumAdapter getAdapter() {
return forumAdapter;
}
protected ActivityModule getActivityModule() {
return new ActivityModule(this) {
@Override
protected BriarController provideBriarController(
BriarControllerImpl briarControllerImpl) {
BriarController c = Mockito.mock(BriarController.class);
Mockito.when(c.hasEncryptionKey()).thenReturn(true);
return c;
}
@Override
protected ForumController provideForumController(
ForumControllerImpl forumController) {
return Mockito.mock(ForumController.class);
}
};
}
}

View File

@@ -11,8 +11,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.inject.Inject;
public class MessageTreeImpl<T extends MessageTree.MessageNode>
implements MessageTree<T> {
@@ -26,11 +24,6 @@ public class MessageTreeImpl<T extends MessageTree.MessageNode>
}
};
@Inject
public MessageTreeImpl() {
}
@Override
public void clear() {
roots.clear();

View File

@@ -2,7 +2,6 @@ package org.briarproject.forum;
import org.briarproject.api.clients.ClientHelper;
import org.briarproject.api.clients.MessageQueueManager;
import org.briarproject.api.clients.MessageTree;
import org.briarproject.api.contact.ContactManager;
import org.briarproject.api.crypto.CryptoComponent;
import org.briarproject.api.data.MetadataEncoder;
@@ -10,14 +9,12 @@ import org.briarproject.api.db.DatabaseComponent;
import org.briarproject.api.forum.ForumFactory;
import org.briarproject.api.forum.ForumManager;
import org.briarproject.api.forum.ForumPostFactory;
import org.briarproject.api.forum.ForumPostHeader;
import org.briarproject.api.forum.ForumSharingManager;
import org.briarproject.api.identity.AuthorFactory;
import org.briarproject.api.lifecycle.LifecycleManager;
import org.briarproject.api.sync.GroupFactory;
import org.briarproject.api.sync.ValidationManager;
import org.briarproject.api.system.Clock;
import org.briarproject.clients.MessageTreeImpl;
import java.security.SecureRandom;
@@ -104,10 +101,4 @@ public class ForumModule {
return forumSharingManager;
}
@Provides
@Singleton
MessageTree<ForumPostHeader> provideForumMessageTree(
MessageTreeImpl<ForumPostHeader> messageTree) {
return messageTree;
}
}