Merge branch '1881-thread-list-controller' into 'master'

Migrate ThreadListController to ViewModel

Closes #1881, #1873, and #1870

See merge request briar/briar!1336
This commit is contained in:
akwizgran
2021-01-29 15:10:16 +00:00
47 changed files with 1543 additions and 1999 deletions

View File

@@ -35,9 +35,11 @@ import org.briarproject.briar.android.forum.ForumModule;
import org.briarproject.briar.android.keyagreement.ContactExchangeModule;
import org.briarproject.briar.android.login.LoginModule;
import org.briarproject.briar.android.navdrawer.NavDrawerModule;
import org.briarproject.briar.android.privategroup.conversation.GroupConversationModule;
import org.briarproject.briar.android.settings.SettingsModule;
import org.briarproject.briar.android.privategroup.list.GroupListModule;
import org.briarproject.briar.android.reporting.DevReportModule;
import org.briarproject.briar.android.sharing.SharingModule;
import org.briarproject.briar.android.test.TestAvatarCreatorImpl;
import org.briarproject.briar.android.viewmodel.ViewModelModule;
import org.briarproject.briar.api.android.AndroidNotificationManager;
@@ -79,8 +81,10 @@ import static org.briarproject.briar.android.TestingConstants.IS_DEBUG_BUILD;
DevReportModule.class,
ContactListModule.class,
// below need to be within same scope as ViewModelProvider.Factory
ForumModule.BindsModule.class,
ForumModule.class,
GroupListModule.class,
GroupConversationModule.class,
SharingModule.class,
})
public class AppModule {

View File

@@ -32,7 +32,6 @@ import org.briarproject.briar.android.conversation.ImageFragment;
import org.briarproject.briar.android.forum.CreateForumActivity;
import org.briarproject.briar.android.forum.ForumActivity;
import org.briarproject.briar.android.forum.ForumListFragment;
import org.briarproject.briar.android.forum.ForumModule;
import org.briarproject.briar.android.fragment.ScreenFilterDialogFragment;
import org.briarproject.briar.android.introduction.ContactChooserFragment;
import org.briarproject.briar.android.introduction.IntroductionActivity;
@@ -50,7 +49,6 @@ import org.briarproject.briar.android.navdrawer.TransportsActivity;
import org.briarproject.briar.android.panic.PanicPreferencesActivity;
import org.briarproject.briar.android.panic.PanicResponderActivity;
import org.briarproject.briar.android.privategroup.conversation.GroupActivity;
import org.briarproject.briar.android.privategroup.conversation.GroupConversationModule;
import org.briarproject.briar.android.privategroup.creation.CreateGroupActivity;
import org.briarproject.briar.android.privategroup.creation.CreateGroupFragment;
import org.briarproject.briar.android.privategroup.creation.CreateGroupModule;
@@ -89,12 +87,10 @@ import dagger.Component;
ActivityModule.class,
BlogModule.class,
CreateGroupModule.class,
ForumModule.class,
GroupInvitationModule.class,
GroupConversationModule.class,
GroupMemberModule.class,
GroupRevealModule.class,
SharingModule.class
SharingModule.SharingLegacyModule.class
}, dependencies = AndroidComponent.class)
public interface ActivityComponent {

View File

@@ -14,7 +14,6 @@ import org.briarproject.briar.android.BriarApplication;
import org.briarproject.briar.android.DestroyableContext;
import org.briarproject.briar.android.Localizer;
import org.briarproject.briar.android.controller.ActivityLifecycleController;
import org.briarproject.briar.android.forum.ForumModule;
import org.briarproject.briar.android.fragment.BaseFragment;
import org.briarproject.briar.android.fragment.ScreenFilterDialogFragment;
import org.briarproject.briar.android.util.UiUtils;
@@ -87,7 +86,6 @@ public abstract class BaseActivity extends AppCompatActivity
activityComponent = DaggerActivityComponent.builder()
.androidComponent(applicationComponent)
.activityModule(getActivityModule())
.forumModule(getForumModule())
.build();
injectActivity(activityComponent);
super.onCreate(state);
@@ -122,11 +120,6 @@ public abstract class BaseActivity extends AppCompatActivity
return new ActivityModule(this);
}
// TODO use a test module where this is used in tests
protected ForumModule getForumModule() {
return new ForumModule();
}
@Override
protected void onStart() {
super.onStart();

View File

@@ -160,7 +160,6 @@ public abstract class BriarActivity extends BaseActivity {
* @param ownLayout true if the custom toolbar brings its own layout
* @return the Toolbar object or null if content view did not contain one
*/
@Nullable
protected Toolbar setUpCustomToolbar(boolean ownLayout) {
// Custom Toolbar
Toolbar toolbar = findViewById(R.id.toolbar);

View File

@@ -7,6 +7,7 @@ import java.util.Collection;
import androidx.annotation.UiThread;
@Deprecated
@NotNullByDefault
public interface SharingController {

View File

@@ -18,6 +18,7 @@ import javax.inject.Inject;
import androidx.annotation.UiThread;
@Deprecated
@NotNullByDefault
public class SharingControllerImpl implements SharingController, EventListener {

View File

@@ -6,52 +6,54 @@ import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.widget.Toast;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.briar.R;
import org.briarproject.briar.android.activity.ActivityComponent;
import org.briarproject.briar.android.controller.handler.UiResultExceptionHandler;
import org.briarproject.briar.android.forum.ForumController.ForumListener;
import org.briarproject.briar.android.sharing.ForumSharingStatusActivity;
import org.briarproject.briar.android.sharing.ShareForumActivity;
import org.briarproject.briar.android.threaded.ThreadItemAdapter;
import org.briarproject.briar.android.threaded.ThreadListActivity;
import org.briarproject.briar.android.threaded.ThreadListController;
import org.briarproject.briar.api.forum.Forum;
import org.briarproject.briar.android.threaded.ThreadListViewModel;
import javax.annotation.Nullable;
import javax.inject.Inject;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.lifecycle.ViewModelProvider;
import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP;
import static android.widget.Toast.LENGTH_SHORT;
import static org.briarproject.briar.android.activity.RequestCodes.REQUEST_SHARE_FORUM;
import static org.briarproject.briar.android.util.UiUtils.observeOnce;
import static org.briarproject.briar.api.forum.ForumConstants.MAX_FORUM_POST_TEXT_LENGTH;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
public class ForumActivity extends
ThreadListActivity<Forum, ForumPostItem, ThreadItemAdapter<ForumPostItem>>
implements ForumListener {
ThreadListActivity<ForumPostItem, ThreadItemAdapter<ForumPostItem>> {
@Inject
ForumController forumController;
ViewModelProvider.Factory viewModelFactory;
private ForumViewModel viewModel;
@Override
public void injectActivity(ActivityComponent component) {
component.inject(this);
viewModel = new ViewModelProvider(this, viewModelFactory)
.get(ForumViewModel.class);
}
@Override
protected ThreadListController<Forum, ForumPostItem> getController() {
return forumController;
protected ThreadListViewModel<ForumPostItem> getViewModel() {
return viewModel;
}
@Override
protected ThreadItemAdapter<ForumPostItem> createAdapter() {
return new ThreadItemAdapter<>(this);
}
@Override
@@ -59,36 +61,27 @@ public class ForumActivity extends
super.onCreate(state);
Toolbar toolbar = setUpCustomToolbar(false);
Intent i = getIntent();
String groupName = i.getStringExtra(GROUP_NAME);
if (groupName != null) setTitle(groupName);
else loadNamedGroup();
// Open member list on Toolbar click
if (toolbar != null) {
toolbar.setOnClickListener(v -> {
Intent i1 = new Intent(ForumActivity.this,
ForumSharingStatusActivity.class);
i1.putExtra(GROUP_ID, groupId.getBytes());
startActivity(i1);
});
toolbar.setOnClickListener(v -> {
Intent i = new Intent(ForumActivity.this,
ForumSharingStatusActivity.class);
i.putExtra(GROUP_ID, groupId.getBytes());
startActivity(i);
});
String groupName = getIntent().getStringExtra(GROUP_NAME);
if (groupName != null) {
setTitle(groupName);
} else {
observeOnce(viewModel.loadForum(), this, forum ->
setTitle(forum.getName())
);
}
}
@Override
protected void onNamedGroupLoaded(Forum forum) {
setTitle(forum.getName());
}
@Override
protected ThreadItemAdapter<ForumPostItem> createAdapter(
LinearLayoutManager layoutManager) {
return new ThreadItemAdapter<>(this, layoutManager);
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
protected void onActivityResult(int request, int result,
@Nullable Intent data) {
super.onActivityResult(request, result, data);
if (request == REQUEST_SHARE_FORUM && result == RESULT_OK) {
@@ -101,32 +94,31 @@ public class ForumActivity extends
// Inflate the menu items for use in the action bar
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.forum_actions, menu);
return super.onCreateOptionsMenu(menu);
super.onCreateOptionsMenu(menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
// Handle presses on the action bar items
switch (item.getItemId()) {
case R.id.action_forum_share:
Intent i2 = new Intent(this, ShareForumActivity.class);
i2.setFlags(FLAG_ACTIVITY_CLEAR_TOP);
i2.putExtra(GROUP_ID, groupId.getBytes());
startActivityForResult(i2, REQUEST_SHARE_FORUM);
return true;
case R.id.action_forum_sharing_status:
Intent i3 = new Intent(this, ForumSharingStatusActivity.class);
i3.setFlags(FLAG_ACTIVITY_CLEAR_TOP);
i3.putExtra(GROUP_ID, groupId.getBytes());
startActivity(i3);
return true;
case R.id.action_forum_delete:
showUnsubscribeDialog();
return true;
default:
return super.onOptionsItemSelected(item);
int itemId = item.getItemId();
if (itemId == R.id.action_forum_share) {
Intent i = new Intent(this, ShareForumActivity.class);
i.setFlags(FLAG_ACTIVITY_CLEAR_TOP);
i.putExtra(GROUP_ID, groupId.getBytes());
startActivityForResult(i, REQUEST_SHARE_FORUM);
return true;
} else if (itemId == R.id.action_forum_sharing_status) {
Intent i = new Intent(this, ForumSharingStatusActivity.class);
i.setFlags(FLAG_ACTIVITY_CLEAR_TOP);
i.putExtra(GROUP_ID, groupId.getBytes());
startActivity(i);
return true;
} else if (itemId == R.id.action_forum_delete) {
showUnsubscribeDialog();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
@@ -135,7 +127,7 @@ public class ForumActivity extends
}
private void showUnsubscribeDialog() {
OnClickListener okListener = (dialog, which) -> deleteForum();
OnClickListener okListener = (dialog, which) -> viewModel.deleteForum();
AlertDialog.Builder builder = new AlertDialog.Builder(this,
R.style.BriarDialogTheme);
builder.setTitle(getString(R.string.dialog_title_leave_forum));
@@ -145,27 +137,4 @@ public class ForumActivity extends
builder.show();
}
private void deleteForum() {
forumController.deleteNamedGroup(
new UiResultExceptionHandler<Void, DbException>(this) {
@Override
public void onResultUi(Void v) {
Toast.makeText(ForumActivity.this,
R.string.forum_left_toast, LENGTH_SHORT).show();
}
@Override
public void onExceptionUi(DbException exception) {
handleException(exception);
}
});
}
@Override
public void onForumLeft(ContactId c) {
sharingController.remove(c);
setToolbarSubTitle(sharingController.getTotalCount(),
sharingController.getOnlineCount());
}
}

View File

@@ -1,18 +0,0 @@
package org.briarproject.briar.android.forum;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.briar.android.threaded.ThreadListController;
import org.briarproject.briar.api.forum.Forum;
import androidx.annotation.UiThread;
@NotNullByDefault
interface ForumController extends ThreadListController<Forum, ForumPostItem> {
interface ForumListener extends ThreadListListener<ForumPostItem> {
@UiThread
void onForumLeft(ContactId c);
}
}

View File

@@ -1,185 +0,0 @@
package org.briarproject.briar.android.forum;
import org.briarproject.bramble.api.contact.Contact;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.crypto.CryptoExecutor;
import org.briarproject.bramble.api.db.DatabaseExecutor;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.event.Event;
import org.briarproject.bramble.api.event.EventBus;
import org.briarproject.bramble.api.identity.IdentityManager;
import org.briarproject.bramble.api.identity.LocalAuthor;
import org.briarproject.bramble.api.lifecycle.LifecycleManager;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.bramble.api.system.Clock;
import org.briarproject.briar.android.controller.handler.ResultExceptionHandler;
import org.briarproject.briar.android.forum.ForumController.ForumListener;
import org.briarproject.briar.android.threaded.ThreadListControllerImpl;
import org.briarproject.briar.api.android.AndroidNotificationManager;
import org.briarproject.briar.api.client.MessageTracker;
import org.briarproject.briar.api.client.MessageTracker.GroupCount;
import org.briarproject.briar.api.forum.Forum;
import org.briarproject.briar.api.forum.ForumInvitationResponse;
import org.briarproject.briar.api.forum.ForumManager;
import org.briarproject.briar.api.forum.ForumPost;
import org.briarproject.briar.api.forum.ForumPostHeader;
import org.briarproject.briar.api.forum.ForumSharingManager;
import org.briarproject.briar.api.forum.event.ForumInvitationResponseReceivedEvent;
import org.briarproject.briar.api.forum.event.ForumPostReceivedEvent;
import org.briarproject.briar.api.sharing.event.ContactLeftShareableEvent;
import java.util.ArrayList;
import java.util.Collection;
import java.util.concurrent.Executor;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;
import static java.lang.Math.max;
import static java.util.logging.Level.WARNING;
import static org.briarproject.bramble.util.LogUtils.logException;
@NotNullByDefault
class ForumControllerImpl extends
ThreadListControllerImpl<Forum, ForumPostItem, ForumPostHeader, ForumPost, ForumListener>
implements ForumController {
private static final Logger LOG =
Logger.getLogger(ForumControllerImpl.class.getName());
private final ForumManager forumManager;
private final ForumSharingManager forumSharingManager;
@Inject
ForumControllerImpl(@DatabaseExecutor Executor dbExecutor,
LifecycleManager lifecycleManager, IdentityManager identityManager,
@CryptoExecutor Executor cryptoExecutor,
ForumManager forumManager, ForumSharingManager forumSharingManager,
EventBus eventBus, Clock clock, MessageTracker messageTracker,
AndroidNotificationManager notificationManager) {
super(dbExecutor, lifecycleManager, identityManager, cryptoExecutor,
eventBus, clock, notificationManager, messageTracker);
this.forumManager = forumManager;
this.forumSharingManager = forumSharingManager;
}
@Override
public void onActivityStart() {
super.onActivityStart();
notificationManager.clearForumPostNotification(getGroupId());
}
@Override
public void eventOccurred(Event e) {
super.eventOccurred(e);
if (e instanceof ForumPostReceivedEvent) {
ForumPostReceivedEvent f = (ForumPostReceivedEvent) e;
if (f.getGroupId().equals(getGroupId())) {
LOG.info("Forum post received, adding...");
listener.onItemReceived(buildItem(f.getHeader(), f.getText()));
}
} else if (e instanceof ForumInvitationResponseReceivedEvent) {
ForumInvitationResponseReceivedEvent f =
(ForumInvitationResponseReceivedEvent) e;
ForumInvitationResponse r = f.getMessageHeader();
if (r.getShareableId().equals(getGroupId()) && r.wasAccepted()) {
LOG.info("Forum invitation was accepted");
listener.onInvitationAccepted(f.getContactId());
}
} else if (e instanceof ContactLeftShareableEvent) {
ContactLeftShareableEvent c = (ContactLeftShareableEvent) e;
if (c.getGroupId().equals(getGroupId())) {
LOG.info("Forum left by contact");
listener.onForumLeft(c.getContactId());
}
}
}
@Override
protected Forum loadNamedGroup() throws DbException {
return forumManager.getForum(getGroupId());
}
@Override
protected Collection<ForumPostHeader> loadHeaders() throws DbException {
return forumManager.getPostHeaders(getGroupId());
}
@Override
protected String loadMessageText(ForumPostHeader h) throws DbException {
return forumManager.getPostText(h.getId());
}
@Override
protected void markRead(MessageId id) throws DbException {
forumManager.setReadFlag(getGroupId(), id, true);
}
@Override
public void loadSharingContacts(
ResultExceptionHandler<Collection<ContactId>, DbException> handler) {
runOnDbThread(() -> {
try {
Collection<Contact> contacts =
forumSharingManager.getSharedWith(getGroupId());
Collection<ContactId> contactIds =
new ArrayList<>(contacts.size());
for (Contact c : contacts) contactIds.add(c.getId());
handler.onResult(contactIds);
} catch (DbException e) {
logException(LOG, WARNING, e);
handler.onException(e);
}
});
}
@Override
public void createAndStoreMessage(String text,
@Nullable ForumPostItem parentItem,
ResultExceptionHandler<ForumPostItem, DbException> handler) {
runOnDbThread(() -> {
try {
LocalAuthor author = identityManager.getLocalAuthor();
GroupCount count = forumManager.getGroupCount(getGroupId());
long timestamp = max(count.getLatestMsgTime() + 1,
clock.currentTimeMillis());
MessageId parentId = parentItem != null ?
parentItem.getId() : null;
createMessage(text, timestamp, parentId, author, handler);
} catch (DbException e) {
logException(LOG, WARNING, e);
handler.onException(e);
}
});
}
private void createMessage(String text, long timestamp,
@Nullable MessageId parentId, LocalAuthor author,
ResultExceptionHandler<ForumPostItem, DbException> handler) {
cryptoExecutor.execute(() -> {
LOG.info("Creating forum post...");
ForumPost msg = forumManager.createLocalPost(getGroupId(), text,
timestamp, parentId, author);
storePost(msg, text, handler);
});
}
@Override
protected ForumPostHeader addLocalMessage(ForumPost p) throws DbException {
return forumManager.addLocalPost(p);
}
@Override
protected void deleteNamedGroup(Forum forum) throws DbException {
forumManager.removeForum(forum);
}
@Override
protected ForumPostItem buildItem(ForumPostHeader header, String text) {
return new ForumPostItem(header, text);
}
}

View File

@@ -1,32 +1,23 @@
package org.briarproject.briar.android.forum;
import org.briarproject.briar.android.activity.ActivityScope;
import org.briarproject.briar.android.activity.BaseActivity;
import org.briarproject.briar.android.viewmodel.ViewModelKey;
import androidx.lifecycle.ViewModel;
import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import dagger.multibindings.IntoMap;
@Module
public class ForumModule {
public interface ForumModule {
@Module
public interface BindsModule {
@Binds
@IntoMap
@ViewModelKey(ForumListViewModel.class)
ViewModel bindForumListViewModel(ForumListViewModel forumListViewModel);
}
@Binds
@IntoMap
@ViewModelKey(ForumListViewModel.class)
ViewModel bindForumListViewModel(ForumListViewModel forumListViewModel);
@ActivityScope
@Provides
ForumController provideForumController(BaseActivity activity,
ForumControllerImpl forumController) {
activity.addLifecycleController(forumController);
return forumController;
}
@Binds
@IntoMap
@ViewModelKey(ForumViewModel.class)
ViewModel bindForumViewModel(ForumViewModel forumViewModel);
}

View File

@@ -6,7 +6,6 @@ import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.briar.android.threaded.ThreadItem;
import org.briarproject.briar.api.forum.ForumPostHeader;
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;
@NotThreadSafe
@@ -17,9 +16,4 @@ class ForumPostItem extends ThreadItem {
h.getAuthorInfo(), h.isRead());
}
ForumPostItem(MessageId messageId, @Nullable MessageId parentId,
String text, long timestamp, Author author, AuthorInfo authorInfo) {
super(messageId, parentId, text, timestamp, author, authorInfo, true);
}
}

View File

@@ -0,0 +1,231 @@
package org.briarproject.briar.android.forum;
import android.app.Application;
import android.widget.Toast;
import org.briarproject.bramble.api.contact.Contact;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.crypto.CryptoExecutor;
import org.briarproject.bramble.api.db.DatabaseExecutor;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.db.Transaction;
import org.briarproject.bramble.api.db.TransactionManager;
import org.briarproject.bramble.api.event.Event;
import org.briarproject.bramble.api.event.EventBus;
import org.briarproject.bramble.api.identity.IdentityManager;
import org.briarproject.bramble.api.identity.LocalAuthor;
import org.briarproject.bramble.api.lifecycle.LifecycleManager;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.bramble.api.system.AndroidExecutor;
import org.briarproject.bramble.api.system.Clock;
import org.briarproject.briar.R;
import org.briarproject.briar.android.sharing.SharingController;
import org.briarproject.briar.android.threaded.ThreadListViewModel;
import org.briarproject.briar.api.android.AndroidNotificationManager;
import org.briarproject.briar.api.client.MessageTracker;
import org.briarproject.briar.api.client.MessageTracker.GroupCount;
import org.briarproject.briar.api.forum.Forum;
import org.briarproject.briar.api.forum.ForumInvitationResponse;
import org.briarproject.briar.api.forum.ForumManager;
import org.briarproject.briar.api.forum.ForumPost;
import org.briarproject.briar.api.forum.ForumPostHeader;
import org.briarproject.briar.api.forum.ForumSharingManager;
import org.briarproject.briar.api.forum.event.ForumInvitationResponseReceivedEvent;
import org.briarproject.briar.api.forum.event.ForumPostReceivedEvent;
import org.briarproject.briar.api.sharing.event.ContactLeftShareableEvent;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import static android.widget.Toast.LENGTH_SHORT;
import static java.lang.Math.max;
import static java.util.logging.Level.WARNING;
import static java.util.logging.Logger.getLogger;
import static org.briarproject.bramble.util.LogUtils.logDuration;
import static org.briarproject.bramble.util.LogUtils.logException;
import static org.briarproject.bramble.util.LogUtils.now;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
class ForumViewModel extends ThreadListViewModel<ForumPostItem> {
private static final Logger LOG = getLogger(ForumViewModel.class.getName());
private final ForumManager forumManager;
private final ForumSharingManager forumSharingManager;
@Inject
ForumViewModel(Application application,
@DatabaseExecutor Executor dbExecutor,
LifecycleManager lifecycleManager,
TransactionManager db,
AndroidExecutor androidExecutor,
IdentityManager identityManager,
AndroidNotificationManager notificationManager,
SharingController sharingController,
@CryptoExecutor Executor cryptoExecutor,
Clock clock,
MessageTracker messageTracker,
EventBus eventBus,
ForumManager forumManager,
ForumSharingManager forumSharingManager) {
super(application, dbExecutor, lifecycleManager, db, androidExecutor,
identityManager, notificationManager, sharingController,
cryptoExecutor, clock, messageTracker, eventBus);
this.forumManager = forumManager;
this.forumSharingManager = forumSharingManager;
}
@Override
public void eventOccurred(Event e) {
if (e instanceof ForumPostReceivedEvent) {
ForumPostReceivedEvent f = (ForumPostReceivedEvent) e;
if (f.getGroupId().equals(groupId)) {
LOG.info("Forum post received, adding...");
ForumPostItem item =
new ForumPostItem(f.getHeader(), f.getText());
addItem(item, false);
}
} else if (e instanceof ForumInvitationResponseReceivedEvent) {
ForumInvitationResponseReceivedEvent f =
(ForumInvitationResponseReceivedEvent) e;
ForumInvitationResponse r = f.getMessageHeader();
if (r.getShareableId().equals(groupId) && r.wasAccepted()) {
LOG.info("Forum invitation was accepted");
sharingController.add(f.getContactId());
}
} else if (e instanceof ContactLeftShareableEvent) {
ContactLeftShareableEvent c = (ContactLeftShareableEvent) e;
if (c.getGroupId().equals(groupId)) {
LOG.info("Forum left by contact");
sharingController.remove(c.getContactId());
}
} else {
super.eventOccurred(e);
}
}
protected void clearNotifications() {
notificationManager.clearForumPostNotification(groupId);
}
LiveData<Forum> loadForum() {
MutableLiveData<Forum> forum = new MutableLiveData<>();
runOnDbThread(() -> {
try {
Forum f = forumManager.getForum(groupId);
forum.postValue(f);
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
return forum;
}
@Override
public void loadItems() {
loadList(txn -> {
long start = now();
List<ForumPostHeader> headers =
forumManager.getPostHeaders(txn, groupId);
logDuration(LOG, "Loading headers", start);
start = now();
List<ForumPostItem> items = new ArrayList<>();
for (ForumPostHeader header : headers) {
items.add(loadItem(txn, header));
}
logDuration(LOG, "Loading bodies and creating items", start);
return items;
}, this::setItems);
}
private ForumPostItem loadItem(Transaction txn, ForumPostHeader header)
throws DbException {
String text = forumManager.getPostText(txn, header.getId());
return new ForumPostItem(header, text);
}
@Override
public void createAndStoreMessage(String text,
@Nullable MessageId parentId) {
runOnDbThread(() -> {
try {
LocalAuthor author = identityManager.getLocalAuthor();
GroupCount count = forumManager.getGroupCount(groupId);
long timestamp = max(count.getLatestMsgTime() + 1,
clock.currentTimeMillis());
createMessage(text, timestamp, parentId, author);
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
}
private void createMessage(String text, long timestamp,
@Nullable MessageId parentId, LocalAuthor author) {
cryptoExecutor.execute(() -> {
LOG.info("Creating forum post...");
ForumPost msg = forumManager.createLocalPost(groupId, text,
timestamp, parentId, author);
storePost(msg, text);
});
}
private void storePost(ForumPost msg, String text) {
runOnDbThread(false, txn -> {
long start = now();
ForumPostHeader header = forumManager.addLocalPost(txn, msg);
logDuration(LOG, "Storing forum post", start);
txn.attach(() -> {
ForumPostItem item = new ForumPostItem(header, text);
addItem(item, true);
});
}, e -> logException(LOG, WARNING, e));
}
@Override
protected void markItemRead(ForumPostItem item) {
runOnDbThread(() -> {
try {
forumManager.setReadFlag(groupId, item.getId(), true);
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
}
public void loadSharingContacts() {
runOnDbThread(true, txn -> {
Collection<Contact> contacts =
forumSharingManager.getSharedWith(txn, groupId);
Collection<ContactId> contactIds = new ArrayList<>(contacts.size());
for (Contact c : contacts) contactIds.add(c.getId());
txn.attach(() -> sharingController.addAll(contactIds));
}, e -> logException(LOG, WARNING, e));
}
void deleteForum() {
runOnDbThread(() -> {
try {
Forum f = forumManager.getForum(groupId);
forumManager.removeForum(f);
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
Toast.makeText(getApplication(), R.string.forum_left_toast,
LENGTH_SHORT).show();
}
}

View File

@@ -1,67 +1,60 @@
package org.briarproject.briar.android.privategroup.conversation;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.identity.AuthorId;
import org.briarproject.bramble.api.identity.LocalAuthor;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.briar.R;
import org.briarproject.briar.android.activity.ActivityComponent;
import org.briarproject.briar.android.controller.handler.UiExceptionHandler;
import org.briarproject.briar.android.controller.handler.UiResultExceptionHandler;
import org.briarproject.briar.android.privategroup.conversation.GroupController.GroupListener;
import org.briarproject.briar.android.privategroup.creation.GroupInviteActivity;
import org.briarproject.briar.android.privategroup.memberlist.GroupMemberListActivity;
import org.briarproject.briar.android.privategroup.reveal.RevealContactsActivity;
import org.briarproject.briar.android.threaded.ThreadListActivity;
import org.briarproject.briar.android.threaded.ThreadListController;
import org.briarproject.briar.api.privategroup.PrivateGroup;
import org.briarproject.briar.api.privategroup.Visibility;
import org.briarproject.briar.android.threaded.ThreadListViewModel;
import javax.annotation.Nullable;
import javax.inject.Inject;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.lifecycle.ViewModelProvider;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static org.briarproject.bramble.api.nullsafety.NullSafety.requireNonNull;
import static org.briarproject.briar.android.activity.RequestCodes.REQUEST_GROUP_INVITE;
import static org.briarproject.briar.android.util.UiUtils.observeOnce;
import static org.briarproject.briar.api.privategroup.PrivateGroupConstants.MAX_GROUP_POST_TEXT_LENGTH;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
public class GroupActivity extends
ThreadListActivity<PrivateGroup, GroupMessageItem, GroupMessageAdapter>
implements GroupListener, OnClickListener {
ThreadListActivity<GroupMessageItem, GroupMessageAdapter> {
@Inject
GroupController controller;
ViewModelProvider.Factory viewModelFactory;
@Nullable
private Boolean isCreator = null;
private boolean isDissolved = false;
private MenuItem revealMenuItem, inviteMenuItem, leaveMenuItem,
dissolveMenuItem;
private GroupViewModel viewModel;
@Override
public void injectActivity(ActivityComponent component) {
component.inject(this);
viewModel = new ViewModelProvider(this, viewModelFactory)
.get(GroupViewModel.class);
}
@Override
protected ThreadListController<PrivateGroup, GroupMessageItem> getController() {
return controller;
protected ThreadListViewModel<GroupMessageItem> getViewModel() {
return viewModel;
}
@Override
protected GroupMessageAdapter createAdapter() {
return new GroupMessageAdapter(this);
}
@Override
@@ -69,65 +62,27 @@ public class GroupActivity extends
super.onCreate(state);
Toolbar toolbar = setUpCustomToolbar(false);
Intent i = getIntent();
String groupName = i.getStringExtra(GROUP_NAME);
if (groupName != null) setTitle(groupName);
loadNamedGroup();
// Open member list on Toolbar click
if (toolbar != null) {
toolbar.setOnClickListener(v -> {
Intent i1 = new Intent(GroupActivity.this,
GroupMemberListActivity.class);
i1.putExtra(GROUP_ID, groupId.getBytes());
startActivity(i1);
});
}
toolbar.setOnClickListener(v -> {
Intent i = new Intent(GroupActivity.this,
GroupMemberListActivity.class);
i.putExtra(GROUP_ID, groupId.getBytes());
startActivity(i);
});
String groupName = getIntent().getStringExtra(GROUP_NAME);
if (groupName != null) setTitle(groupName);
observeOnce(viewModel.getPrivateGroup(), this, privateGroup ->
setTitle(privateGroup.getName())
);
observeOnce(viewModel.isCreator(), this, adapter::setIsCreator);
// start with group disabled and enable when not dissolved
setGroupEnabled(false);
}
@Override
protected GroupMessageAdapter createAdapter(
LinearLayoutManager layoutManager) {
return new GroupMessageAdapter(this, layoutManager);
}
@Override
protected void loadItems() {
controller.isDissolved(
new UiResultExceptionHandler<Boolean, DbException>(this) {
@Override
public void onResultUi(Boolean isDissolved) {
setGroupEnabled(!isDissolved);
GroupActivity.super.loadItems();
}
@Override
public void onExceptionUi(DbException exception) {
handleException(exception);
}
});
}
@Override
protected void onNamedGroupLoaded(PrivateGroup group) {
setTitle(group.getName());
controller.loadLocalAuthor(
new UiResultExceptionHandler<LocalAuthor, DbException>(this) {
@Override
public void onResultUi(LocalAuthor author) {
isCreator = group.getCreator().equals(author);
adapter.setPerspective(isCreator);
showMenuItems();
}
@Override
public void onExceptionUi(DbException exception) {
handleException(exception);
}
});
viewModel.isDissolved().observeEvent(this, dissolved -> {
setGroupEnabled(!dissolved);
if (dissolved) onGroupDissolved();
});
}
@Override
@@ -136,74 +91,61 @@ public class GroupActivity extends
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.group_actions, menu);
revealMenuItem = menu.findItem(R.id.action_group_reveal);
inviteMenuItem = menu.findItem(R.id.action_group_invite);
leaveMenuItem = menu.findItem(R.id.action_group_leave);
dissolveMenuItem = menu.findItem(R.id.action_group_dissolve);
// all role-dependent items are invisible until we know our role
revealMenuItem.setVisible(false);
inviteMenuItem.setVisible(false);
leaveMenuItem.setVisible(false);
dissolveMenuItem.setVisible(false);
// show items based on role
showMenuItems();
return super.onCreateOptionsMenu(menu);
// show items based on role (which will not change, so observe once)
observeOnce(viewModel.isCreator(), this, isCreator -> {
menu.findItem(R.id.action_group_reveal).setVisible(!isCreator);
menu.findItem(R.id.action_group_invite).setVisible(isCreator);
menu.findItem(R.id.action_group_leave).setVisible(!isCreator);
menu.findItem(R.id.action_group_dissolve).setVisible(isCreator);
});
super.onCreateOptionsMenu(menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_group_member_list:
Intent i1 = new Intent(this, GroupMemberListActivity.class);
i1.putExtra(GROUP_ID, groupId.getBytes());
startActivity(i1);
return true;
case R.id.action_group_reveal:
if (isCreator == null || isCreator)
throw new IllegalStateException();
Intent i2 = new Intent(this, RevealContactsActivity.class);
i2.putExtra(GROUP_ID, groupId.getBytes());
startActivity(i2);
return true;
case R.id.action_group_invite:
if (isCreator == null || !isCreator)
throw new IllegalStateException();
Intent i3 = new Intent(this, GroupInviteActivity.class);
i3.putExtra(GROUP_ID, groupId.getBytes());
startActivityForResult(i3, REQUEST_GROUP_INVITE);
return true;
case R.id.action_group_leave:
if (isCreator == null || isCreator)
throw new IllegalStateException();
showLeaveGroupDialog();
return true;
case R.id.action_group_dissolve:
if (isCreator == null || !isCreator)
throw new IllegalStateException();
showDissolveGroupDialog();
default:
return super.onOptionsItemSelected(item);
int itemId = item.getItemId();
if (itemId == R.id.action_group_member_list) {
Intent i = new Intent(this, GroupMemberListActivity.class);
i.putExtra(GROUP_ID, groupId.getBytes());
startActivity(i);
return true;
} else if (itemId == R.id.action_group_reveal) {
if (requireNonNull(viewModel.isCreator().getValue()))
throw new IllegalStateException();
Intent i = new Intent(this, RevealContactsActivity.class);
i.putExtra(GROUP_ID, groupId.getBytes());
startActivity(i);
return true;
} else if (itemId == R.id.action_group_invite) {
if (!requireNonNull(viewModel.isCreator().getValue()))
throw new IllegalStateException();
Intent i = new Intent(this, GroupInviteActivity.class);
i.putExtra(GROUP_ID, groupId.getBytes());
startActivityForResult(i, REQUEST_GROUP_INVITE);
return true;
} else if (itemId == R.id.action_group_leave) {
if (requireNonNull(viewModel.isCreator().getValue()))
throw new IllegalStateException();
showLeaveGroupDialog();
return true;
} else if (itemId == R.id.action_group_dissolve) {
if (!requireNonNull(viewModel.isCreator().getValue()))
throw new IllegalStateException();
showDissolveGroupDialog();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
protected void onActivityResult(int request, int result,
@Nullable Intent data) {
if (request == REQUEST_GROUP_INVITE && result == RESULT_OK) {
displaySnackbar(R.string.groups_invitation_sent);
} else super.onActivityResult(request, result, data);
}
@Override
public void onItemReceived(GroupMessageItem item) {
super.onItemReceived(item);
if (item instanceof JoinMessageItem) {
if (((JoinMessageItem) item).isInitial()) loadSharingContacts();
}
}
@Override
protected int getMaxTextLength() {
return MAX_GROUP_POST_TEXT_LENGTH;
@@ -211,11 +153,11 @@ public class GroupActivity extends
@Override
public void onReplyClick(GroupMessageItem item) {
if (!isDissolved) super.onReplyClick(item);
Boolean isDissolved = viewModel.isDissolved().getLastValue();
if (isDissolved != null && !isDissolved) super.onReplyClick(item);
}
private void setGroupEnabled(boolean enabled) {
isDissolved = !enabled;
sendController.setReady(enabled);
list.getRecyclerView().setAlpha(enabled ? 1f : 0.5f);
@@ -227,21 +169,13 @@ public class GroupActivity extends
}
}
private void showMenuItems() {
// we need to have the menu items and know if we are the creator
if (leaveMenuItem == null || isCreator == null) return;
revealMenuItem.setVisible(!isCreator);
inviteMenuItem.setVisible(isCreator);
leaveMenuItem.setVisible(!isCreator);
dissolveMenuItem.setVisible(isCreator);
}
private void showLeaveGroupDialog() {
AlertDialog.Builder builder =
new AlertDialog.Builder(this, R.style.BriarDialogTheme);
builder.setTitle(getString(R.string.groups_leave_dialog_title));
builder.setMessage(getString(R.string.groups_leave_dialog_message));
builder.setNegativeButton(R.string.dialog_button_leave, this);
builder.setNegativeButton(R.string.dialog_button_leave,
(d, w) -> deleteGroup());
builder.setPositiveButton(R.string.cancel, null);
builder.show();
}
@@ -251,37 +185,19 @@ public class GroupActivity extends
new AlertDialog.Builder(this, R.style.BriarDialogTheme);
builder.setTitle(getString(R.string.groups_dissolve_dialog_title));
builder.setMessage(getString(R.string.groups_dissolve_dialog_message));
builder.setNegativeButton(R.string.groups_dissolve_button, this);
builder.setNegativeButton(R.string.groups_dissolve_button,
(d, w) -> deleteGroup());
builder.setPositiveButton(R.string.cancel, null);
builder.show();
}
@Override
public void onClick(DialogInterface dialog, int which) {
controller.deleteNamedGroup(
new UiExceptionHandler<DbException>(this) {
// The activity is going to be destroyed by the
// GroupRemovedEvent being fired
@Override
public void onExceptionUi(DbException exception) {
handleException(exception);
}
});
private void deleteGroup() {
// The activity is going to be destroyed by the
// GroupRemovedEvent being fired
viewModel.deletePrivateGroup();
}
@Override
public void onContactRelationshipRevealed(AuthorId memberId, ContactId c,
Visibility v) {
adapter.updateVisibility(memberId, v);
sharingController.add(c);
setToolbarSubTitle(sharingController.getTotalCount(),
sharingController.getOnlineCount());
}
@Override
public void onGroupDissolved() {
setGroupEnabled(false);
private void onGroupDissolved() {
AlertDialog.Builder builder =
new AlertDialog.Builder(this, R.style.BriarDialogTheme);
builder.setTitle(getString(R.string.groups_dissolved_dialog_title));

View File

@@ -1,33 +0,0 @@
package org.briarproject.briar.android.privategroup.conversation;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.identity.AuthorId;
import org.briarproject.bramble.api.identity.LocalAuthor;
import org.briarproject.briar.android.controller.handler.ResultExceptionHandler;
import org.briarproject.briar.android.threaded.ThreadListController;
import org.briarproject.briar.api.privategroup.PrivateGroup;
import org.briarproject.briar.api.privategroup.Visibility;
import androidx.annotation.UiThread;
public interface GroupController
extends ThreadListController<PrivateGroup, GroupMessageItem> {
void loadLocalAuthor(
ResultExceptionHandler<LocalAuthor, DbException> handler);
void isDissolved(
ResultExceptionHandler<Boolean, DbException> handler);
interface GroupListener extends ThreadListListener<GroupMessageItem> {
@UiThread
void onContactRelationshipRevealed(AuthorId memberId,
ContactId contactId, Visibility v);
@UiThread
void onGroupDissolved();
}
}

View File

@@ -1,242 +0,0 @@
package org.briarproject.briar.android.privategroup.conversation;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.crypto.CryptoExecutor;
import org.briarproject.bramble.api.db.DatabaseExecutor;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.event.Event;
import org.briarproject.bramble.api.event.EventBus;
import org.briarproject.bramble.api.identity.IdentityManager;
import org.briarproject.bramble.api.identity.LocalAuthor;
import org.briarproject.bramble.api.lifecycle.LifecycleManager;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.bramble.api.system.Clock;
import org.briarproject.briar.android.controller.handler.ResultExceptionHandler;
import org.briarproject.briar.android.privategroup.conversation.GroupController.GroupListener;
import org.briarproject.briar.android.threaded.ThreadListControllerImpl;
import org.briarproject.briar.api.android.AndroidNotificationManager;
import org.briarproject.briar.api.client.MessageTracker;
import org.briarproject.briar.api.client.MessageTracker.GroupCount;
import org.briarproject.briar.api.privategroup.GroupMember;
import org.briarproject.briar.api.privategroup.GroupMessage;
import org.briarproject.briar.api.privategroup.GroupMessageFactory;
import org.briarproject.briar.api.privategroup.GroupMessageHeader;
import org.briarproject.briar.api.privategroup.JoinMessageHeader;
import org.briarproject.briar.api.privategroup.PrivateGroup;
import org.briarproject.briar.api.privategroup.PrivateGroupManager;
import org.briarproject.briar.api.privategroup.event.ContactRelationshipRevealedEvent;
import org.briarproject.briar.api.privategroup.event.GroupDissolvedEvent;
import org.briarproject.briar.api.privategroup.event.GroupInvitationResponseReceivedEvent;
import org.briarproject.briar.api.privategroup.event.GroupMessageAddedEvent;
import org.briarproject.briar.api.privategroup.invitation.GroupInvitationResponse;
import java.util.ArrayList;
import java.util.Collection;
import java.util.concurrent.Executor;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;
import static java.lang.Math.max;
import static java.util.logging.Level.WARNING;
import static org.briarproject.bramble.util.LogUtils.logException;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
class GroupControllerImpl extends
ThreadListControllerImpl<PrivateGroup, GroupMessageItem, GroupMessageHeader, GroupMessage, GroupListener>
implements GroupController {
private static final Logger LOG =
Logger.getLogger(GroupControllerImpl.class.getName());
private final PrivateGroupManager privateGroupManager;
private final GroupMessageFactory groupMessageFactory;
@Inject
GroupControllerImpl(@DatabaseExecutor Executor dbExecutor,
LifecycleManager lifecycleManager, IdentityManager identityManager,
@CryptoExecutor Executor cryptoExecutor,
PrivateGroupManager privateGroupManager,
GroupMessageFactory groupMessageFactory, EventBus eventBus,
MessageTracker messageTracker, Clock clock,
AndroidNotificationManager notificationManager) {
super(dbExecutor, lifecycleManager, identityManager, cryptoExecutor,
eventBus, clock, notificationManager, messageTracker);
this.privateGroupManager = privateGroupManager;
this.groupMessageFactory = groupMessageFactory;
}
@Override
public void onActivityStart() {
super.onActivityStart();
notificationManager.clearGroupMessageNotification(getGroupId());
}
@Override
public void eventOccurred(Event e) {
super.eventOccurred(e);
if (e instanceof GroupMessageAddedEvent) {
GroupMessageAddedEvent g = (GroupMessageAddedEvent) e;
if (!g.isLocal() && g.getGroupId().equals(getGroupId())) {
LOG.info("Group message received, adding...");
listener.onItemReceived(buildItem(g.getHeader(), g.getText()));
}
} else if (e instanceof ContactRelationshipRevealedEvent) {
ContactRelationshipRevealedEvent c =
(ContactRelationshipRevealedEvent) e;
if (getGroupId().equals(c.getGroupId())) {
listener.onContactRelationshipRevealed(c.getMemberId(),
c.getContactId(), c.getVisibility());
}
} else if (e instanceof GroupInvitationResponseReceivedEvent) {
GroupInvitationResponseReceivedEvent g =
(GroupInvitationResponseReceivedEvent) e;
GroupInvitationResponse r = g.getMessageHeader();
if (getGroupId().equals(r.getShareableId()) && r.wasAccepted()) {
listener.onInvitationAccepted(g.getContactId());
}
} else if (e instanceof GroupDissolvedEvent) {
GroupDissolvedEvent g = (GroupDissolvedEvent) e;
if (getGroupId().equals(g.getGroupId())) {
listener.onGroupDissolved();
}
}
}
@Override
protected PrivateGroup loadNamedGroup() throws DbException {
return privateGroupManager.getPrivateGroup(getGroupId());
}
@Override
protected Collection<GroupMessageHeader> loadHeaders() throws DbException {
return privateGroupManager.getHeaders(getGroupId());
}
@Override
protected String loadMessageText(GroupMessageHeader header)
throws DbException {
if (header instanceof JoinMessageHeader) {
// will be looked up later
return "";
}
return privateGroupManager.getMessageText(header.getId());
}
@Override
protected void markRead(MessageId id) throws DbException {
privateGroupManager.setReadFlag(getGroupId(), id, true);
}
@Override
public void loadSharingContacts(
ResultExceptionHandler<Collection<ContactId>, DbException> handler) {
runOnDbThread(() -> {
try {
Collection<GroupMember> members =
privateGroupManager.getMembers(getGroupId());
Collection<ContactId> contactIds = new ArrayList<>();
for (GroupMember m : members) {
if (m.getContactId() != null)
contactIds.add(m.getContactId());
}
handler.onResult(contactIds);
} catch (DbException e) {
logException(LOG, WARNING, e);
handler.onException(e);
}
});
}
@Override
public void createAndStoreMessage(String text,
@Nullable GroupMessageItem parentItem,
ResultExceptionHandler<GroupMessageItem, DbException> handler) {
runOnDbThread(() -> {
try {
LocalAuthor author = identityManager.getLocalAuthor();
MessageId parentId = null;
MessageId previousMsgId =
privateGroupManager.getPreviousMsgId(getGroupId());
GroupCount count =
privateGroupManager.getGroupCount(getGroupId());
long timestamp = count.getLatestMsgTime();
if (parentItem != null) parentId = parentItem.getId();
timestamp = max(clock.currentTimeMillis(), timestamp + 1);
createMessage(text, timestamp, parentId, author, previousMsgId,
handler);
} catch (DbException e) {
logException(LOG, WARNING, e);
handler.onException(e);
}
});
}
private void createMessage(String text, long timestamp,
@Nullable MessageId parentId, LocalAuthor author,
MessageId previousMsgId,
ResultExceptionHandler<GroupMessageItem, DbException> handler) {
cryptoExecutor.execute(() -> {
LOG.info("Creating group message...");
GroupMessage msg = groupMessageFactory
.createGroupMessage(getGroupId(), timestamp,
parentId, author, text, previousMsgId);
storePost(msg, text, handler);
});
}
@Override
protected GroupMessageHeader addLocalMessage(GroupMessage message)
throws DbException {
return privateGroupManager.addLocalMessage(message);
}
@Override
protected void deleteNamedGroup(PrivateGroup group) throws DbException {
privateGroupManager.removePrivateGroup(group.getId());
}
@Override
protected GroupMessageItem buildItem(GroupMessageHeader header,
String text) {
if (header instanceof JoinMessageHeader) {
return new JoinMessageItem((JoinMessageHeader) header, text);
}
return new GroupMessageItem(header, text);
}
@Override
public void loadLocalAuthor(
ResultExceptionHandler<LocalAuthor, DbException> handler) {
runOnDbThread(() -> {
try {
LocalAuthor author = identityManager.getLocalAuthor();
handler.onResult(author);
} catch (DbException e) {
logException(LOG, WARNING, e);
handler.onException(e);
}
});
}
@Override
public void isDissolved(
ResultExceptionHandler<Boolean, DbException> handler) {
runOnDbThread(() -> {
try {
boolean isDissolved =
privateGroupManager.isDissolved(getGroupId());
handler.onResult(isDissolved);
} catch (DbException e) {
logException(LOG, WARNING, e);
handler.onException(e);
}
});
}
}

View File

@@ -1,19 +1,18 @@
package org.briarproject.briar.android.privategroup.conversation;
import org.briarproject.briar.android.activity.ActivityScope;
import org.briarproject.briar.android.activity.BaseActivity;
import org.briarproject.briar.android.viewmodel.ViewModelKey;
import androidx.lifecycle.ViewModel;
import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import dagger.multibindings.IntoMap;
@Module
public class GroupConversationModule {
public interface GroupConversationModule {
@Binds
@IntoMap
@ViewModelKey(GroupViewModel.class)
ViewModel bindGroupViewModel(GroupViewModel groupViewModel);
@ActivityScope
@Provides
GroupController provideGroupController(BaseActivity activity,
GroupControllerImpl groupController) {
activity.addLifecycleController(groupController);
return groupController;
}
}

View File

@@ -4,19 +4,14 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import org.briarproject.bramble.api.identity.AuthorId;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.briar.R;
import org.briarproject.briar.android.threaded.BaseThreadItemViewHolder;
import org.briarproject.briar.android.threaded.ThreadItemAdapter;
import org.briarproject.briar.android.threaded.ThreadPostViewHolder;
import org.briarproject.briar.api.privategroup.Visibility;
import androidx.annotation.LayoutRes;
import androidx.annotation.UiThread;
import androidx.recyclerview.widget.LinearLayoutManager;
import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
@UiThread
@NotNullByDefault
@@ -24,15 +19,14 @@ class GroupMessageAdapter extends ThreadItemAdapter<GroupMessageItem> {
private boolean isCreator = false;
GroupMessageAdapter(ThreadItemListener<GroupMessageItem> listener,
LinearLayoutManager layoutManager) {
super(listener, layoutManager);
GroupMessageAdapter(ThreadItemListener<GroupMessageItem> listener) {
super(listener);
}
@LayoutRes
@Override
public int getItemViewType(int position) {
GroupMessageItem item = items.get(position);
GroupMessageItem item = getItem(position);
return item.getLayout();
}
@@ -47,30 +41,9 @@ class GroupMessageAdapter extends ThreadItemAdapter<GroupMessageItem> {
return new ThreadPostViewHolder<>(v);
}
void setPerspective(boolean isCreator) {
void setIsCreator(boolean isCreator) {
this.isCreator = isCreator;
notifyDataSetChanged();
}
void updateVisibility(AuthorId memberId, Visibility v) {
int position = findItemPosition(memberId);
if (position != NO_POSITION) {
GroupMessageItem item = items.get(position);
if (item instanceof JoinMessageItem) {
((JoinMessageItem) item).setVisibility(v);
notifyItemChanged(findItemPosition(item), item);
}
}
}
private int findItemPosition(AuthorId a) {
int count = items.size();
for (int i = 0; i < count; i++) {
GroupMessageItem item = items.get(i);
if (item.getAuthor().getId().equals(a))
return i;
}
return NO_POSITION; // Not found
}
}

View File

@@ -0,0 +1,289 @@
package org.briarproject.briar.android.privategroup.conversation;
import android.app.Application;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.crypto.CryptoExecutor;
import org.briarproject.bramble.api.db.DatabaseExecutor;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.db.Transaction;
import org.briarproject.bramble.api.db.TransactionManager;
import org.briarproject.bramble.api.event.Event;
import org.briarproject.bramble.api.event.EventBus;
import org.briarproject.bramble.api.identity.Author;
import org.briarproject.bramble.api.identity.IdentityManager;
import org.briarproject.bramble.api.identity.LocalAuthor;
import org.briarproject.bramble.api.lifecycle.LifecycleManager;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.bramble.api.sync.GroupId;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.bramble.api.system.AndroidExecutor;
import org.briarproject.bramble.api.system.Clock;
import org.briarproject.briar.android.sharing.SharingController;
import org.briarproject.briar.android.threaded.ThreadListViewModel;
import org.briarproject.briar.android.viewmodel.LiveEvent;
import org.briarproject.briar.android.viewmodel.MutableLiveEvent;
import org.briarproject.briar.api.android.AndroidNotificationManager;
import org.briarproject.briar.api.client.MessageTracker;
import org.briarproject.briar.api.client.MessageTracker.GroupCount;
import org.briarproject.briar.api.privategroup.GroupMember;
import org.briarproject.briar.api.privategroup.GroupMessage;
import org.briarproject.briar.api.privategroup.GroupMessageFactory;
import org.briarproject.briar.api.privategroup.GroupMessageHeader;
import org.briarproject.briar.api.privategroup.JoinMessageHeader;
import org.briarproject.briar.api.privategroup.PrivateGroup;
import org.briarproject.briar.api.privategroup.PrivateGroupManager;
import org.briarproject.briar.api.privategroup.event.ContactRelationshipRevealedEvent;
import org.briarproject.briar.api.privategroup.event.GroupDissolvedEvent;
import org.briarproject.briar.api.privategroup.event.GroupInvitationResponseReceivedEvent;
import org.briarproject.briar.api.privategroup.event.GroupMessageAddedEvent;
import org.briarproject.briar.api.privategroup.invitation.GroupInvitationResponse;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import static java.lang.Math.max;
import static java.util.logging.Level.WARNING;
import static java.util.logging.Logger.getLogger;
import static org.briarproject.bramble.util.LogUtils.logDuration;
import static org.briarproject.bramble.util.LogUtils.logException;
import static org.briarproject.bramble.util.LogUtils.now;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
class GroupViewModel extends ThreadListViewModel<GroupMessageItem> {
private static final Logger LOG = getLogger(GroupViewModel.class.getName());
private final PrivateGroupManager privateGroupManager;
private final GroupMessageFactory groupMessageFactory;
private final MutableLiveData<PrivateGroup> privateGroup =
new MutableLiveData<>();
private final MutableLiveData<Boolean> isCreator = new MutableLiveData<>();
private final MutableLiveEvent<Boolean> isDissolved =
new MutableLiveEvent<>();
@Inject
GroupViewModel(Application application,
@DatabaseExecutor Executor dbExecutor,
LifecycleManager lifecycleManager,
TransactionManager db,
AndroidExecutor androidExecutor,
EventBus eventBus,
IdentityManager identityManager,
AndroidNotificationManager notificationManager,
SharingController sharingController,
@CryptoExecutor Executor cryptoExecutor,
Clock clock,
MessageTracker messageTracker,
PrivateGroupManager privateGroupManager,
GroupMessageFactory groupMessageFactory) {
super(application, dbExecutor, lifecycleManager, db, androidExecutor,
identityManager, notificationManager, sharingController,
cryptoExecutor, clock, messageTracker, eventBus);
this.privateGroupManager = privateGroupManager;
this.groupMessageFactory = groupMessageFactory;
}
@Override
public void eventOccurred(Event e) {
if (e instanceof GroupMessageAddedEvent) {
GroupMessageAddedEvent g = (GroupMessageAddedEvent) e;
// only act on non-local messages in this group
if (!g.isLocal() && g.getGroupId().equals(groupId)) {
LOG.info("Group message received, adding...");
GroupMessageItem item = buildItem(g.getHeader(), g.getText());
addItem(item, false);
// In case the join message comes from the creator,
// we need to reload the sharing contacts
// in case it was delayed and the sharing count is wrong (#850).
if (item instanceof JoinMessageItem &&
(((JoinMessageItem) item).isInitial())) {
loadSharingContacts();
}
}
} else if (e instanceof GroupInvitationResponseReceivedEvent) {
GroupInvitationResponseReceivedEvent g =
(GroupInvitationResponseReceivedEvent) e;
GroupInvitationResponse r = g.getMessageHeader();
if (r.getShareableId().equals(groupId) && r.wasAccepted()) {
sharingController.add(g.getContactId());
}
} else if (e instanceof ContactRelationshipRevealedEvent) {
ContactRelationshipRevealedEvent c =
(ContactRelationshipRevealedEvent) e;
if (c.getGroupId().equals(groupId)) {
sharingController.add(c.getContactId());
}
} else if (e instanceof GroupDissolvedEvent) {
GroupDissolvedEvent g = (GroupDissolvedEvent) e;
if (g.getGroupId().equals(groupId)) {
isDissolved.setEvent(true);
}
} else {
super.eventOccurred(e);
}
}
@Override
protected void performInitialLoad() {
super.performInitialLoad();
loadPrivateGroup(groupId);
}
protected void clearNotifications() {
notificationManager.clearGroupMessageNotification(groupId);
}
private void loadPrivateGroup(GroupId groupId) {
runOnDbThread(() -> {
try {
PrivateGroup g = privateGroupManager.getPrivateGroup(groupId);
privateGroup.postValue(g);
Author author = identityManager.getLocalAuthor();
isCreator.postValue(g.getCreator().equals(author));
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
}
@Override
public void loadItems() {
loadList(txn -> {
// check first if group is dissolved
isDissolved
.postEvent(privateGroupManager.isDissolved(txn, groupId));
// now continue to load the items
long start = now();
List<GroupMessageHeader> headers =
privateGroupManager.getHeaders(txn, groupId);
logDuration(LOG, "Loading headers", start);
start = now();
List<GroupMessageItem> items = new ArrayList<>();
for (GroupMessageHeader header : headers) {
items.add(loadItem(txn, header));
}
logDuration(LOG, "Loading bodies and creating items", start);
return items;
}, this::setItems);
}
private GroupMessageItem loadItem(Transaction txn,
GroupMessageHeader header) throws DbException {
String text;
if (header instanceof JoinMessageHeader) {
// will be looked up later
text = "";
} else {
text = privateGroupManager.getMessageText(txn, header.getId());
}
return buildItem(header, text);
}
private GroupMessageItem buildItem(GroupMessageHeader header, String text) {
if (header instanceof JoinMessageHeader) {
return new JoinMessageItem((JoinMessageHeader) header, text);
}
return new GroupMessageItem(header, text);
}
@Override
public void createAndStoreMessage(String text,
@Nullable MessageId parentId) {
runOnDbThread(() -> {
try {
LocalAuthor author = identityManager.getLocalAuthor();
MessageId previousMsgId =
privateGroupManager.getPreviousMsgId(groupId);
GroupCount count = privateGroupManager.getGroupCount(groupId);
long timestamp = count.getLatestMsgTime();
timestamp = max(clock.currentTimeMillis(), timestamp + 1);
createMessage(text, timestamp, parentId, author, previousMsgId);
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
}
private void createMessage(String text, long timestamp,
@Nullable MessageId parentId, LocalAuthor author,
MessageId previousMsgId) {
cryptoExecutor.execute(() -> {
LOG.info("Creating group message...");
GroupMessage msg = groupMessageFactory.createGroupMessage(groupId,
timestamp, parentId, author, text, previousMsgId);
storePost(msg, text);
});
}
private void storePost(GroupMessage msg, String text) {
runOnDbThread(false, txn -> {
long start = now();
GroupMessageHeader header =
privateGroupManager.addLocalMessage(txn, msg);
logDuration(LOG, "Storing group message", start);
txn.attach(() ->
addItem(buildItem(header, text), true)
);
}, e -> logException(LOG, WARNING, e));
}
@Override
protected void markItemRead(GroupMessageItem item) {
runOnDbThread(() -> {
try {
privateGroupManager.setReadFlag(groupId, item.getId(), true);
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
}
public void loadSharingContacts() {
runOnDbThread(true, txn -> {
Collection<GroupMember> members =
privateGroupManager.getMembers(txn, groupId);
Collection<ContactId> contactIds = new ArrayList<>();
for (GroupMember m : members) {
if (m.getContactId() != null)
contactIds.add(m.getContactId());
}
txn.attach(() -> sharingController.addAll(contactIds));
}, e -> logException(LOG, WARNING, e));
}
void deletePrivateGroup() {
runOnDbThread(() -> {
try {
privateGroupManager.removePrivateGroup(groupId);
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
}
LiveData<PrivateGroup> getPrivateGroup() {
return privateGroup;
}
LiveData<Boolean> isCreator() {
return isCreator;
}
LiveEvent<Boolean> isDissolved() {
return isDissolved;
}
}

View File

@@ -2,7 +2,6 @@ package org.briarproject.briar.android.privategroup.conversation;
import org.briarproject.briar.R;
import org.briarproject.briar.api.privategroup.JoinMessageHeader;
import org.briarproject.briar.api.privategroup.Visibility;
import javax.annotation.concurrent.NotThreadSafe;
@@ -13,13 +12,11 @@ import androidx.annotation.UiThread;
@NotThreadSafe
class JoinMessageItem extends GroupMessageItem {
private Visibility visibility;
private final boolean isInitial;
JoinMessageItem(JoinMessageHeader h, String text) {
super(h, text);
this.visibility = h.getVisibility();
this.isInitial = h.isInitial();
isInitial = h.isInitial();
}
@Override
@@ -33,14 +30,6 @@ class JoinMessageItem extends GroupMessageItem {
return R.layout.list_item_group_join_notice;
}
Visibility getVisibility() {
return visibility;
}
void setVisibility(Visibility visibility) {
this.visibility = visibility;
}
boolean isInitial() {
return isInitial;
}

View File

@@ -0,0 +1,54 @@
package org.briarproject.briar.android.sharing;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.event.EventBus;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import java.util.Collection;
import androidx.annotation.UiThread;
import androidx.lifecycle.LiveData;
@NotNullByDefault
public interface SharingController {
/**
* Call this when the owning ViewModel gets cleared,
* so the {@link EventBus} can get unregistered.
*/
void onCleared();
/**
* Adds one contact to be tracked.
*/
@UiThread
void add(ContactId c);
/**
* Adds a collection of contacts to be tracked.
*/
@UiThread
void addAll(Collection<ContactId> contacts);
/**
* Call this when the contact identified by c is no longer sharing
* the given group identified by GroupId g.
*/
@UiThread
void remove(ContactId c);
/**
* Returns the total number of contacts that have been added.
*/
LiveData<SharingInfo> getSharingInfo();
class SharingInfo {
public final int total, online;
SharingInfo(int total, int online) {
this.total = total;
this.online = online;
}
}
}

View File

@@ -0,0 +1,102 @@
package org.briarproject.briar.android.sharing;
import org.briarproject.bramble.api.connection.ConnectionRegistry;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.event.Event;
import org.briarproject.bramble.api.event.EventBus;
import org.briarproject.bramble.api.event.EventListener;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent;
import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import javax.inject.Inject;
import androidx.annotation.UiThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
@NotNullByDefault
public class SharingControllerImpl implements SharingController, EventListener {
private final EventBus eventBus;
private final ConnectionRegistry connectionRegistry;
// UI thread
private final Set<ContactId> contacts = new HashSet<>();
private final MutableLiveData<SharingInfo> sharingInfo =
new MutableLiveData<>();
@Inject
SharingControllerImpl(EventBus eventBus,
ConnectionRegistry connectionRegistry) {
this.eventBus = eventBus;
this.connectionRegistry = connectionRegistry;
eventBus.addListener(this);
}
@Override
public void onCleared() {
eventBus.removeListener(this);
}
@Override
public void eventOccurred(Event e) {
if (e instanceof ContactConnectedEvent) {
setConnected(((ContactConnectedEvent) e).getContactId());
} else if (e instanceof ContactDisconnectedEvent) {
setConnected(((ContactDisconnectedEvent) e).getContactId());
}
}
@UiThread
private void setConnected(ContactId c) {
if (contacts.contains(c)) {
updateLiveData();
}
}
@UiThread
private void updateLiveData() {
int online = getOnlineCount();
sharingInfo.setValue(new SharingInfo(contacts.size(), online));
}
private int getOnlineCount() {
int online = 0;
for (ContactId c : contacts) {
if (connectionRegistry.isConnected(c)) online++;
}
return online;
}
@UiThread
@Override
public void addAll(Collection<ContactId> c) {
contacts.addAll(c);
updateLiveData();
}
@UiThread
@Override
public void add(ContactId c) {
contacts.add(c);
updateLiveData();
}
@UiThread
@Override
public void remove(ContactId c) {
contacts.remove(c);
updateLiveData();
}
@Override
public LiveData<SharingInfo> getSharingInfo() {
return sharingInfo;
}
}

View File

@@ -9,36 +9,47 @@ import dagger.Provides;
@Module
public class SharingModule {
@ActivityScope
@Provides
ShareForumController provideShareForumController(
ShareForumControllerImpl shareForumController) {
return shareForumController;
@Module
@Deprecated
public static class SharingLegacyModule {
@ActivityScope
@Provides
ShareForumController provideShareForumController(
ShareForumControllerImpl shareForumController) {
return shareForumController;
}
@ActivityScope
@Provides
BlogInvitationController provideInvitationBlogController(
BaseActivity activity,
BlogInvitationControllerImpl blogInvitationController) {
activity.addLifecycleController(blogInvitationController);
return blogInvitationController;
}
@ActivityScope
@Provides
ForumInvitationController provideInvitationForumController(
BaseActivity activity,
ForumInvitationControllerImpl forumInvitationController) {
activity.addLifecycleController(forumInvitationController);
return forumInvitationController;
}
@ActivityScope
@Provides
ShareBlogController provideShareBlogController(
ShareBlogControllerImpl shareBlogController) {
return shareBlogController;
}
}
@ActivityScope
@Provides
BlogInvitationController provideInvitationBlogController(
BaseActivity activity,
BlogInvitationControllerImpl blogInvitationController) {
activity.addLifecycleController(blogInvitationController);
return blogInvitationController;
}
@ActivityScope
@Provides
ForumInvitationController provideInvitationForumController(
BaseActivity activity,
ForumInvitationControllerImpl forumInvitationController) {
activity.addLifecycleController(forumInvitationController);
return forumInvitationController;
}
@ActivityScope
@Provides
ShareBlogController provideShareBlogController(
ShareBlogControllerImpl shareBlogController) {
return shareBlogController;
SharingController provideSharingController(
SharingControllerImpl sharingController) {
return sharingController;
}
}

View File

@@ -1,54 +0,0 @@
package org.briarproject.briar.android.threaded;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.briar.api.client.MessageTree;
import org.briarproject.briar.api.client.MessageTree.MessageNode;
import org.briarproject.briar.client.MessageTreeImpl;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import androidx.annotation.UiThread;
@UiThread
@NotNullByDefault
public class NestedTreeList<T extends MessageNode> implements Iterable<T> {
private final MessageTree<T> tree = new MessageTreeImpl<>();
private List<T> depthFirstCollection = new ArrayList<>();
public void addAll(Collection<T> collection) {
tree.add(collection);
depthFirstCollection = new ArrayList<>(tree.depthFirstOrder());
}
public void add(T elem) {
tree.add(elem);
depthFirstCollection = new ArrayList<>(tree.depthFirstOrder());
}
public void clear() {
tree.clear();
depthFirstCollection.clear();
}
public T get(int index) {
return depthFirstCollection.get(index);
}
public int size() {
return depthFirstCollection.size();
}
public boolean contains(MessageId m) {
return tree.contains(m);
}
@Override
public Iterator<T> iterator() {
return depthFirstCollection.iterator();
}
}

View File

@@ -99,4 +99,14 @@ public abstract class ThreadItem implements MessageNode {
return highlighted;
}
@Override
public int hashCode() {
return messageId.hashCode();
}
@Override
public boolean equals(@Nullable Object o) {
return o instanceof ThreadItem &&
messageId.equals(((ThreadItem) o).messageId);
}
}

View File

@@ -4,39 +4,45 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.briar.R;
import org.briarproject.briar.android.util.ItemReturningAdapter;
import org.briarproject.briar.android.util.VersionedAdapter;
import java.util.Collection;
import javax.annotation.Nullable;
import androidx.annotation.NonNull;
import androidx.annotation.UiThread;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.ListAdapter;
import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
@UiThread
@NotNullByDefault
public class ThreadItemAdapter<I extends ThreadItem>
extends RecyclerView.Adapter<BaseThreadItemViewHolder<I>>
implements VersionedAdapter, ItemReturningAdapter<I> {
extends ListAdapter<I, BaseThreadItemViewHolder<I>>
implements ItemReturningAdapter<I> {
static final int UNDEFINED = -1;
protected final NestedTreeList<I> items = new NestedTreeList<>();
private final ThreadItemListener<I> listener;
private final LinearLayoutManager layoutManager;
private volatile int revision = 0;
public ThreadItemAdapter(ThreadItemListener<I> listener) {
super(new DiffUtil.ItemCallback<I>() {
@Override
public boolean areItemsTheSame(I a, I b) {
return a.equals(b);
}
public ThreadItemAdapter(ThreadItemListener<I> listener,
LinearLayoutManager layoutManager) {
@Override
public boolean areContentsTheSame(I a, I b) {
return a.isHighlighted() == b.isHighlighted() &&
a.isRead() == b.isRead();
}
});
this.listener = listener;
this.layoutManager = layoutManager;
}
@NonNull
@@ -51,76 +57,27 @@ public class ThreadItemAdapter<I extends ThreadItem>
@Override
public void onBindViewHolder(@NonNull BaseThreadItemViewHolder<I> ui,
int position) {
I item = items.get(position);
I item = getItem(position);
ui.bind(item, listener);
}
@Override
public int getItemCount() {
return items.size();
}
@Override
public int getRevision() {
return revision;
}
@Override
public void incrementRevision() {
revision++;
}
void setItemWithIdVisible(MessageId messageId) {
int pos = 0;
for (I item : items) {
if (item.getId().equals(messageId)) {
layoutManager.scrollToPosition(pos);
break;
}
pos++;
}
}
public void setItems(Collection<I> items) {
this.items.clear();
this.items.addAll(items);
notifyDataSetChanged();
}
public void add(I item) {
items.add(item);
notifyItemInserted(findItemPosition(item));
}
@Nullable
public I getItemAt(int position) {
if (position == NO_POSITION || position >= items.size()) {
return null;
}
return items.get(position);
}
protected int findItemPosition(@Nullable I item) {
for (int i = 0; i < items.size(); i++) {
if (items.get(i).equals(item)) return i;
int findItemPosition(MessageId id) {
for (int i = 0; i < getItemCount(); i++) {
if (id.equals(getItem(i).getId())) return i;
}
return NO_POSITION; // Not found
}
boolean contains(MessageId m) {
return items.contains(m);
}
/**
* Highlights the item with the given {@link MessageId}
* and disables the highlight for a previously highlighted item, if any.
*
* <p>
* Only one item can be highlighted at a time.
*/
void setHighlightedItem(@Nullable MessageId id) {
for (int i = 0; i < items.size(); i++) {
I item = items.get(i);
if (id != null && item.getId().equals(id)) {
for (int i = 0; i < getItemCount(); i++) {
I item = getItem(i);
if (item.getId().equals(id)) {
item.setHighlighted(true);
notifyItemChanged(i, item);
} else if (item.isHighlighted()) {
@@ -132,20 +89,27 @@ public class ThreadItemAdapter<I extends ThreadItem>
@Nullable
I getHighlightedItem() {
for (I i : items) {
if (i.isHighlighted()) return i;
for (I item : getCurrentList()) {
if (item.isHighlighted()) return item;
}
return null;
}
@Nullable
MessageId getFirstVisibleMessageId(LinearLayoutManager layoutManager) {
int position = layoutManager.findFirstVisibleItemPosition();
if (position == NO_POSITION) return null;
return getItemAt(position).getId();
}
/**
* Returns the position of the first unread item below the current viewport
*/
int getVisibleUnreadPosBottom() {
int getVisibleUnreadPosBottom(LinearLayoutManager layoutManager) {
int positionBottom = layoutManager.findLastVisibleItemPosition();
if (positionBottom == NO_POSITION) return NO_POSITION;
for (int i = positionBottom + 1; i < items.size(); i++) {
if (!items.get(i).isRead()) return i;
for (int i = positionBottom + 1; i < getItemCount(); i++) {
if (!getItem(i).isRead()) return i;
}
return NO_POSITION;
}
@@ -153,11 +117,11 @@ public class ThreadItemAdapter<I extends ThreadItem>
/**
* Returns the position of the first unread item above the current viewport
*/
int getVisibleUnreadPosTop() {
int getVisibleUnreadPosTop(LinearLayoutManager layoutManager) {
int positionTop = layoutManager.findFirstVisibleItemPosition();
int position = NO_POSITION;
for (int i = 0; i < items.size(); i++) {
if (i < positionTop && !items.get(i).isRead()) {
for (int i = 0; i < getItemCount(); i++) {
if (i < positionTop && !getItem(i).isRead()) {
position = i;
} else if (i >= positionTop) {
return position;
@@ -166,6 +130,11 @@ public class ThreadItemAdapter<I extends ThreadItem>
return NO_POSITION;
}
@Override
public I getItemAt(int position) {
return getItem(position);
}
public interface ThreadItemListener<I> {
void onReplyClick(I item);
}

View File

@@ -1,15 +0,0 @@
package org.briarproject.briar.android.threaded;
import org.briarproject.bramble.api.sync.MessageId;
import java.util.List;
import javax.annotation.Nullable;
public interface ThreadItemList<I extends ThreadItem> extends List<I> {
@Nullable
MessageId getFirstVisibleItemId();
void setFirstVisibleId(@Nullable MessageId bottomVisibleItemId);
}

View File

@@ -1,23 +0,0 @@
package org.briarproject.briar.android.threaded;
import org.briarproject.bramble.api.sync.MessageId;
import java.util.ArrayList;
import javax.annotation.Nullable;
public class ThreadItemListImpl<I extends ThreadItem> extends ArrayList<I>
implements ThreadItemList<I> {
private MessageId bottomVisibleItemId;
@Override
public MessageId getFirstVisibleItemId() {
return bottomVisibleItemId;
}
@Override
public void setFirstVisibleId(@Nullable MessageId bottomVisibleItemId) {
this.bottomVisibleItemId = bottomVisibleItemId;
}
}

View File

@@ -2,25 +2,18 @@ package org.briarproject.briar.android.threaded;
import android.content.Intent;
import android.os.Bundle;
import android.os.Parcelable;
import android.view.MenuItem;
import com.google.android.material.snackbar.Snackbar;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.bramble.api.sync.GroupId;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.briar.R;
import org.briarproject.briar.android.activity.BriarActivity;
import org.briarproject.briar.android.controller.SharingController;
import org.briarproject.briar.android.controller.SharingController.SharingListener;
import org.briarproject.briar.android.controller.handler.UiResultExceptionHandler;
import org.briarproject.briar.android.sharing.SharingController.SharingInfo;
import org.briarproject.briar.android.threaded.ThreadItemAdapter.ThreadItemListener;
import org.briarproject.briar.android.threaded.ThreadListController.ThreadListDataSource;
import org.briarproject.briar.android.threaded.ThreadListController.ThreadListListener;
import org.briarproject.briar.android.util.BriarSnackbarBuilder;
import org.briarproject.briar.android.view.BriarRecyclerView;
import org.briarproject.briar.android.view.TextInputView;
@@ -28,18 +21,13 @@ import org.briarproject.briar.android.view.TextSendController;
import org.briarproject.briar.android.view.TextSendController.SendListener;
import org.briarproject.briar.android.view.UnreadMessageButton;
import org.briarproject.briar.api.attachment.AttachmentHeader;
import org.briarproject.briar.api.client.NamedGroup;
import java.util.Collection;
import java.util.List;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;
import androidx.annotation.CallSuper;
import androidx.annotation.StringRes;
import androidx.annotation.UiThread;
import androidx.appcompat.app.ActionBar;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -48,32 +36,19 @@ import static org.briarproject.bramble.util.StringUtils.isNullOrEmpty;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
public abstract class ThreadListActivity<G extends NamedGroup, I extends ThreadItem, A extends ThreadItemAdapter<I>>
extends BriarActivity
implements ThreadListListener<I>, SendListener, SharingListener,
ThreadItemListener<I>, ThreadListDataSource {
public abstract class ThreadListActivity<I extends ThreadItem, A extends ThreadItemAdapter<I>>
extends BriarActivity implements SendListener, ThreadItemListener<I> {
protected static final String KEY_REPLY_ID = "replyId";
private static final Logger LOG =
Logger.getLogger(ThreadListActivity.class.getName());
protected A adapter;
private ThreadScrollListener<I> scrollListener;
protected final A adapter = createAdapter();
protected abstract ThreadListViewModel<I> getViewModel();
protected abstract A createAdapter();
protected BriarRecyclerView list;
private LinearLayoutManager layoutManager;
protected TextInputView textInput;
protected TextSendController sendController;
protected GroupId groupId;
@Nullable
private Parcelable layoutManagerState;
@Nullable
private MessageId replyId;
protected abstract ThreadListController<G, I> getController();
@Inject
protected SharingController sharingController;
private LinearLayoutManager layoutManager;
private ThreadScrollListener<I> scrollListener;
@CallSuper
@Override
@@ -86,7 +61,8 @@ public abstract class ThreadListActivity<G extends NamedGroup, I extends ThreadI
byte[] b = i.getByteArrayExtra(GROUP_ID);
if (b == null) throw new IllegalStateException("No GroupId in intent.");
groupId = new GroupId(b);
getController().setGroupId(groupId);
ThreadListViewModel<I> viewModel = getViewModel();
viewModel.setGroupId(groupId);
textInput = findViewById(R.id.text_input_container);
sendController = new TextSendController(textInput, this, false);
@@ -100,131 +76,41 @@ public abstract class ThreadListActivity<G extends NamedGroup, I extends ThreadI
list = findViewById(R.id.list);
layoutManager = new LinearLayoutManager(this);
list.setLayoutManager(layoutManager);
adapter = createAdapter(layoutManager);
list.setAdapter(adapter);
scrollListener = new ThreadScrollListener<>(adapter, getController(),
scrollListener = new ThreadScrollListener<>(adapter, viewModel,
upButton, downButton);
list.getRecyclerView().addOnScrollListener(scrollListener);
upButton.setOnClickListener(v -> {
int position = adapter.getVisibleUnreadPosTop();
int position = adapter.getVisibleUnreadPosTop(layoutManager);
if (position != NO_POSITION) {
list.getRecyclerView().scrollToPosition(position);
}
});
downButton.setOnClickListener(v -> {
int position = adapter.getVisibleUnreadPosBottom();
int position = adapter.getVisibleUnreadPosBottom(layoutManager);
if (position != NO_POSITION) {
list.getRecyclerView().scrollToPosition(position);
}
});
if (state != null) {
byte[] replyIdBytes = state.getByteArray(KEY_REPLY_ID);
if (replyIdBytes != null) replyId = new MessageId(replyIdBytes);
}
viewModel.getItems().observe(this, result -> result
.onError(this::handleException)
.onSuccess(this::displayItems)
);
sharingController.setSharingListener(this);
loadSharingContacts();
}
viewModel.getSharingInfo().observe(this, this::setToolbarSubTitle);
@Override
@Nullable
public MessageId getFirstVisibleMessageId() {
if (layoutManager != null && adapter != null) {
int position =
layoutManager.findFirstVisibleItemPosition();
I i = adapter.getItemAt(position);
return i == null ? null : i.getId();
}
return null;
}
protected abstract A createAdapter(LinearLayoutManager layoutManager);
protected void loadNamedGroup() {
getController().loadNamedGroup(
new UiResultExceptionHandler<G, DbException>(this) {
@Override
public void onResultUi(G groupItem) {
onNamedGroupLoaded(groupItem);
}
@Override
public void onExceptionUi(DbException exception) {
handleException(exception);
}
});
}
@UiThread
protected abstract void onNamedGroupLoaded(G groupItem);
protected void loadItems() {
int revision = adapter.getRevision();
getController().loadItems(
new UiResultExceptionHandler<ThreadItemList<I>, DbException>(
this) {
@Override
public void onResultUi(ThreadItemList<I> items) {
if (revision == adapter.getRevision()) {
adapter.incrementRevision();
if (items.isEmpty()) {
list.showData();
} else {
displayItems(items);
updateTextInput();
}
} else {
LOG.info("Concurrent update, reloading");
loadItems();
}
}
@Override
public void onExceptionUi(DbException exception) {
handleException(exception);
}
});
}
private void displayItems(ThreadItemList<I> items) {
adapter.setItems(items);
MessageId messageId = items.getFirstVisibleItemId();
if (messageId != null)
adapter.setItemWithIdVisible(messageId);
list.showData();
if (layoutManagerState == null) {
list.scrollToPosition(0); // Scroll to the top
} else {
layoutManager.onRestoreInstanceState(layoutManagerState);
}
}
protected void loadSharingContacts() {
getController().loadSharingContacts(
new UiResultExceptionHandler<Collection<ContactId>, DbException>(
this) {
@Override
public void onResultUi(Collection<ContactId> contacts) {
sharingController.addAll(contacts);
int online = sharingController.getOnlineCount();
setToolbarSubTitle(contacts.size(), online);
}
@Override
public void onExceptionUi(DbException exception) {
handleException(exception);
}
});
viewModel.getGroupRemoved().observe(this, removed -> {
if (removed) supportFinishAfterTransition();
});
}
@CallSuper
@Override
public void onStart() {
super.onStart();
sharingController.onStart();
loadItems();
getViewModel().blockAndClearNotifications();
list.startPeriodicUpdate();
}
@@ -232,91 +118,98 @@ public abstract class ThreadListActivity<G extends NamedGroup, I extends ThreadI
@Override
public void onStop() {
super.onStop();
sharingController.onStop();
getViewModel().unblockNotifications();
list.stopPeriodicUpdate();
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
if (layoutManager != null) {
layoutManagerState = layoutManager.onSaveInstanceState();
outState.putParcelable("layoutManager", layoutManagerState);
}
if (replyId != null) {
outState.putByteArray(KEY_REPLY_ID, replyId.getBytes());
}
}
@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
layoutManagerState = savedInstanceState.getParcelable("layoutManager");
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
supportFinishAfterTransition();
return true;
default:
return super.onOptionsItemSelected(item);
if (item.getItemId() == android.R.id.home) {
supportFinishAfterTransition();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public void onBackPressed() {
if (adapter.getHighlightedItem() != null) {
textInput.clearText();
replyId = null;
getViewModel().setReplyId(null);
updateTextInput();
} else {
super.onBackPressed();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
// store list position, so we can restore it when coming back here
if (layoutManager != null && adapter != null) {
MessageId id = adapter.getFirstVisibleMessageId(layoutManager);
getViewModel().storeMessageId(id);
}
}
protected void displayItems(List<I> items) {
if (items.isEmpty()) {
list.showData();
} else {
adapter.submitList(items, () -> {
// do stuff *after* list had been updated
scrollAfterListCommitted();
updateTextInput();
});
}
}
/**
* Scrolls to the first visible item last time the activity was open,
* if one exists and this is the first time, the list gets displayed.
* Or scrolls to a locally added item that has just been added to the list.
*/
private void scrollAfterListCommitted() {
MessageId restoredFirstVisibleItemId =
getViewModel().getAndResetRestoredMessageId();
MessageId scrollToItem =
getViewModel().getAndResetScrollToItem();
if (restoredFirstVisibleItemId != null) {
scrollToItemAtTop(restoredFirstVisibleItemId);
} else if (scrollToItem != null) {
scrollToItemAtTop(scrollToItem);
}
scrollListener.updateUnreadButtons(layoutManager);
}
@Override
public void onReplyClick(I item) {
replyId = item.getId();
getViewModel().setReplyId(item.getId());
updateTextInput();
// FIXME This does not work for a hardware keyboard
if (textInput.isKeyboardOpen()) {
scrollToItemAtTop(item);
scrollToItemAtTop(item.getId());
} else {
// wait with scrolling until keyboard opened
textInput.setOnKeyboardShownListener(() -> {
scrollToItemAtTop(item);
scrollToItemAtTop(item.getId());
textInput.setOnKeyboardShownListener(null);
});
}
}
@Override
public void onSharingInfoUpdated(int total, int online) {
setToolbarSubTitle(total, online);
}
@Override
public void onInvitationAccepted(ContactId c) {
sharingController.add(c);
setToolbarSubTitle(sharingController.getTotalCount(),
sharingController.getOnlineCount());
}
protected void setToolbarSubTitle(int total, int online) {
protected void setToolbarSubTitle(SharingInfo sharingInfo) {
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setSubtitle(
getString(R.string.shared_with, total, online));
actionBar.setSubtitle(getString(R.string.shared_with,
sharingInfo.total, sharingInfo.online));
}
}
private void scrollToItemAtTop(I item) {
int position = adapter.findItemPosition(item);
private void scrollToItemAtTop(MessageId messageId) {
int position = adapter.findItemPosition(messageId);
if (position != NO_POSITION) {
layoutManager
.scrollToPositionWithOffset(position, 0);
layoutManager.scrollToPositionWithOffset(position, 0);
}
}
@@ -327,6 +220,7 @@ public abstract class ThreadListActivity<G extends NamedGroup, I extends ThreadI
}
private void updateTextInput() {
MessageId replyId = getViewModel().getReplyId();
if (replyId != null) {
textInput.setHint(R.string.forum_message_reply_hint);
textInput.showSoftKeyboard();
@@ -341,54 +235,14 @@ public abstract class ThreadListActivity<G extends NamedGroup, I extends ThreadI
List<AttachmentHeader> headers) {
if (isNullOrEmpty(text)) throw new AssertionError();
I replyItem = adapter.getHighlightedItem();
UiResultExceptionHandler<I, DbException> handler =
new UiResultExceptionHandler<I, DbException>(this) {
@Override
public void onResultUi(I result) {
addItem(result, true);
}
@Override
public void onExceptionUi(DbException exception) {
handleException(exception);
}
};
getController().createAndStoreMessage(text, replyItem, handler);
MessageId replyId = getViewModel().getReplyId();
getViewModel().createAndStoreMessage(text, replyId);
textInput.hideSoftKeyboard();
textInput.clearText();
replyId = null;
getViewModel().setReplyId(null);
updateTextInput();
}
protected abstract int getMaxTextLength();
@Override
public void onItemReceived(I item) {
addItem(item, false);
}
@Override
public void onGroupRemoved() {
supportFinishAfterTransition();
}
private void addItem(I item, boolean isLocal) {
adapter.incrementRevision();
MessageId parent = item.getParentId();
if (parent != null && !adapter.contains(parent)) {
// We've incremented the adapter's revision, so the item will be
// loaded when its parent has been loaded
LOG.info("Ignoring item with missing parent");
return;
}
adapter.add(item);
if (isLocal) {
scrollToItemAtTop(item);
} else {
scrollListener.updateUnreadButtons(layoutManager);
}
}
}

View File

@@ -1,60 +0,0 @@
package org.briarproject.briar.android.threaded;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.bramble.api.sync.GroupId;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.briar.android.controller.ActivityLifecycleController;
import org.briarproject.briar.android.controller.handler.ExceptionHandler;
import org.briarproject.briar.android.controller.handler.ResultExceptionHandler;
import org.briarproject.briar.api.client.NamedGroup;
import java.util.Collection;
import javax.annotation.Nullable;
import androidx.annotation.UiThread;
@NotNullByDefault
public interface ThreadListController<G extends NamedGroup, I extends ThreadItem>
extends ActivityLifecycleController {
void setGroupId(GroupId groupId);
void loadNamedGroup(ResultExceptionHandler<G, DbException> handler);
void loadSharingContacts(
ResultExceptionHandler<Collection<ContactId>, DbException> handler);
void loadItems(
ResultExceptionHandler<ThreadItemList<I>, DbException> handler);
void markItemRead(I item);
void markItemsRead(Collection<I> items);
void createAndStoreMessage(String text, @Nullable I parentItem,
ResultExceptionHandler<I, DbException> handler);
void deleteNamedGroup(ExceptionHandler<DbException> handler);
interface ThreadListListener<I> extends ThreadListDataSource {
@UiThread
void onItemReceived(I item);
@UiThread
void onGroupRemoved();
@UiThread
void onInvitationAccepted(ContactId c);
}
interface ThreadListDataSource {
@UiThread @Nullable
MessageId getFirstVisibleMessageId();
}
}

View File

@@ -1,273 +0,0 @@
package org.briarproject.briar.android.threaded;
import android.app.Activity;
import org.briarproject.bramble.api.crypto.CryptoExecutor;
import org.briarproject.bramble.api.db.DatabaseExecutor;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.event.Event;
import org.briarproject.bramble.api.event.EventBus;
import org.briarproject.bramble.api.event.EventListener;
import org.briarproject.bramble.api.identity.IdentityManager;
import org.briarproject.bramble.api.lifecycle.LifecycleManager;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.bramble.api.sync.GroupId;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.bramble.api.sync.event.GroupRemovedEvent;
import org.briarproject.bramble.api.system.Clock;
import org.briarproject.briar.android.controller.DbControllerImpl;
import org.briarproject.briar.android.controller.handler.ExceptionHandler;
import org.briarproject.briar.android.controller.handler.ResultExceptionHandler;
import org.briarproject.briar.android.threaded.ThreadListController.ThreadListListener;
import org.briarproject.briar.api.android.AndroidNotificationManager;
import org.briarproject.briar.api.client.MessageTracker;
import org.briarproject.briar.api.client.NamedGroup;
import org.briarproject.briar.api.client.PostHeader;
import org.briarproject.briar.api.client.ThreadedMessage;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.logging.Logger;
import androidx.annotation.CallSuper;
import static java.util.logging.Level.INFO;
import static java.util.logging.Level.WARNING;
import static org.briarproject.bramble.util.LogUtils.logDuration;
import static org.briarproject.bramble.util.LogUtils.logException;
import static org.briarproject.bramble.util.LogUtils.now;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
public abstract class ThreadListControllerImpl<G extends NamedGroup, I extends ThreadItem, H extends PostHeader, M extends ThreadedMessage, L extends ThreadListListener<I>>
extends DbControllerImpl
implements ThreadListController<G, I>, EventListener {
private static final Logger LOG =
Logger.getLogger(ThreadListControllerImpl.class.getName());
private final EventBus eventBus;
private final MessageTracker messageTracker;
private final Map<MessageId, String> textCache = new ConcurrentHashMap<>();
private volatile GroupId groupId;
protected final IdentityManager identityManager;
protected final AndroidNotificationManager notificationManager;
protected final Executor cryptoExecutor;
protected final Clock clock;
// UI thread
protected L listener;
protected ThreadListControllerImpl(@DatabaseExecutor Executor dbExecutor,
LifecycleManager lifecycleManager, IdentityManager identityManager,
@CryptoExecutor Executor cryptoExecutor, EventBus eventBus,
Clock clock, AndroidNotificationManager notificationManager,
MessageTracker messageTracker) {
super(dbExecutor, lifecycleManager);
this.identityManager = identityManager;
this.cryptoExecutor = cryptoExecutor;
this.notificationManager = notificationManager;
this.clock = clock;
this.eventBus = eventBus;
this.messageTracker = messageTracker;
}
@Override
public void setGroupId(GroupId groupId) {
this.groupId = groupId;
}
@CallSuper
@SuppressWarnings("unchecked")
@Override
public void onActivityCreate(Activity activity) {
listener = (L) activity;
}
@CallSuper
@Override
public void onActivityStart() {
notificationManager.blockNotification(getGroupId());
eventBus.addListener(this);
}
@CallSuper
@Override
public void onActivityStop() {
notificationManager.unblockNotification(getGroupId());
eventBus.removeListener(this);
}
@Override
public void onActivityDestroy() {
MessageId messageId = listener.getFirstVisibleMessageId();
if (messageId != null) {
dbExecutor.execute(() -> {
try {
messageTracker.storeMessageId(groupId, messageId);
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
}
}
@CallSuper
@Override
public void eventOccurred(Event e) {
if (e instanceof GroupRemovedEvent) {
GroupRemovedEvent s = (GroupRemovedEvent) e;
if (s.getGroup().getId().equals(getGroupId())) {
LOG.info("Group removed");
listener.onGroupRemoved();
}
}
}
@Override
public void loadNamedGroup(
ResultExceptionHandler<G, DbException> handler) {
checkGroupId();
runOnDbThread(() -> {
try {
long start = now();
G groupItem = loadNamedGroup();
logDuration(LOG, "Loading group", start);
handler.onResult(groupItem);
} catch (DbException e) {
logException(LOG, WARNING, e);
handler.onException(e);
}
});
}
@DatabaseExecutor
protected abstract G loadNamedGroup() throws DbException;
@Override
public void loadItems(
ResultExceptionHandler<ThreadItemList<I>, DbException> handler) {
checkGroupId();
runOnDbThread(() -> {
try {
// Load headers
long start = now();
Collection<H> headers = loadHeaders();
logDuration(LOG, "Loading headers", start);
// Load bodies into cache
start = now();
for (H header : headers) {
if (!textCache.containsKey(header.getId())) {
textCache.put(header.getId(),
loadMessageText(header));
}
}
logDuration(LOG, "Loading bodies", start);
// Build and hand over items
handler.onResult(buildItems(headers));
} catch (DbException e) {
logException(LOG, WARNING, e);
handler.onException(e);
}
});
}
@DatabaseExecutor
protected abstract Collection<H> loadHeaders() throws DbException;
@DatabaseExecutor
protected abstract String loadMessageText(H header) throws DbException;
@Override
public void markItemRead(I item) {
markItemsRead(Collections.singletonList(item));
}
@Override
public void markItemsRead(Collection<I> items) {
runOnDbThread(() -> {
try {
long start = now();
for (I i : items) {
markRead(i.getId());
}
logDuration(LOG, "Marking read", start);
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
}
@DatabaseExecutor
protected abstract void markRead(MessageId id) throws DbException;
protected void storePost(M msg, String text,
ResultExceptionHandler<I, DbException> resultHandler) {
runOnDbThread(() -> {
try {
long start = now();
H header = addLocalMessage(msg);
textCache.put(msg.getMessage().getId(), text);
logDuration(LOG, "Storing message", start);
resultHandler.onResult(buildItem(header, text));
} catch (DbException e) {
logException(LOG, WARNING, e);
resultHandler.onException(e);
}
});
}
@DatabaseExecutor
protected abstract H addLocalMessage(M message) throws DbException;
@Override
public void deleteNamedGroup(ExceptionHandler<DbException> handler) {
runOnDbThread(() -> {
try {
long start = now();
G groupItem = loadNamedGroup();
deleteNamedGroup(groupItem);
logDuration(LOG, "Removing group", start);
} catch (DbException e) {
logException(LOG, WARNING, e);
handler.onException(e);
}
});
}
@DatabaseExecutor
protected abstract void deleteNamedGroup(G groupItem) throws DbException;
private ThreadItemList<I> buildItems(Collection<H> headers)
throws DbException {
ThreadItemList<I> items = new ThreadItemListImpl<>();
for (H h : headers) {
items.add(buildItem(h, textCache.get(h.getId())));
}
MessageId msgId = messageTracker.loadStoredMessageId(groupId);
if (LOG.isLoggable(INFO))
LOG.info("Loaded last top visible message id " + msgId);
items.setFirstVisibleId(msgId);
return items;
}
protected abstract I buildItem(H header, String text);
protected GroupId getGroupId() {
checkGroupId();
return groupId;
}
private void checkGroupId() {
if (groupId == null) throw new IllegalStateException();
}
}

View File

@@ -0,0 +1,256 @@
package org.briarproject.briar.android.threaded;
import android.app.Application;
import org.briarproject.bramble.api.crypto.CryptoExecutor;
import org.briarproject.bramble.api.db.DatabaseExecutor;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.db.TransactionManager;
import org.briarproject.bramble.api.event.Event;
import org.briarproject.bramble.api.event.EventBus;
import org.briarproject.bramble.api.event.EventListener;
import org.briarproject.bramble.api.identity.IdentityManager;
import org.briarproject.bramble.api.lifecycle.LifecycleManager;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.bramble.api.sync.GroupId;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.bramble.api.sync.event.GroupRemovedEvent;
import org.briarproject.bramble.api.system.AndroidExecutor;
import org.briarproject.bramble.api.system.Clock;
import org.briarproject.briar.android.sharing.SharingController;
import org.briarproject.briar.android.sharing.SharingController.SharingInfo;
import org.briarproject.briar.android.viewmodel.DbViewModel;
import org.briarproject.briar.android.viewmodel.LiveResult;
import org.briarproject.briar.api.android.AndroidNotificationManager;
import org.briarproject.briar.api.client.MessageTracker;
import org.briarproject.briar.api.client.MessageTree;
import org.briarproject.briar.client.MessageTreeImpl;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import androidx.annotation.CallSuper;
import androidx.annotation.UiThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import static java.util.Objects.requireNonNull;
import static java.util.logging.Level.INFO;
import static java.util.logging.Level.WARNING;
import static java.util.logging.Logger.getLogger;
import static org.briarproject.bramble.util.LogUtils.logException;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
public abstract class ThreadListViewModel<I extends ThreadItem>
extends DbViewModel implements EventListener {
private static final Logger LOG =
getLogger(ThreadListViewModel.class.getName());
protected final IdentityManager identityManager;
protected final AndroidNotificationManager notificationManager;
protected final SharingController sharingController;
protected final Executor cryptoExecutor;
protected final Clock clock;
private final MessageTracker messageTracker;
private final EventBus eventBus;
// UIThread
private final MessageTree<I> messageTree = new MessageTreeImpl<>();
private final MutableLiveData<LiveResult<List<I>>> items =
new MutableLiveData<>();
private final MutableLiveData<Boolean> groupRemoved =
new MutableLiveData<>();
private final AtomicReference<MessageId> scrollToItem =
new AtomicReference<>();
protected volatile GroupId groupId;
@Nullable
private MessageId replyId;
/**
* Stored list position. Needs to be loaded and set before the list itself.
*/
private final AtomicReference<MessageId> storedMessageId =
new AtomicReference<>();
public ThreadListViewModel(Application application,
@DatabaseExecutor Executor dbExecutor,
LifecycleManager lifecycleManager,
TransactionManager db,
AndroidExecutor androidExecutor,
IdentityManager identityManager,
AndroidNotificationManager notificationManager,
SharingController sharingController,
@CryptoExecutor Executor cryptoExecutor,
Clock clock,
MessageTracker messageTracker,
EventBus eventBus) {
super(application, dbExecutor, lifecycleManager, db, androidExecutor);
this.identityManager = identityManager;
this.notificationManager = notificationManager;
this.cryptoExecutor = cryptoExecutor;
this.clock = clock;
this.sharingController = sharingController;
this.messageTracker = messageTracker;
this.eventBus = eventBus;
this.eventBus.addListener(this);
}
@Override
protected void onCleared() {
super.onCleared();
eventBus.removeListener(this);
sharingController.onCleared();
}
/**
* Needs to be called right after initialization,
* before calling any other methods.
*/
public final void setGroupId(GroupId groupId) {
boolean needsInitialLoad = this.groupId == null;
this.groupId = groupId;
if (needsInitialLoad) performInitialLoad();
}
@CallSuper
protected void performInitialLoad() {
// load stored MessageId (last list position) before the list itself
loadStoredMessageId();
loadItems();
loadSharingContacts();
}
protected abstract void clearNotifications();
void blockAndClearNotifications() {
notificationManager.blockNotification(groupId);
clearNotifications();
}
void unblockNotifications() {
notificationManager.unblockNotification(groupId);
}
@Override
@CallSuper
public void eventOccurred(Event e) {
if (e instanceof GroupRemovedEvent) {
GroupRemovedEvent s = (GroupRemovedEvent) e;
if (s.getGroup().getId().equals(groupId)) {
LOG.info("Group removed");
groupRemoved.setValue(true);
}
}
}
private void loadStoredMessageId() {
runOnDbThread(() -> {
try {
storedMessageId
.set(messageTracker.loadStoredMessageId(groupId));
if (LOG.isLoggable(INFO)) {
LOG.info("Loaded last top visible message id " +
storedMessageId);
}
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
}
public abstract void loadItems();
public abstract void createAndStoreMessage(String text,
@Nullable MessageId parentMessageId);
/**
* Loads the ContactIds of all contacts the group is shared with
* and adds them to {@link SharingController}.
*/
protected abstract void loadSharingContacts();
@UiThread
protected void setItems(LiveResult<List<I>> items) {
if (items.hasError()) {
this.items.setValue(items);
} else {
messageTree.clear();
// not null, because hasError() is false
messageTree.add(requireNonNull(items.getResultOrNull()));
LiveResult<List<I>> result =
new LiveResult<>(messageTree.depthFirstOrder());
this.items.setValue(result);
}
}
/**
* Add a remote item on the UI thread.
*
* @param scrollToItem whether the list will scroll to the newly added item
*/
@UiThread
protected void addItem(I item, boolean scrollToItem) {
messageTree.add(item);
if (scrollToItem) this.scrollToItem.set(item.getId());
items.setValue(new LiveResult<>(messageTree.depthFirstOrder()));
}
@UiThread
void setReplyId(@Nullable MessageId id) {
replyId = id;
}
@UiThread
@Nullable
MessageId getReplyId() {
return replyId;
}
void storeMessageId(@Nullable MessageId messageId) {
if (messageId != null) {
runOnDbThread(() -> {
try {
messageTracker.storeMessageId(groupId, messageId);
} catch (DbException e) {
logException(LOG, WARNING, e);
}
});
}
}
protected abstract void markItemRead(I item);
/**
* Returns the {@link MessageId} of the item that was at the top of the
* list last time or null if there has been nothing stored, yet.
*/
@Nullable
MessageId getAndResetRestoredMessageId() {
return storedMessageId.getAndSet(null);
}
LiveData<LiveResult<List<I>>> getItems() {
return items;
}
LiveData<SharingInfo> getSharingInfo() {
return sharingController.getSharingInfo();
}
LiveData<Boolean> getGroupRemoved() {
return groupRemoved;
}
@Nullable
MessageId getAndResetScrollToItem() {
return scrollToItem.getAndSet(null);
}
}

View File

@@ -20,15 +20,15 @@ class ThreadScrollListener<I extends ThreadItem>
private static final Logger LOG =
getLogger(ThreadScrollListener.class.getName());
private final ThreadListController<?, I> controller;
private final ThreadListViewModel<I> viewModel;
private final UnreadMessageButton upButton, downButton;
ThreadScrollListener(ThreadItemAdapter<I> adapter,
ThreadListController<?, I> controller,
ThreadListViewModel<I> viewModel,
UnreadMessageButton upButton,
UnreadMessageButton downButton) {
super(adapter);
this.controller = controller;
this.viewModel = viewModel;
this.upButton = upButton;
this.downButton = downButton;
}
@@ -44,7 +44,7 @@ class ThreadScrollListener<I extends ThreadItem>
protected void onItemVisible(I item) {
if (!item.isRead()) {
item.setRead(true);
controller.markItemRead(item);
viewModel.markItemRead(item);
}
}

View File

@@ -5,6 +5,7 @@ import android.app.Application;
import org.briarproject.bramble.api.db.DatabaseExecutor;
import org.briarproject.bramble.api.db.DbCallable;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.db.DbRunnable;
import org.briarproject.bramble.api.db.Transaction;
import org.briarproject.bramble.api.db.TransactionManager;
import org.briarproject.bramble.api.lifecycle.LifecycleManager;
@@ -23,6 +24,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.arch.core.util.Function;
import androidx.core.util.Consumer;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.recyclerview.widget.RecyclerView;
@@ -57,8 +59,8 @@ public abstract class DbViewModel extends AndroidViewModel {
}
/**
* Runs the given task on the {@link DatabaseExecutor}
* and waits for the DB to open.
* Waits for the DB to open and runs the given task on the
* {@link DatabaseExecutor}.
* <p>
* If you need a list of items to be displayed in a
* {@link RecyclerView.Adapter},
@@ -76,6 +78,29 @@ public abstract class DbViewModel extends AndroidViewModel {
});
}
/**
* Waits for the DB to open and runs the given task on the
* {@link DatabaseExecutor}.
* <p>
* If you need a list of items to be displayed in a
* {@link RecyclerView.Adapter},
* use {@link #loadList(DbCallable, UiConsumer)} instead.
*/
protected void runOnDbThread(boolean readOnly,
DbRunnable<Exception> task, Consumer<Exception> err) {
dbExecutor.execute(() -> {
try {
lifecycleManager.waitForDatabase();
db.transaction(readOnly, task);
} catch (InterruptedException e) {
LOG.warning("Interrupted while waiting for database");
Thread.currentThread().interrupt();
} catch (Exception e) {
err.accept(e);
}
});
}
/**
* Loads a list of items on the {@link DatabaseExecutor} within a single
* {@link Transaction} and publishes it as a {@link LiveResult}

View File

@@ -39,6 +39,17 @@ public class LiveEvent<T> extends LiveData<LiveEvent.ConsumableEvent<T>> {
super.observeForever(observer);
}
/**
* Returns the last value of the event (even if already consumed)
* or null if there hasn't been any value so far.
*/
@Nullable
public T getLastValue() {
ConsumableEvent<T> event = getValue();
if (event == null) return null;
return event.content;
}
static class ConsumableEvent<T> {
private final T content;

View File

@@ -1,36 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<menu 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">
<item
android:id="@+id/action_group_invite"
android:icon="@drawable/social_share_white"
android:title="@string/groups_invite_members"
app:showAsAction="ifRoom"/>
android:visible="false"
app:showAsAction="ifRoom"
tools:visible="true" />
<item
android:id="@+id/action_group_member_list"
android:icon="@drawable/ic_group_white"
android:title="@string/groups_member_list"
app:showAsAction="never"/>
app:showAsAction="never" />
<item
android:id="@+id/action_group_reveal"
android:icon="@drawable/ic_visibility_white"
android:title="@string/groups_reveal_contacts"
app:showAsAction="never"/>
android:visible="false"
app:showAsAction="never"
tools:visible="true" />
<item
android:id="@+id/action_group_leave"
android:icon="@drawable/action_delete_white"
android:title="@string/groups_leave"
app:showAsAction="never"/>
android:visible="false"
app:showAsAction="never"
tools:visible="true" />
<item
android:id="@+id/action_group_dissolve"
android:icon="@drawable/action_delete_white"
android:title="@string/groups_dissolve"
app:showAsAction="never"/>
android:visible="false"
app:showAsAction="never"
tools:visible="true" />
</menu>

View File

@@ -1,120 +0,0 @@
package org.briarproject.briar.android.forum;
import android.content.Intent;
import junit.framework.Assert;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.identity.Author;
import org.briarproject.briar.api.identity.AuthorInfo;
import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.briar.android.controller.handler.UiResultExceptionHandler;
import org.briarproject.briar.android.threaded.ThreadItemAdapter;
import org.briarproject.briar.android.threaded.ThreadItemList;
import org.briarproject.briar.android.threaded.ThreadItemListImpl;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.MockitoAnnotations;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.util.Arrays;
import static junit.framework.Assert.assertEquals;
import static org.briarproject.briar.api.identity.AuthorInfo.Status.UNKNOWN;
import static org.briarproject.bramble.test.TestUtils.getAuthor;
import static org.briarproject.bramble.test.TestUtils.getRandomId;
import static org.briarproject.bramble.util.StringUtils.getRandomString;
import static org.briarproject.briar.api.forum.ForumConstants.MAX_FORUM_POST_TEXT_LENGTH;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 21)
public class ForumActivityTest {
private final static MessageId[] MESSAGE_IDS = new MessageId[6];
static {
for (int i = 0; i < MESSAGE_IDS.length; i++)
MESSAGE_IDS[i] = new MessageId(getRandomId());
}
private final static MessageId[] PARENT_IDS = {
null,
MESSAGE_IDS[0],
MESSAGE_IDS[1],
MESSAGE_IDS[2],
MESSAGE_IDS[0],
null
};
/*
1
-> 2
-> 3
-> 4
5
6
*/
private final static int[] LEVELS = {
0, 1, 2, 3, 1, 0
};
private TestForumActivity forumActivity;
@Captor
private ArgumentCaptor<UiResultExceptionHandler<ThreadItemList<ForumPostItem>, DbException>>
rc;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
Intent intent = new Intent();
intent.putExtra("briar.GROUP_ID", getRandomId());
forumActivity = Robolectric.buildActivity(TestForumActivity.class,
intent).create().start().resume().get();
}
private ThreadItemList<ForumPostItem> getDummyData() {
ForumPostItem[] forumPostItems = new ForumPostItem[6];
for (int i = 0; i < forumPostItems.length; i++) {
Author author = getAuthor();
String text = getRandomString(MAX_FORUM_POST_TEXT_LENGTH);
forumPostItems[i] = new ForumPostItem(MESSAGE_IDS[i], PARENT_IDS[i],
text, System.currentTimeMillis(), author,
new AuthorInfo(UNKNOWN));
forumPostItems[i].setLevel(LEVELS[i]);
}
ThreadItemList<ForumPostItem> list = new ThreadItemListImpl<>();
list.addAll(Arrays.asList(forumPostItems));
return list;
}
@Test
public void testNestedEntries() {
ForumController mc = forumActivity.getController();
ThreadItemList<ForumPostItem> dummyData = getDummyData();
verify(mc, times(1)).loadItems(rc.capture());
rc.getValue().onResult(dummyData);
ThreadItemAdapter<ForumPostItem> adapter = forumActivity.getAdapter();
Assert.assertNotNull(adapter);
assertEquals(6, adapter.getItemCount());
assertEquals(dummyData.get(0).getText(),
adapter.getItemAt(0).getText());
assertEquals(dummyData.get(1).getText(),
adapter.getItemAt(1).getText());
assertEquals(dummyData.get(2).getText(),
adapter.getItemAt(2).getText());
assertEquals(dummyData.get(3).getText(),
adapter.getItemAt(3).getText());
assertEquals(dummyData.get(4).getText(),
adapter.getItemAt(4).getText());
assertEquals(dummyData.get(5).getText(),
adapter.getItemAt(5).getText());
}
}

View File

@@ -1,65 +0,0 @@
package org.briarproject.briar.android.forum;
import android.os.Bundle;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.briar.R;
import org.briarproject.briar.android.activity.ActivityModule;
import org.briarproject.briar.android.activity.BaseActivity;
import org.briarproject.briar.android.controller.BriarController;
import org.briarproject.briar.android.controller.BriarControllerImpl;
import org.briarproject.briar.android.threaded.ThreadItemAdapter;
import org.mockito.Mockito;
import javax.annotation.Nullable;
/**
* This class exposes the ForumController and offers the possibility to
* override it.
*/
@MethodsNotNullByDefault
@ParametersNotNullByDefault
public class TestForumActivity extends ForumActivity {
@Override
public ForumController getController() {
return forumController;
}
public ThreadItemAdapter<ForumPostItem> getAdapter() {
return adapter;
}
@Override
public void onCreate(@Nullable Bundle state) {
setTheme(R.style.BriarTheme_NoActionBar);
super.onCreate(state);
}
@Override
protected ActivityModule getActivityModule() {
return new ActivityModule(this) {
@Override
protected BriarController provideBriarController(
BriarControllerImpl briarController) {
BriarController c = Mockito.mock(BriarController.class);
Mockito.when(c.accountSignedIn()).thenReturn(true);
return c;
}
};
}
@Override
protected ForumModule getForumModule() {
return new ForumModule() {
@Override
ForumController provideForumController(BaseActivity activity,
ForumControllerImpl forumController) {
return Mockito.mock(ForumController.class);
}
};
}
}

View File

@@ -4,7 +4,7 @@ import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.bramble.api.sync.MessageId;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import javax.annotation.Nullable;
@@ -15,11 +15,9 @@ public interface MessageTree<T extends MessageTree.MessageNode> {
void add(T node);
void setComparator(Comparator<T> comparator);
void clear();
Collection<T> depthFirstOrder();
List<T> depthFirstOrder();
boolean contains(MessageId m);

View File

@@ -11,6 +11,7 @@ import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.briar.api.client.MessageTracker.GroupCount;
import java.util.Collection;
import java.util.List;
import javax.annotation.Nullable;
@@ -59,6 +60,12 @@ public interface ForumManager {
*/
ForumPostHeader addLocalPost(ForumPost p) throws DbException;
/**
* Stores a local forum post.
*/
ForumPostHeader addLocalPost(Transaction txn, ForumPost p)
throws DbException;
/**
* Returns the forum with the given ID.
*/
@@ -84,11 +91,22 @@ public interface ForumManager {
*/
String getPostText(MessageId m) throws DbException;
/**
* Returns the text of the forum post with the given ID.
*/
String getPostText(Transaction txn, MessageId m) throws DbException;
/**
* Returns the headers of all posts in the given forum.
*/
Collection<ForumPostHeader> getPostHeaders(GroupId g) throws DbException;
/**
* Returns the headers of all posts in the given forum.
*/
List<ForumPostHeader> getPostHeaders(Transaction txn, GroupId g)
throws DbException;
/**
* Registers a hook to be called whenever a forum is removed.
*/
@@ -97,7 +115,6 @@ public interface ForumManager {
/**
* Returns the group count for the given forum.
*/
@Deprecated
GroupCount getGroupCount(GroupId g) throws DbException;
/**

View File

@@ -8,21 +8,14 @@ import javax.annotation.concurrent.Immutable;
@NotNullByDefault
public class JoinMessageHeader extends GroupMessageHeader {
private final Visibility visibility;
private final boolean isInitial;
public JoinMessageHeader(GroupMessageHeader h, Visibility visibility,
boolean isInitial) {
public JoinMessageHeader(GroupMessageHeader h, boolean isInitial) {
super(h.getGroupId(), h.getId(), h.getParentId(), h.getTimestamp(),
h.getAuthor(), h.getAuthorInfo(), h.isRead());
this.visibility = visibility;
this.isInitial = isInitial;
}
public Visibility getVisibility() {
return visibility;
}
public boolean isInitial() {
return isInitial;
}

View File

@@ -12,6 +12,7 @@ import org.briarproject.bramble.api.sync.MessageId;
import org.briarproject.briar.api.client.MessageTracker.GroupCount;
import java.util.Collection;
import java.util.List;
@NotNullByDefault
public interface PrivateGroupManager {
@@ -81,6 +82,12 @@ public interface PrivateGroupManager {
*/
GroupMessageHeader addLocalMessage(GroupMessage p) throws DbException;
/**
* Stores and sends a local private group message.
*/
GroupMessageHeader addLocalMessage(Transaction txn, GroupMessage p)
throws DbException;
/**
* Returns the private group with the given ID.
*/
@@ -107,16 +114,33 @@ public interface PrivateGroupManager {
*/
String getMessageText(MessageId m) throws DbException;
/**
* Returns the text of the private group message with the given ID.
*/
String getMessageText(Transaction txn, MessageId m) throws DbException;
/**
* Returns the headers of all messages in the given private group.
*/
Collection<GroupMessageHeader> getHeaders(GroupId g) throws DbException;
/**
* Returns the headers of all messages in the given private group.
*/
List<GroupMessageHeader> getHeaders(Transaction txn, GroupId g)
throws DbException;
/**
* Returns all members of the given private group.
*/
Collection<GroupMember> getMembers(GroupId g) throws DbException;
/**
* Returns all members of the given private group.
*/
Collection<GroupMember> getMembers(Transaction txn, GroupId g)
throws DbException;
/**
* Returns true if the given author is a member of the given private group.
*/

View File

@@ -3,6 +3,7 @@ package org.briarproject.briar.api.sharing;
import org.briarproject.bramble.api.contact.Contact;
import org.briarproject.bramble.api.contact.ContactId;
import org.briarproject.bramble.api.db.DbException;
import org.briarproject.bramble.api.db.Transaction;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.bramble.api.sync.GroupId;
import org.briarproject.briar.api.client.SessionId;
@@ -45,6 +46,12 @@ public interface SharingManager<S extends Shareable>
*/
Collection<Contact> getSharedWith(GroupId g) throws DbException;
/**
* Returns all contacts with whom the given group is shared.
*/
Collection<Contact> getSharedWith(Transaction txn, GroupId g)
throws DbException;
/**
* Returns true if the group not already shared and no invitation is open
*/

View File

@@ -30,7 +30,7 @@ public class MessageTreeImpl<T extends MessageTree.MessageNode>
private final List<List<T>> unsortedLists = new ArrayList<>();
@SuppressWarnings("UseCompareMethod")
private Comparator<T> comparator = (o1, o2) ->
private final Comparator<T> comparator = (o1, o2) ->
Long.valueOf(o1.getTimestamp()).compareTo(o2.getTimestamp());
@Override
@@ -79,6 +79,7 @@ public class MessageTreeImpl<T extends MessageTree.MessageNode>
@GuardedBy("this")
private void sortUnsorted() {
for (List<T> list : unsortedLists) {
//noinspection Java8ListSort
Collections.sort(list, comparator);
}
unsortedLists.clear();
@@ -95,17 +96,7 @@ public class MessageTreeImpl<T extends MessageTree.MessageNode>
}
@Override
public synchronized void setComparator(Comparator<T> comparator) {
this.comparator = comparator;
// Sort all lists with the new comparator
Collections.sort(roots, comparator);
for (Map.Entry<MessageId, List<T>> entry : nodeMap.entrySet()) {
Collections.sort(entry.getValue(), comparator);
}
}
@Override
public synchronized Collection<T> depthFirstOrder() {
public synchronized List<T> depthFirstOrder() {
List<T> orderedList = new ArrayList<>();
for (T root : roots) {
traverse(orderedList, root, 0);

View File

@@ -126,29 +126,30 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
@Override
public ForumPostHeader addLocalPost(ForumPost p) throws DbException {
return db.transactionWithResult(false, txn -> {
try {
return addLocalPost(txn, p);
} catch (FormatException e) {
throw new AssertionError(e);
}
});
return db.transactionWithResult(false, txn -> addLocalPost(txn, p));
}
private ForumPostHeader addLocalPost(Transaction txn, ForumPost p)
throws DbException, FormatException {
BdfDictionary meta = new BdfDictionary();
meta.put(KEY_TIMESTAMP, p.getMessage().getTimestamp());
if (p.getParent() != null) meta.put(KEY_PARENT, p.getParent());
Author a = p.getAuthor();
meta.put(KEY_AUTHOR, clientHelper.toList(a));
meta.put(KEY_LOCAL, true);
meta.put(MSG_KEY_READ, true);
clientHelper.addLocalMessage(txn, p.getMessage(), meta, true, false);
messageTracker.trackOutgoingMessage(txn, p.getMessage());
AuthorInfo authorInfo = authorManager.getMyAuthorInfo(txn);
return new ForumPostHeader(p.getMessage().getId(), p.getParent(),
p.getMessage().getTimestamp(), p.getAuthor(), authorInfo, true);
@Override
public ForumPostHeader addLocalPost(Transaction txn, ForumPost p)
throws DbException {
try {
BdfDictionary meta = new BdfDictionary();
meta.put(KEY_TIMESTAMP, p.getMessage().getTimestamp());
if (p.getParent() != null) meta.put(KEY_PARENT, p.getParent());
Author a = p.getAuthor();
meta.put(KEY_AUTHOR, clientHelper.toList(a));
meta.put(KEY_LOCAL, true);
meta.put(MSG_KEY_READ, true);
clientHelper
.addLocalMessage(txn, p.getMessage(), meta, true, false);
messageTracker.trackOutgoingMessage(txn, p.getMessage());
AuthorInfo authorInfo = authorManager.getMyAuthorInfo(txn);
return new ForumPostHeader(p.getMessage().getId(), p.getParent(),
p.getMessage().getTimestamp(), p.getAuthor(), authorInfo,
true);
} catch (FormatException e) {
throw new AssertionError(e);
}
}
@Override
@@ -192,6 +193,15 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
}
}
@Override
public String getPostText(Transaction txn, MessageId m) throws DbException {
try {
return getPostText(clientHelper.getMessageAsList(txn, m));
} catch (FormatException e) {
throw new DbException(e);
}
}
private String getPostText(BdfList body) throws FormatException {
// Parent ID, author, text, signature
return body.getString(2);
@@ -200,33 +210,35 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
@Override
public Collection<ForumPostHeader> getPostHeaders(GroupId g)
throws DbException {
return db.transactionWithResult(true, txn -> getPostHeaders(txn, g));
}
@Override
public List<ForumPostHeader> getPostHeaders(Transaction txn, GroupId g)
throws DbException {
try {
return db.transactionWithResult(true, txn -> {
Collection<ForumPostHeader> headers = new ArrayList<>();
Map<MessageId, BdfDictionary> metadata =
clientHelper.getMessageMetadataAsDictionary(txn, g);
// get all authors we need to get the info for
Set<AuthorId> authors = new HashSet<>();
for (Entry<MessageId, BdfDictionary> entry :
metadata.entrySet()) {
BdfList authorList = entry.getValue().getList(KEY_AUTHOR);
Author a = clientHelper.parseAndValidateAuthor(authorList);
authors.add(a.getId());
}
// get information for all authors
Map<AuthorId, AuthorInfo> authorInfos = new HashMap<>();
for (AuthorId id : authors) {
authorInfos.put(id, authorManager.getAuthorInfo(txn, id));
}
// Parse the metadata
for (Entry<MessageId, BdfDictionary> entry :
metadata.entrySet()) {
BdfDictionary meta = entry.getValue();
headers.add(getForumPostHeader(txn, entry.getKey(), meta,
authorInfos));
}
return headers;
});
List<ForumPostHeader> headers = new ArrayList<>();
Map<MessageId, BdfDictionary> metadata =
clientHelper.getMessageMetadataAsDictionary(txn, g);
// get all authors we need to get the info for
Set<AuthorId> authors = new HashSet<>();
for (Entry<MessageId, BdfDictionary> entry : metadata.entrySet()) {
BdfList authorList = entry.getValue().getList(KEY_AUTHOR);
Author a = clientHelper.parseAndValidateAuthor(authorList);
authors.add(a.getId());
}
// get information for all authors
Map<AuthorId, AuthorInfo> authorInfos = new HashMap<>();
for (AuthorId id : authors) {
authorInfos.put(id, authorManager.getAuthorInfo(txn, id));
}
// Parse the metadata
for (Entry<MessageId, BdfDictionary> entry : metadata.entrySet()) {
BdfDictionary meta = entry.getValue();
headers.add(getForumPostHeader(txn, entry.getKey(), meta,
authorInfos));
}
return headers;
} catch (FormatException e) {
throw new DbException(e);
}

View File

@@ -145,7 +145,7 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook
addMember(txn, m.getMessage().getGroupId(), m.getMember(), VISIBLE);
setPreviousMsgId(txn, m.getMessage().getGroupId(),
m.getMessage().getId());
attachJoinMessageAddedEvent(txn, m.getMessage(), meta, true, VISIBLE);
attachJoinMessageAddedEvent(txn, m.getMessage(), meta, true);
}
@Override
@@ -210,34 +210,35 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook
@Override
public GroupMessageHeader addLocalMessage(GroupMessage m)
throws DbException {
return db.transactionWithResult(false, txn -> {
try {
return addLocalMessage(txn, m);
} catch (FormatException e) {
throw new DbException(e);
}
});
return db.transactionWithResult(false, txn -> addLocalMessage(txn, m));
}
private GroupMessageHeader addLocalMessage(Transaction txn, GroupMessage m)
throws DbException, FormatException {
// store message and metadata
BdfDictionary meta = new BdfDictionary();
meta.put(KEY_TYPE, POST.getInt());
if (m.getParent() != null)
meta.put(KEY_PARENT_MSG_ID, m.getParent());
addMessageMetadata(meta, m);
GroupId g = m.getMessage().getGroupId();
clientHelper.addLocalMessage(txn, m.getMessage(), meta, true, false);
// track message
setPreviousMsgId(txn, g, m.getMessage().getId());
messageTracker.trackOutgoingMessage(txn, m.getMessage());
// broadcast event
attachGroupMessageAddedEvent(txn, m.getMessage(), meta, true);
AuthorInfo authorInfo = authorManager.getMyAuthorInfo(txn);
return new GroupMessageHeader(m.getMessage().getGroupId(),
m.getMessage().getId(), m.getParent(),
m.getMessage().getTimestamp(), m.getMember(), authorInfo, true);
@Override
public GroupMessageHeader addLocalMessage(Transaction txn, GroupMessage m)
throws DbException {
try {
// store message and metadata
BdfDictionary meta = new BdfDictionary();
meta.put(KEY_TYPE, POST.getInt());
if (m.getParent() != null)
meta.put(KEY_PARENT_MSG_ID, m.getParent());
addMessageMetadata(meta, m);
GroupId g = m.getMessage().getGroupId();
clientHelper
.addLocalMessage(txn, m.getMessage(), meta, true, false);
// track message
setPreviousMsgId(txn, g, m.getMessage().getId());
messageTracker.trackOutgoingMessage(txn, m.getMessage());
// broadcast event
attachGroupMessageAddedEvent(txn, m.getMessage(), meta, true);
AuthorInfo authorInfo = authorManager.getMyAuthorInfo(txn);
return new GroupMessageHeader(m.getMessage().getGroupId(),
m.getMessage().getId(), m.getParent(),
m.getMessage().getTimestamp(), m.getMember(), authorInfo,
true);
} catch (FormatException e) {
throw new DbException(e);
}
}
private void addMessageMetadata(BdfDictionary meta, GroupMessage m) {
@@ -315,6 +316,16 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook
}
}
@Override
public String getMessageText(Transaction txn, MessageId m)
throws DbException {
try {
return getMessageText(clientHelper.getMessageAsList(txn, m));
} catch (FormatException e) {
throw new DbException(e);
}
}
private String getMessageText(BdfList body) throws FormatException {
// Message type (0), member (1), parent ID (2), previous message ID (3),
// text (4), signature (5)
@@ -324,8 +335,13 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook
@Override
public Collection<GroupMessageHeader> getHeaders(GroupId g)
throws DbException {
Collection<GroupMessageHeader> headers = new ArrayList<>();
Transaction txn = db.startTransaction(true);
return db.transactionWithResult(true, txn -> getHeaders(txn, g));
}
@Override
public List<GroupMessageHeader> getHeaders(Transaction txn, GroupId g)
throws DbException {
List<GroupMessageHeader> headers = new ArrayList<>();
try {
Map<MessageId, BdfDictionary> metadata =
clientHelper.getMessageMetadataAsDictionary(txn, g);
@@ -339,27 +355,20 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook
for (AuthorId id : authors) {
authorInfos.put(id, authorManager.getAuthorInfo(txn, id));
}
// get current visibilities for join messages
Map<Author, Visibility> visibilities = getMembers(txn, g);
// parse the metadata
for (Entry<MessageId, BdfDictionary> entry : metadata.entrySet()) {
BdfDictionary meta = entry.getValue();
if (meta.getLong(KEY_TYPE) == JOIN.getInt()) {
Author member = getAuthor(meta);
Visibility v = visibilities.get(member);
headers.add(getJoinMessageHeader(txn, g, entry.getKey(),
meta, authorInfos, v));
meta, authorInfos));
} else {
headers.add(getGroupMessageHeader(txn, g, entry.getKey(),
meta, authorInfos));
}
}
db.commitTransaction(txn);
return headers;
} catch (FormatException e) {
throw new DbException(e);
} finally {
db.endTransaction(txn);
}
}
@@ -389,46 +398,45 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook
private JoinMessageHeader getJoinMessageHeader(Transaction txn, GroupId g,
MessageId id, BdfDictionary meta,
Map<AuthorId, AuthorInfo> authorInfos, Visibility v)
Map<AuthorId, AuthorInfo> authorInfos)
throws DbException, FormatException {
GroupMessageHeader header =
getGroupMessageHeader(txn, g, id, meta, authorInfos);
boolean creator = meta.getBoolean(KEY_INITIAL_JOIN_MSG);
return new JoinMessageHeader(header, v, creator);
return new JoinMessageHeader(header, creator);
}
@Override
public Collection<GroupMember> getMembers(GroupId g) throws DbException {
Transaction txn = db.startTransaction(true);
try {
Collection<GroupMember> members = new ArrayList<>();
Map<Author, Visibility> authors = getMembers(txn, g);
LocalAuthor la = identityManager.getLocalAuthor(txn);
PrivateGroup privateGroup = getPrivateGroup(txn, g);
for (Entry<Author, Visibility> m : authors.entrySet()) {
Author a = m.getKey();
AuthorInfo authorInfo =
authorManager.getAuthorInfo(txn, a.getId());
Status status = authorInfo.getStatus();
Visibility v = m.getValue();
ContactId c = null;
if (v != INVISIBLE &&
(status == VERIFIED || status == UNVERIFIED)) {
c = contactManager.getContact(txn, a.getId(), la.getId())
.getId();
}
boolean isCreator = privateGroup.getCreator().equals(a);
members.add(new GroupMember(a, authorInfo, isCreator, c, v));
}
db.commitTransaction(txn);
return members;
} finally {
db.endTransaction(txn);
}
return db.transactionWithResult(true, txn -> getMembers(txn, g));
}
private Map<Author, Visibility> getMembers(Transaction txn, GroupId g)
@Override
public Collection<GroupMember> getMembers(Transaction txn, GroupId g)
throws DbException {
Collection<GroupMember> members = new ArrayList<>();
Map<Author, Visibility> authors = getMemberAuthors(txn, g);
LocalAuthor la = identityManager.getLocalAuthor(txn);
PrivateGroup privateGroup = getPrivateGroup(txn, g);
for (Entry<Author, Visibility> m : authors.entrySet()) {
Author a = m.getKey();
AuthorInfo authorInfo = authorManager.getAuthorInfo(txn, a.getId());
Status status = authorInfo.getStatus();
Visibility v = m.getValue();
ContactId c = null;
if (v != INVISIBLE &&
(status == VERIFIED || status == UNVERIFIED)) {
c = contactManager.getContact(txn, a.getId(), la.getId())
.getId();
}
boolean isCreator = privateGroup.getCreator().equals(a);
members.add(new GroupMember(a, authorInfo, isCreator, c, v));
}
return members;
}
private Map<Author, Visibility> getMemberAuthors(Transaction txn, GroupId g)
throws DbException {
try {
BdfDictionary meta =
@@ -450,7 +458,7 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook
@Override
public boolean isMember(Transaction txn, GroupId g, Author a)
throws DbException {
for (Author member : getMembers(txn, g).keySet()) {
for (Author member : getMemberAuthors(txn, g).keySet()) {
if (member.equals(a)) return true;
}
return false;
@@ -544,7 +552,7 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook
addMember(txn, m.getGroupId(), member, v);
// track message and broadcast event
messageTracker.trackIncomingMessage(txn, m);
attachJoinMessageAddedEvent(txn, m, meta, false, v);
attachJoinMessageAddedEvent(txn, m, meta, false);
}
private void handleGroupMessage(Transaction txn, Message m,
@@ -594,10 +602,10 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook
}
private void attachJoinMessageAddedEvent(Transaction txn, Message m,
BdfDictionary meta, boolean local, Visibility v)
BdfDictionary meta, boolean local)
throws DbException, FormatException {
JoinMessageHeader header = getJoinMessageHeader(txn, m.getGroupId(),
m.getId(), meta, Collections.emptyMap(), v);
m.getId(), meta, Collections.emptyMap());
txn.attach(new GroupMessageAddedEvent(m.getGroupId(), header, "",
local));
}

View File

@@ -426,17 +426,17 @@ abstract class SharingManagerImpl<S extends Shareable>
@Override
public Collection<Contact> getSharedWith(GroupId g) throws DbException {
return db.transactionWithResult(true, txn -> getSharedWith(txn, g));
}
@Override
public Collection<Contact> getSharedWith(Transaction txn, GroupId g)
throws DbException {
// TODO report also pending invitations
Collection<Contact> contacts = new ArrayList<>();
Transaction txn = db.startTransaction(true);
try {
for (Contact c : db.getContacts(txn)) {
if (db.getGroupVisibility(txn, c.getId(), g) == SHARED)
contacts.add(c);
}
db.commitTransaction(txn);
} finally {
db.endTransaction(txn);
for (Contact c : db.getContacts(txn)) {
if (db.getGroupVisibility(txn, c.getId(), g) == SHARED)
contacts.add(c);
}
return contacts;
}

View File

@@ -124,10 +124,8 @@ public class PrivateGroupManagerIntegrationTest
addGroup();
// create and add test message with no previousMsgId
@SuppressWarnings("ConstantConditions")
GroupMessage msg = groupMessageFactory
.createGroupMessage(groupId0, clock.currentTimeMillis(), null,
author0, "test", null);
GroupMessage msg = groupMessageFactory.createGroupMessage(groupId0,
clock.currentTimeMillis(), null, author0, "test", null);
groupManager0.addLocalMessage(msg);
// sync test message
@@ -342,52 +340,19 @@ public class PrivateGroupManagerIntegrationTest
Collection<GroupMember> members0 = groupManager0.getMembers(groupId0);
assertEquals(2, members0.size());
for (GroupMember m : members0) {
if (m.getAuthor().equals(author0)) {
assertEquals(VISIBLE, m.getVisibility());
} else {
if (!m.getAuthor().equals(author0)) {
assertEquals(author1, m.getAuthor());
assertEquals(VISIBLE, m.getVisibility());
}
assertEquals(VISIBLE, m.getVisibility());
}
Collection<GroupMember> members1 = groupManager1.getMembers(groupId0);
assertEquals(2, members1.size());
for (GroupMember m : members1) {
if (m.getAuthor().equals(author1)) {
assertEquals(VISIBLE, m.getVisibility());
} else {
if (!m.getAuthor().equals(author1)) {
assertEquals(author0, m.getAuthor());
assertEquals(VISIBLE, m.getVisibility());
}
}
}
@Test
public void testJoinMessages() throws Exception {
addGroup();
Collection<GroupMessageHeader> headers0 =
groupManager0.getHeaders(groupId0);
for (GroupMessageHeader h : headers0) {
if (h instanceof JoinMessageHeader) {
JoinMessageHeader j = (JoinMessageHeader) h;
// all relationships of the creator are visible
assertEquals(VISIBLE, j.getVisibility());
}
}
Collection<GroupMessageHeader> headers1 =
groupManager1.getHeaders(groupId0);
for (GroupMessageHeader h : headers1) {
if (h instanceof JoinMessageHeader) {
JoinMessageHeader j = (JoinMessageHeader) h;
if (h.getAuthor().equals(author1))
// we are visible to ourselves
assertEquals(VISIBLE, j.getVisibility());
else
// our relationship to the creator is visible
assertEquals(VISIBLE, j.getVisibility());
}
assertEquals(VISIBLE, m.getVisibility());
}
}
@@ -463,34 +428,6 @@ public class PrivateGroupManagerIntegrationTest
assertEquals(REVEALED_BY_CONTACT, m.getVisibility());
}
}
// assert that join messages reflect revealed relationship
Collection<GroupMessageHeader> headers1 =
groupManager1.getHeaders(groupId0);
for (GroupMessageHeader h : headers1) {
if (h instanceof JoinMessageHeader) {
JoinMessageHeader j = (JoinMessageHeader) h;
if (h.getAuthor().equals(author2))
// 1 revealed the relationship to 2
assertEquals(REVEALED_BY_US, j.getVisibility());
else
// 1's other relationship (to 1 and creator) are visible
assertEquals(VISIBLE, j.getVisibility());
}
}
Collection<GroupMessageHeader> headers2 =
groupManager2.getHeaders(groupId0);
for (GroupMessageHeader h : headers2) {
if (h instanceof JoinMessageHeader) {
JoinMessageHeader j = (JoinMessageHeader) h;
if (h.getAuthor().equals(author1))
// 2's relationship was revealed by 1
assertEquals(REVEALED_BY_CONTACT, j.getVisibility());
else
// 2's other relationship (to 2 and creator) are visible
assertEquals(VISIBLE, j.getVisibility());
}
}
}
@Test