Forum, nested discussions front end UI/UX, rev 2

This commit is contained in:
Ernir Erlingsson
2016-04-18 11:04:43 +02:00
parent 661140f623
commit 86039b81c0
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;
}
}