diff --git a/briar-android/build.gradle b/briar-android/build.gradle index 6d6a46b88..0f7507e73 100644 --- a/briar-android/build.gradle +++ b/briar-android/build.gradle @@ -126,10 +126,11 @@ dependencies { testImplementation 'androidx.test:runner:1.3.0' testImplementation 'androidx.test.ext:junit:1.1.2' testImplementation 'androidx.fragment:fragment-testing:1.2.5' + testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "androidx.test.espresso:espresso-core:$espressoVersion" testImplementation 'org.robolectric:robolectric:4.3.1' testImplementation 'org.mockito:mockito-core:3.1.0' - testImplementation 'junit:junit:4.12' + testImplementation 'junit:junit:4.13.1' testImplementation "org.jmock:jmock:$jmockVersion" testImplementation "org.jmock:jmock-junit4:$jmockVersion" testImplementation "org.jmock:jmock-legacy:$jmockVersion" diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java index b1168d848..6f83e06fc 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java @@ -14,6 +14,7 @@ import org.briarproject.bramble.api.contact.ContactManager; import org.briarproject.bramble.api.crypto.CryptoExecutor; import org.briarproject.bramble.api.crypto.PasswordStrengthEstimator; import org.briarproject.bramble.api.db.DatabaseExecutor; +import org.briarproject.bramble.api.db.TransactionManager; import org.briarproject.bramble.api.event.EventBus; import org.briarproject.bramble.api.identity.IdentityManager; import org.briarproject.bramble.api.keyagreement.KeyAgreementTask; @@ -85,6 +86,8 @@ public interface AndroidComponent @DatabaseExecutor Executor databaseExecutor(); + TransactionManager transactionManager(); + MessageTracker messageTracker(); LifecycleManager lifecycleManager(); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java b/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java index a1da1af9d..318153320 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java @@ -109,7 +109,7 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager, @Nullable private GroupId blockedGroup = null; private boolean blockSignInReminder = false; - private boolean blockBlogs = false; + private boolean blockGroups = false, blockBlogs = false; private long lastSound = 0; private volatile Settings settings = new Settings(); @@ -223,8 +223,8 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager, if (s.getNamespace().equals(SETTINGS_NAMESPACE)) settings = s.getSettings(); } else if (e instanceof ConversationMessageReceivedEvent) { - ConversationMessageReceivedEvent p = - (ConversationMessageReceivedEvent) e; + ConversationMessageReceivedEvent p = + (ConversationMessageReceivedEvent) e; showContactNotification(p.getContactId()); } else if (e instanceof GroupMessageAddedEvent) { GroupMessageAddedEvent g = (GroupMessageAddedEvent) e; @@ -385,6 +385,7 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager, @UiThread private void showGroupMessageNotification(GroupId g) { + if (blockGroups) return; if (g.equals(blockedGroup)) return; groupCounts.add(g); updateGroupMessageNotification(true); @@ -681,6 +682,17 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager, }); } + @Override + public void blockAllGroupMessageNotifications() { + androidExecutor.runOnUiThread((Runnable) () -> blockGroups = true); + + } + + @Override + public void unblockAllGroupMessageNotifications() { + androidExecutor.runOnUiThread((Runnable) () -> blockGroups = false); + } + @Override public void blockAllBlogPostNotifications() { androidExecutor.runOnUiThread((Runnable) () -> blockBlogs = true); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java b/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java index 30c510d80..451aac2b6 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java @@ -31,6 +31,7 @@ import org.briarproject.briar.android.account.LockManagerImpl; 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.list.GroupListModule; import org.briarproject.briar.android.reporting.DevReportModule; import org.briarproject.briar.android.viewmodel.ViewModelModule; import org.briarproject.briar.api.android.AndroidNotificationManager; @@ -65,7 +66,9 @@ import static org.briarproject.briar.android.TestingConstants.IS_DEBUG_BUILD; LoginModule.class, NavDrawerModule.class, ViewModelModule.class, - DevReportModule.class + DevReportModule.class, + // below need to be within same scope as ViewModelProvider.Factory + GroupListModule.class, }) public class AppModule { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java index 51e524319..0154b0469 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java @@ -60,7 +60,6 @@ import org.briarproject.briar.android.privategroup.creation.GroupInviteFragment; import org.briarproject.briar.android.privategroup.invitation.GroupInvitationActivity; import org.briarproject.briar.android.privategroup.invitation.GroupInvitationModule; import org.briarproject.briar.android.privategroup.list.GroupListFragment; -import org.briarproject.briar.android.privategroup.list.GroupListModule; import org.briarproject.briar.android.privategroup.memberlist.GroupMemberListActivity; import org.briarproject.briar.android.privategroup.memberlist.GroupMemberModule; import org.briarproject.briar.android.privategroup.reveal.GroupRevealModule; @@ -94,7 +93,6 @@ import dagger.Component; ForumModule.class, GroupInvitationModule.class, GroupConversationModule.class, - GroupListModule.class, GroupMemberModule.class, GroupRevealModule.class, SharingModule.class diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java index f67f1fc91..1398c223a 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java @@ -6,7 +6,6 @@ import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; -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; @@ -240,7 +239,7 @@ public abstract class BaseActivity extends AppCompatActivity } @UiThread - public void handleDbException(DbException e) { + public void handleException(Exception e) { supportFinishAfterTransition(); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/blog/BlogFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/blog/BlogFragment.java index a9e3da6d3..e138ec501 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/blog/BlogFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/blog/BlogFragment.java @@ -232,7 +232,7 @@ public class BlogFragment extends BaseFragment @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } } ); @@ -277,7 +277,7 @@ public class BlogFragment extends BaseFragment @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } @@ -296,7 +296,7 @@ public class BlogFragment extends BaseFragment @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } @@ -318,7 +318,7 @@ public class BlogFragment extends BaseFragment @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } @@ -398,7 +398,7 @@ public class BlogFragment extends BaseFragment @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/blog/BlogPostFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/blog/BlogPostFragment.java index 25eb5d23b..cb61b75de 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/blog/BlogPostFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/blog/BlogPostFragment.java @@ -58,7 +58,7 @@ public class BlogPostFragment extends BasePostFragment implements BlogListener { @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/blog/FeedFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/blog/FeedFragment.java index cbb686e9d..75026a034 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/blog/FeedFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/blog/FeedFragment.java @@ -156,7 +156,7 @@ public class FeedFragment extends BaseFragment implements @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } @@ -187,7 +187,7 @@ public class FeedFragment extends BaseFragment implements @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } @@ -242,7 +242,7 @@ public class FeedFragment extends BaseFragment implements @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } } ); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/blog/FeedPostFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/blog/FeedPostFragment.java index fb397898d..b9750a5a1 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/blog/FeedPostFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/blog/FeedPostFragment.java @@ -79,7 +79,7 @@ public class FeedPostFragment extends BasePostFragment { @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java index 058c09a2e..3f6f881ad 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/blog/ReblogFragment.java @@ -101,7 +101,7 @@ public class ReblogFragment extends BaseFragment implements SendListener { @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); @@ -128,7 +128,7 @@ public class ReblogFragment extends BaseFragment implements SendListener { new UiExceptionHandler(this) { @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); finish(); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/AddContactViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/AddContactViewModel.java index 4d31de63a..b3a7cb122 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/AddContactViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/AddContactViewModel.java @@ -9,8 +9,10 @@ import org.briarproject.bramble.api.contact.PendingContact; import org.briarproject.bramble.api.db.DatabaseExecutor; import org.briarproject.bramble.api.db.DbException; import org.briarproject.bramble.api.db.NoSuchPendingContactException; +import org.briarproject.bramble.api.db.TransactionManager; import org.briarproject.bramble.api.lifecycle.LifecycleManager; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.system.AndroidExecutor; import org.briarproject.briar.android.viewmodel.DbViewModel; import org.briarproject.briar.android.viewmodel.LiveEvent; import org.briarproject.briar.android.viewmodel.LiveResult; @@ -52,8 +54,10 @@ public class AddContactViewModel extends DbViewModel { AddContactViewModel(Application application, ContactManager contactManager, @DatabaseExecutor Executor dbExecutor, - LifecycleManager lifecycleManager) { - super(application, dbExecutor, lifecycleManager); + LifecycleManager lifecycleManager, + TransactionManager db, + AndroidExecutor androidExecutor) { + super(application, dbExecutor, lifecycleManager, db, androidExecutor); this.contactManager = contactManager; } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListViewModel.java index 55b2f6806..8317214c1 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/remote/PendingContactListViewModel.java @@ -11,6 +11,7 @@ import org.briarproject.bramble.api.contact.event.PendingContactRemovedEvent; import org.briarproject.bramble.api.contact.event.PendingContactStateChangedEvent; 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; @@ -18,6 +19,7 @@ import org.briarproject.bramble.api.lifecycle.LifecycleManager; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.bramble.api.rendezvous.RendezvousPoller; import org.briarproject.bramble.api.rendezvous.event.RendezvousPollEvent; +import org.briarproject.bramble.api.system.AndroidExecutor; import org.briarproject.briar.android.viewmodel.DbViewModel; import java.util.ArrayList; @@ -56,10 +58,12 @@ public class PendingContactListViewModel extends DbViewModel PendingContactListViewModel(Application application, @DatabaseExecutor Executor dbExecutor, LifecycleManager lifecycleManager, + TransactionManager db, + AndroidExecutor androidExecutor, ContactManager contactManager, RendezvousPoller rendezvousPoller, EventBus eventBus) { - super(application, dbExecutor, lifecycleManager); + super(application, dbExecutor, lifecycleManager, db, androidExecutor); this.contactManager = contactManager; this.rendezvousPoller = rendezvousPoller; this.eventBus = eventBus; diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contactselection/BaseContactSelectorFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/contactselection/BaseContactSelectorFragment.java index 3c6052c77..0d7558f02 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contactselection/BaseContactSelectorFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/contactselection/BaseContactSelectorFragment.java @@ -133,7 +133,7 @@ public abstract class BaseContactSelectorFragment { private final PrivateGroup privateGroup; private final AuthorInfo authorInfo; - private int messageCount, unreadCount; - private long timestamp; - private boolean dissolved; + private final int messageCount, unreadCount; + private final long timestamp; + private final boolean dissolved; GroupItem(PrivateGroup privateGroup, AuthorInfo authorInfo, GroupCount count, boolean dissolved) { @@ -28,18 +32,22 @@ class GroupItem { this.dissolved = dissolved; } - void addMessageHeader(GroupMessageHeader header) { - messageCount++; - if (header.getTimestamp() > timestamp) { - timestamp = header.getTimestamp(); - } - if (!header.isRead()) { - unreadCount++; - } + GroupItem(GroupItem item, GroupMessageHeader header) { + this.privateGroup = item.privateGroup; + this.authorInfo = item.authorInfo; + this.messageCount = item.messageCount + 1; + this.unreadCount = item.unreadCount + (header.isRead() ? 0 : 1); + this.timestamp = Math.max(header.getTimestamp(), item.timestamp); + this.dissolved = item.dissolved; } - PrivateGroup getPrivateGroup() { - return privateGroup; + GroupItem(GroupItem item, boolean isDissolved) { + this.privateGroup = item.privateGroup; + this.authorInfo = item.authorInfo; + this.messageCount = item.messageCount; + this.unreadCount = item.unreadCount; + this.timestamp = item.timestamp; + this.dissolved = isDissolved; } GroupId getId() { @@ -78,8 +86,27 @@ class GroupItem { return dissolved; } - void setDissolved() { - dissolved = true; + @Override + public int hashCode() { + return getId().hashCode(); } + @Override + public boolean equals(@Nullable Object o) { + return o instanceof GroupItem && + getId().equals(((GroupItem) o).getId()); + } + + @Override + public int compareTo(GroupItem o) { + if (this == o) return 0; + // The group with the latest message comes first + long aTime = getTimestamp(), bTime = o.getTimestamp(); + if (aTime > bTime) return -1; + if (aTime < bTime) return 1; + // Break ties by group name + String aName = getName(); + String bName = o.getName(); + return String.CASE_INSENSITIVE_ORDER.compare(aName, bName); + } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListAdapter.java b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListAdapter.java index d6fb8cc35..a2a4805bc 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListAdapter.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListAdapter.java @@ -1,81 +1,52 @@ package org.briarproject.briar.android.privategroup.list; -import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; -import org.briarproject.bramble.api.sync.GroupId; import org.briarproject.briar.R; import org.briarproject.briar.android.privategroup.list.GroupViewHolder.OnGroupRemoveClickListener; -import org.briarproject.briar.android.util.BriarAdapter; -import static androidx.recyclerview.widget.SortedList.INVALID_POSITION; +import androidx.recyclerview.widget.DiffUtil.ItemCallback; +import androidx.recyclerview.widget.ListAdapter; @MethodsNotNullByDefault @ParametersNotNullByDefault -class GroupListAdapter extends BriarAdapter { +class GroupListAdapter extends ListAdapter { private final OnGroupRemoveClickListener listener; - GroupListAdapter(Context ctx, OnGroupRemoveClickListener listener) { - super(ctx, GroupItem.class); + GroupListAdapter(OnGroupRemoveClickListener listener) { + super(new GroupItemCallback()); this.listener = listener; } @Override public GroupViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View v = LayoutInflater.from(ctx).inflate( - R.layout.list_item_group, parent, false); + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.list_item_group, parent, false); return new GroupViewHolder(v); } @Override public void onBindViewHolder(GroupViewHolder ui, int position) { - ui.bindView(ctx, items.get(position), listener); + ui.bindView(getItem(position), listener); } - @Override - public int compare(GroupItem a, GroupItem b) { - if (a == b) return 0; - // The group with the latest message comes first - long aTime = a.getTimestamp(), bTime = b.getTimestamp(); - if (aTime > bTime) return -1; - if (aTime < bTime) return 1; - // Break ties by group name - String aName = a.getName(); - String bName = b.getName(); - return String.CASE_INSENSITIVE_ORDER.compare(aName, bName); - } - - @Override - public boolean areContentsTheSame(GroupItem a, GroupItem b) { - return a.getMessageCount() == b.getMessageCount() && - a.getTimestamp() == b.getTimestamp() && - a.getUnreadCount() == b.getUnreadCount() && - a.isDissolved() == b.isDissolved(); - } - - @Override - public boolean areItemsTheSame(GroupItem a, GroupItem b) { - return a.getId().equals(b.getId()); - } - - int findItemPosition(GroupId g) { - for (int i = 0; i < items.size(); i++) { - GroupItem item = items.get(i); - if (item.getId().equals(g)) { - return i; - } + private static class GroupItemCallback extends ItemCallback { + @Override + public boolean areItemsTheSame(GroupItem a, GroupItem b) { + return a.equals(b); } - return INVALID_POSITION; - } - void removeItem(GroupId groupId) { - int pos = findItemPosition(groupId); - if (pos != INVALID_POSITION) items.removeItemAt(pos); + @Override + public boolean areContentsTheSame(GroupItem a, GroupItem b) { + return a.getMessageCount() == b.getMessageCount() && + a.getTimestamp() == b.getTimestamp() && + a.getUnreadCount() == b.getUnreadCount() && + a.isDissolved() == b.isDissolved(); + } } - } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListController.java b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListController.java deleted file mode 100644 index 47763f9d8..000000000 --- a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListController.java +++ /dev/null @@ -1,60 +0,0 @@ -package org.briarproject.briar.android.privategroup.list; - -import org.briarproject.bramble.api.db.DbException; -import org.briarproject.bramble.api.nullsafety.NotNullByDefault; -import org.briarproject.bramble.api.sync.GroupId; -import org.briarproject.briar.android.controller.DbController; -import org.briarproject.briar.android.controller.handler.ExceptionHandler; -import org.briarproject.briar.android.controller.handler.ResultExceptionHandler; -import org.briarproject.briar.api.privategroup.GroupMessageHeader; - -import java.util.Collection; - -import androidx.annotation.UiThread; - -@NotNullByDefault -interface GroupListController extends DbController { - - /** - * The listener must be set right after the controller was injected - */ - @UiThread - void setGroupListListener(GroupListListener listener); - - @UiThread - void unsetGroupListListener(GroupListListener listener); - - @UiThread - void onStart(); - - @UiThread - void onStop(); - - void loadGroups( - ResultExceptionHandler, DbException> result); - - void removeGroup(GroupId g, ExceptionHandler result); - - void loadAvailableGroups( - ResultExceptionHandler result); - - interface GroupListListener { - - @UiThread - void onGroupMessageAdded(GroupMessageHeader header); - - @UiThread - void onGroupInvitationReceived(); - - @UiThread - void onGroupAdded(GroupId groupId); - - @UiThread - void onGroupRemoved(GroupId groupId); - - @UiThread - void onGroupDissolved(GroupId groupId); - - } - -} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListFragment.java index 98cb53f90..dcd023e5f 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListFragment.java @@ -12,57 +12,50 @@ import android.view.ViewGroup; import com.google.android.material.snackbar.Snackbar; -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.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.fragment.BaseFragment; import org.briarproject.briar.android.privategroup.creation.CreateGroupActivity; import org.briarproject.briar.android.privategroup.invitation.GroupInvitationActivity; -import org.briarproject.briar.android.privategroup.list.GroupListController.GroupListListener; import org.briarproject.briar.android.privategroup.list.GroupViewHolder.OnGroupRemoveClickListener; import org.briarproject.briar.android.util.BriarSnackbarBuilder; import org.briarproject.briar.android.view.BriarRecyclerView; -import org.briarproject.briar.api.privategroup.GroupMessageHeader; - -import java.util.Collection; -import java.util.logging.Logger; import javax.annotation.Nullable; import javax.inject.Inject; import androidx.annotation.UiThread; +import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; import static com.google.android.material.snackbar.Snackbar.LENGTH_INDEFINITE; +import static java.util.Objects.requireNonNull; @MethodsNotNullByDefault @ParametersNotNullByDefault public class GroupListFragment extends BaseFragment implements - GroupListListener, OnGroupRemoveClickListener, OnClickListener { + OnGroupRemoveClickListener, OnClickListener { public final static String TAG = GroupListFragment.class.getName(); - private static final Logger LOG = Logger.getLogger(TAG); public static GroupListFragment newInstance() { return new GroupListFragment(); } @Inject - GroupListController controller; + ViewModelProvider.Factory viewModelFactory; + private GroupListViewModel viewModel; private BriarRecyclerView list; private GroupListAdapter adapter; - private Snackbar snackbar; @Override public void injectFragment(ActivityComponent component) { component.inject(this); - controller.setGroupListListener(this); + viewModel = new ViewModelProvider(this, viewModelFactory) + .get(GroupListViewModel.class); } @Nullable @@ -75,17 +68,32 @@ public class GroupListFragment extends BaseFragment implements View v = inflater.inflate(R.layout.list, container, false); - adapter = new GroupListAdapter(getActivity(), this); + adapter = new GroupListAdapter(this); list = v.findViewById(R.id.list); list.setEmptyImage(R.drawable.ic_empty_state_group_list); list.setEmptyText(R.string.groups_list_empty); list.setEmptyAction(R.string.groups_list_empty_action); list.setLayoutManager(new LinearLayoutManager(getContext())); list.setAdapter(adapter); + viewModel.getGroupItems().observe(getViewLifecycleOwner(), result -> + result.onError(this::handleException).onSuccess(items -> { + adapter.submitList(items); + if (requireNonNull(items).size() == 0) list.showData(); + }) + ); - snackbar = new BriarSnackbarBuilder() + Snackbar snackbar = new BriarSnackbarBuilder() .setAction(R.string.show, this) .make(list, "", LENGTH_INDEFINITE); + viewModel.getNumInvitations().observe(getViewLifecycleOwner(), num -> { + if (num == 0) { + snackbar.dismiss(); + } else { + snackbar.setText(getResources().getQuantityString( + R.plurals.groups_invitations_open, num, num)); + if (!snackbar.isShownOrQueued()) snackbar.show(); + } + }); return v; } @@ -93,25 +101,23 @@ public class GroupListFragment extends BaseFragment implements @Override public void onStart() { super.onStart(); - controller.onStart(); + viewModel.blockAllGroupMessageNotifications(); + viewModel.clearAllGroupMessageNotifications(); + // The attributes and sorting of the groups may have changed while we + // were stopped and we have no way finding out about them, so re-load + // e.g. less unread messages in a group after viewing it. + viewModel.loadGroups(); + // The number of invitations might have changed while we were stopped + // e.g. because of accepting an invitation which does not trigger event + viewModel.loadNumInvitations(); list.startPeriodicUpdate(); - loadGroups(); - loadAvailableGroups(); } @Override public void onStop() { super.onStop(); - controller.onStop(); list.stopPeriodicUpdate(); - adapter.clear(); - list.showProgressBar(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - controller.unsetGroupListListener(this); + viewModel.unblockAllGroupMessageNotifications(); } @Override @@ -122,68 +128,18 @@ public class GroupListFragment extends BaseFragment implements @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.action_add_group: - Intent i = new Intent(getContext(), CreateGroupActivity.class); - startActivity(i); - return true; - default: - return super.onOptionsItemSelected(item); + if (item.getItemId() == R.id.action_add_group) { + Intent i = new Intent(getContext(), CreateGroupActivity.class); + startActivity(i); + return true; } + return super.onOptionsItemSelected(item); } @UiThread @Override public void onGroupRemoveClick(GroupItem item) { - controller.removeGroup(item.getId(), - new UiExceptionHandler(this) { - // result handled by GroupRemovedEvent and onGroupRemoved() - @Override - public void onExceptionUi(DbException exception) { - handleDbException(exception); - } - }); - } - - @UiThread - @Override - public void onGroupMessageAdded(GroupMessageHeader header) { - adapter.incrementRevision(); - int position = adapter.findItemPosition(header.getGroupId()); - GroupItem item = adapter.getItemAt(position); - if (item != null) { - item.addMessageHeader(header); - adapter.updateItemAt(position, item); - } - } - - @Override - public void onGroupInvitationReceived() { - loadAvailableGroups(); - } - - @UiThread - @Override - public void onGroupAdded(GroupId groupId) { - loadGroups(); - } - - @UiThread - @Override - public void onGroupRemoved(GroupId groupId) { - adapter.incrementRevision(); - adapter.removeItem(groupId); - } - - @Override - public void onGroupDissolved(GroupId groupId) { - adapter.incrementRevision(); - int position = adapter.findItemPosition(groupId); - GroupItem item = adapter.getItemAt(position); - if (item != null) { - item.setDissolved(); - adapter.updateItemAt(position, item); - } + viewModel.removeGroup(item.getId()); } @Override @@ -191,52 +147,6 @@ public class GroupListFragment extends BaseFragment implements return TAG; } - private void loadGroups() { - int revision = adapter.getRevision(); - controller.loadGroups( - new UiResultExceptionHandler, DbException>( - this) { - @Override - public void onResultUi(Collection groups) { - if (revision == adapter.getRevision()) { - adapter.incrementRevision(); - if (groups.isEmpty()) list.showData(); - else adapter.replaceAll(groups); - } else { - LOG.info("Concurrent update, reloading"); - loadGroups(); - } - } - - @Override - public void onExceptionUi(DbException exception) { - handleDbException(exception); - } - }); - } - - private void loadAvailableGroups() { - controller.loadAvailableGroups( - new UiResultExceptionHandler(this) { - @Override - public void onResultUi(Integer num) { - if (num == 0) { - snackbar.dismiss(); - } else { - snackbar.setText(getResources().getQuantityString( - R.plurals.groups_invitations_open, num, - num)); - if (!snackbar.isShownOrQueued()) snackbar.show(); - } - } - - @Override - public void onExceptionUi(DbException exception) { - handleDbException(exception); - } - }); - } - /** * This method is handling the available groups snackbar action */ diff --git a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListModule.java b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListModule.java index fcf9c09d1..d8cb791a2 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListModule.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListModule.java @@ -1,17 +1,18 @@ package org.briarproject.briar.android.privategroup.list; -import org.briarproject.briar.android.activity.ActivityScope; +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 GroupListModule { +public abstract class GroupListModule { - @ActivityScope - @Provides - GroupListController provideGroupListController( - GroupListControllerImpl groupListController) { - return groupListController; - } + @Binds + @IntoMap + @ViewModelKey(GroupListViewModel.class) + abstract ViewModel bindGroupListViewModel( + GroupListViewModel groupListViewModel); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListControllerImpl.java b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListViewModel.java similarity index 50% rename from briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListControllerImpl.java rename to briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListViewModel.java index 51d97095e..25857fca5 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListControllerImpl.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupListViewModel.java @@ -1,9 +1,12 @@ package org.briarproject.briar.android.privategroup.list; +import android.app.Application; + import org.briarproject.bramble.api.contact.ContactManager; import org.briarproject.bramble.api.db.DatabaseExecutor; import org.briarproject.bramble.api.db.DbException; -import org.briarproject.bramble.api.db.NoSuchGroupException; +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.event.EventListener; @@ -16,11 +19,12 @@ import org.briarproject.bramble.api.sync.ClientId; import org.briarproject.bramble.api.sync.GroupId; import org.briarproject.bramble.api.sync.event.GroupAddedEvent; import org.briarproject.bramble.api.sync.event.GroupRemovedEvent; -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.bramble.api.system.AndroidExecutor; +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.GroupCount; +import org.briarproject.briar.api.privategroup.GroupMessageHeader; import org.briarproject.briar.api.privategroup.PrivateGroup; import org.briarproject.briar.api.privategroup.PrivateGroupManager; import org.briarproject.briar.api.privategroup.event.GroupDissolvedEvent; @@ -30,6 +34,7 @@ import org.briarproject.briar.api.privategroup.invitation.GroupInvitationManager import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -38,10 +43,13 @@ import java.util.logging.Logger; import javax.inject.Inject; -import androidx.annotation.CallSuper; -import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import static java.util.Objects.requireNonNull; 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; @@ -49,11 +57,10 @@ import static org.briarproject.briar.api.privategroup.PrivateGroupManager.CLIENT @MethodsNotNullByDefault @ParametersNotNullByDefault -class GroupListControllerImpl extends DbControllerImpl - implements GroupListController, EventListener { +class GroupListViewModel extends DbViewModel implements EventListener { private static final Logger LOG = - Logger.getLogger(GroupListControllerImpl.class.getName()); + getLogger(GroupListViewModel.class.getName()); private final PrivateGroupManager groupManager; private final GroupInvitationManager groupInvitationManager; @@ -61,120 +68,137 @@ class GroupListControllerImpl extends DbControllerImpl private final AndroidNotificationManager notificationManager; private final EventBus eventBus; - // UI thread - @Nullable - private GroupListListener listener; + private final MutableLiveData>> groupItems = + new MutableLiveData<>(); + private final MutableLiveData numInvitations = + new MutableLiveData<>(); @Inject - GroupListControllerImpl(@DatabaseExecutor Executor dbExecutor, - LifecycleManager lifecycleManager, PrivateGroupManager groupManager, + GroupListViewModel(Application application, + @DatabaseExecutor Executor dbExecutor, + LifecycleManager lifecycleManager, + TransactionManager db, + AndroidExecutor androidExecutor, + PrivateGroupManager groupManager, GroupInvitationManager groupInvitationManager, ContactManager contactManager, AndroidNotificationManager notificationManager, EventBus eventBus) { - super(dbExecutor, lifecycleManager); + super(application, dbExecutor, lifecycleManager, db, androidExecutor); this.groupManager = groupManager; this.groupInvitationManager = groupInvitationManager; this.contactManager = contactManager; this.notificationManager = notificationManager; this.eventBus = eventBus; + this.eventBus.addListener(this); } @Override - public void setGroupListListener(GroupListListener listener) { - this.listener = listener; - } - - @Override - public void unsetGroupListListener(GroupListListener listener) { - if (this.listener == listener) this.listener = null; - } - - @Override - @CallSuper - public void onStart() { - if (listener == null) throw new IllegalStateException(); - eventBus.addListener(this); - notificationManager.clearAllGroupMessageNotifications(); - } - - @Override - @CallSuper - public void onStop() { + protected void onCleared() { + super.onCleared(); eventBus.removeListener(this); } + void clearAllGroupMessageNotifications() { + notificationManager.clearAllGroupMessageNotifications(); + } + + void blockAllGroupMessageNotifications() { + notificationManager.blockAllGroupMessageNotifications(); + } + + void unblockAllGroupMessageNotifications() { + notificationManager.unblockAllGroupMessageNotifications(); + } + @Override - @CallSuper public void eventOccurred(Event e) { - if (listener == null) throw new IllegalStateException(); if (e instanceof GroupMessageAddedEvent) { GroupMessageAddedEvent g = (GroupMessageAddedEvent) e; LOG.info("Private group message added"); - listener.onGroupMessageAdded(g.getHeader()); + onGroupMessageAdded(g.getHeader()); } else if (e instanceof GroupInvitationRequestReceivedEvent) { LOG.info("Private group invitation received"); - listener.onGroupInvitationReceived(); + loadNumInvitations(); } else if (e instanceof GroupAddedEvent) { GroupAddedEvent g = (GroupAddedEvent) e; ClientId id = g.getGroup().getClientId(); if (id.equals(CLIENT_ID)) { LOG.info("Private group added"); - listener.onGroupAdded(g.getGroup().getId()); + loadGroups(); } } else if (e instanceof GroupRemovedEvent) { GroupRemovedEvent g = (GroupRemovedEvent) e; ClientId id = g.getGroup().getClientId(); if (id.equals(CLIENT_ID)) { LOG.info("Private group removed"); - listener.onGroupRemoved(g.getGroup().getId()); + onGroupRemoved(g.getGroup().getId()); } } else if (e instanceof GroupDissolvedEvent) { GroupDissolvedEvent g = (GroupDissolvedEvent) e; LOG.info("Private group dissolved"); - listener.onGroupDissolved(g.getGroupId()); + onGroupDissolved(g.getGroupId()); } } - @Override - public void loadGroups( - ResultExceptionHandler, DbException> handler) { - runOnDbThread(() -> { - try { - long start = now(); - Collection groups = - groupManager.getPrivateGroups(); - List items = new ArrayList<>(groups.size()); - Map authorInfos = new HashMap<>(); - for (PrivateGroup g : groups) { - try { - GroupId id = g.getId(); - AuthorId authorId = g.getCreator().getId(); - AuthorInfo authorInfo; - if (authorInfos.containsKey(authorId)) { - authorInfo = authorInfos.get(authorId); - } else { - authorInfo = contactManager.getAuthorInfo(authorId); - authorInfos.put(authorId, authorInfo); - } - GroupCount count = groupManager.getGroupCount(id); - boolean dissolved = groupManager.isDissolved(id); - items.add( - new GroupItem(g, authorInfo, count, dissolved)); - } catch (NoSuchGroupException e) { - // Continue - } - } - logDuration(LOG, "Loading groups", start); - handler.onResult(items); - } catch (DbException e) { - logException(LOG, WARNING, e); - handler.onException(e); - } - }); + void loadGroups() { + loadList(this::loadGroups, groupItems::setValue); } - @Override - public void removeGroup(GroupId g, ExceptionHandler handler) { + @DatabaseExecutor + private List loadGroups(Transaction txn) throws DbException { + long start = now(); + Collection groups = groupManager.getPrivateGroups(txn); + List items = new ArrayList<>(groups.size()); + Map authorInfos = new HashMap<>(); + for (PrivateGroup g : groups) { + GroupId id = g.getId(); + AuthorId authorId = g.getCreator().getId(); + AuthorInfo authorInfo; + if (authorInfos.containsKey(authorId)) { + authorInfo = requireNonNull(authorInfos.get(authorId)); + } else { + authorInfo = contactManager.getAuthorInfo(txn, authorId); + authorInfos.put(authorId, authorInfo); + } + GroupCount count = groupManager.getGroupCount(txn, id); + boolean dissolved = groupManager.isDissolved(txn, id); + items.add(new GroupItem(g, authorInfo, count, dissolved)); + } + Collections.sort(items); + logDuration(LOG, "Loading groups", start); + return items; + } + + @UiThread + private void onGroupMessageAdded(GroupMessageHeader header) { + GroupId g = header.getGroupId(); + List list = updateListItems(groupItems, + itemToTest -> itemToTest.getId().equals(g), + itemToUpdate -> new GroupItem(itemToUpdate, header)); + if (list == null) return; + // re-sort as the order of items may have changed + Collections.sort(list); + groupItems.setValue(new LiveResult<>(list)); + } + + @UiThread + private void onGroupDissolved(GroupId groupId) { + List list = updateListItems(groupItems, + itemToTest -> itemToTest.getId().equals(groupId), + itemToUpdate -> new GroupItem(itemToUpdate, true)); + if (list == null) return; + groupItems.setValue(new LiveResult<>(list)); + } + + @UiThread + private void onGroupRemoved(GroupId groupId) { + List list = + removeListItems(groupItems, i -> i.getId().equals(groupId)); + if (list == null) return; + groupItems.setValue(new LiveResult<>(list)); + } + + void removeGroup(GroupId g) { runOnDbThread(() -> { try { long start = now(); @@ -182,23 +206,26 @@ class GroupListControllerImpl extends DbControllerImpl logDuration(LOG, "Removing group", start); } catch (DbException e) { logException(LOG, WARNING, e); - handler.onException(e); } }); } - @Override - public void loadAvailableGroups( - ResultExceptionHandler handler) { + void loadNumInvitations() { runOnDbThread(() -> { try { - handler.onResult( - groupInvitationManager.getInvitations().size()); + int i = groupInvitationManager.getInvitations().size(); + numInvitations.postValue(i); } catch (DbException e) { logException(LOG, WARNING, e); - handler.onException(e); } }); } + LiveData>> getGroupItems() { + return groupItems; + } + + LiveData getNumInvitations() { + return numInvitations; + } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupViewHolder.java index f1c50eefb..4179b4061 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupViewHolder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/list/GroupViewHolder.java @@ -29,6 +29,7 @@ class GroupViewHolder extends RecyclerView.ViewHolder { private final static float ALPHA = 0.42f; + private final Context ctx; private final ViewGroup layout; private final TextAvatarView avatar; private final TextView name; @@ -40,7 +41,7 @@ class GroupViewHolder extends RecyclerView.ViewHolder { GroupViewHolder(View v) { super(v); - + ctx = v.getContext(); layout = (ViewGroup) v; avatar = v.findViewById(R.id.avatarView); name = v.findViewById(R.id.nameView); @@ -51,8 +52,7 @@ class GroupViewHolder extends RecyclerView.ViewHolder { remove = v.findViewById(R.id.removeButton); } - void bindView(Context ctx, GroupItem group, - OnGroupRemoveClickListener listener) { + void bindView(GroupItem group, OnGroupRemoveClickListener listener) { // Avatar avatar.setText(group.getName().substring(0, 1)); avatar.setBackgroundBytes(group.getId().getBytes()); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/memberlist/GroupMemberListActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/memberlist/GroupMemberListActivity.java index 6a6fe5a7a..d359068db 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/memberlist/GroupMemberListActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/memberlist/GroupMemberListActivity.java @@ -124,7 +124,7 @@ public class GroupMemberListActivity extends BriarActivity @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/reveal/RevealContactsActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/reveal/RevealContactsActivity.java index 2e906f5e4..33dd285ae 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/privategroup/reveal/RevealContactsActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/privategroup/reveal/RevealContactsActivity.java @@ -80,7 +80,7 @@ public class RevealContactsActivity extends ContactSelectorActivity @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } @@ -120,7 +120,7 @@ public class RevealContactsActivity extends ContactSelectorActivity new UiExceptionHandler(this) { @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } @@ -137,7 +137,7 @@ public class RevealContactsActivity extends ContactSelectorActivity new UiExceptionHandler(this) { @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); supportFinishAfterTransition(); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/sharing/InvitationActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/sharing/InvitationActivity.java index ab83a50a1..e82b19ad6 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/sharing/InvitationActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/sharing/InvitationActivity.java @@ -98,7 +98,7 @@ public abstract class InvitationActivity @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } @@ -110,7 +110,7 @@ public abstract class InvitationActivity new UiExceptionHandler(this) { @Override public void onExceptionUi(DbException exception) { - handleDbException(exception); + handleException(exception); } }); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareBlogActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareBlogActivity.java index 56bd95f72..9bc3cda43 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareBlogActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareBlogActivity.java @@ -59,7 +59,7 @@ public class ShareBlogActivity extends ShareActivity { Toast.makeText(ShareBlogActivity.this, R.string.blogs_sharing_error, LENGTH_SHORT) .show(); - handleDbException(exception); + handleException(exception); } }); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareForumActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareForumActivity.java index 5969ea92d..f2c1d63e5 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareForumActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/sharing/ShareForumActivity.java @@ -59,7 +59,7 @@ public class ShareForumActivity extends ShareActivity { Toast.makeText(ShareForumActivity.this, R.string.forum_share_error, LENGTH_SHORT) .show(); - handleDbException(exception); + handleException(exception); } }); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java index b01248782..22138f19e 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/threaded/ThreadListActivity.java @@ -152,7 +152,7 @@ public abstract class ThreadListActivity + * If you need a list of items to be displayed in a + * {@link RecyclerView.Adapter}, + * use {@link #loadList(DbCallable, UiConsumer)} instead. + */ + protected void runOnDbThread(Runnable task) { dbExecutor.execute(() -> { try { lifecycleManager.waitForDatabase(); @@ -47,4 +76,120 @@ public abstract class DbViewModel extends AndroidViewModel { }); } + /** + * Loads a list of items on the {@link DatabaseExecutor} within a single + * {@link Transaction} and publishes it as a {@link LiveResult} + * to the {@link UiThread}. + *

+ * Use this to ensure that modifications to your local list do not get + * overridden by database loads that were in progress while the modification + * was made. + * E.g. An event about the removal of a message causes the message item to + * be removed from the local list while all messages are reloaded. + * This method ensures that those operations can be processed on the + * UiThread in the correct order so that the removed message will not be + * re-added when the re-load completes. + */ + protected > void loadList( + DbCallable task, + UiConsumer> uiConsumer) { + dbExecutor.execute(() -> { + try { + lifecycleManager.waitForDatabase(); + db.transaction(true, txn -> { + T t = task.call(txn); + txn.attach(() -> uiConsumer.accept(new LiveResult<>(t))); + }); + } catch (InterruptedException e) { + LOG.warning("Interrupted while waiting for database"); + Thread.currentThread().interrupt(); + } catch (DbException e) { + logException(LOG, WARNING, e); + androidExecutor.runOnUiThread( + () -> uiConsumer.accept(new LiveResult<>(e))); + } + }); + } + + @NotNullByDefault + public interface UiConsumer { + @UiThread + void accept(T t); + } + + /** + * Creates a copy of the list available in the given LiveData + * and replaces items where the given test function returns true. + * + * @return a copy of the list in the LiveData with item(s) replaced + * or null when the + *

    + *
  • LiveData does not have a value + *
  • LiveResult in the LiveData has an error + *
  • test function did return false for all items in the list + *
+ */ + @Nullable + protected List updateListItems( + LiveData>> liveData, Function test, + Function replacer) { + List items = getListCopy(liveData); + if (items == null) return null; + + ListIterator iterator = items.listIterator(); + boolean changed = false; + while (iterator.hasNext()) { + T item = iterator.next(); + if (test.apply(item)) { + changed = true; + iterator.set(replacer.apply(item)); + } + } + return changed ? items : null; + } + + /** + * Creates a copy of the list available in the given LiveData + * and removes the items from it where the given test function returns true. + * + * @return a copy of the list in the LiveData with item(s) removed + * or null when the + *
    + *
  • LiveData does not have a value + *
  • LiveResult in the LiveData has an error + *
  • test function did return false for all items in the list + *
+ */ + @Nullable + protected List removeListItems( + LiveData>> liveData, Function test) { + List items = getListCopy(liveData); + if (items == null) return null; + + ListIterator iterator = items.listIterator(); + boolean changed = false; + while (iterator.hasNext()) { + T item = iterator.next(); + if (test.apply(item)) { + changed = true; + iterator.remove(); + } + } + return changed ? items : null; + } + + /** + * Retrieves a copy of the list of items from the given LiveData + * or null if it is not available. + * The list copy can be safely mutated. + */ + @Nullable + private List getListCopy(LiveData>> liveData) { + LiveResult> value = liveData.getValue(); + if (value == null) return null; + List list = value.getResultOrNull(); + if (list == null) return null; + return new ArrayList<>(list); + } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/LiveResult.java b/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/LiveResult.java index 2d2d09da6..dffb946b1 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/LiveResult.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/LiveResult.java @@ -3,14 +3,15 @@ package org.briarproject.briar.android.viewmodel; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import androidx.annotation.Nullable; +import androidx.core.util.Consumer; @NotNullByDefault public class LiveResult { @Nullable - private T result; + private final T result; @Nullable - private Exception exception; + private final Exception exception; public LiveResult(T result) { this.result = result; @@ -36,4 +37,20 @@ public class LiveResult { return exception != null; } + /** + * Runs the given function, if {@link #hasError()} is true. + */ + public LiveResult onError(Consumer fun) { + if (exception != null) fun.accept(exception); + return this; + } + + /** + * Runs the given function, if {@link #hasError()} is false. + */ + public LiveResult onSuccess(Consumer fun) { + if (result != null) fun.accept(result); + return this; + } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java b/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java index eec2b8e9f..b2eeebd16 100644 --- a/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java +++ b/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java @@ -82,6 +82,10 @@ public interface AndroidNotificationManager { void unblockNotification(GroupId g); + void blockAllGroupMessageNotifications(); + + void unblockAllGroupMessageNotifications(); + void blockAllBlogPostNotifications(); void unblockAllBlogPostNotifications(); diff --git a/briar-android/src/test/java/org/briarproject/briar/android/AndroidExecutorTestImpl.java b/briar-android/src/test/java/org/briarproject/briar/android/AndroidExecutorTestImpl.java new file mode 100644 index 000000000..5c02d8718 --- /dev/null +++ b/briar-android/src/test/java/org/briarproject/briar/android/AndroidExecutorTestImpl.java @@ -0,0 +1,36 @@ +package org.briarproject.briar.android; + +import org.briarproject.bramble.api.system.AndroidExecutor; + +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +public class AndroidExecutorTestImpl implements AndroidExecutor { + + private final Executor executor; + + public AndroidExecutorTestImpl(Executor executor) { + this.executor = executor; + } + + @Override + public Future runOnBackgroundThread(Callable c) { + throw new IllegalStateException("not implemented"); + } + + @Override + public void runOnBackgroundThread(Runnable r) { + executor.execute(r); + } + + @Override + public Future runOnUiThread(Callable c) { + throw new IllegalStateException("not implemented"); + } + + @Override + public void runOnUiThread(Runnable r) { + executor.execute(r); + } +} diff --git a/briar-android/src/test/java/org/briarproject/briar/android/privategroup/list/GroupListViewModelTest.java b/briar-android/src/test/java/org/briarproject/briar/android/privategroup/list/GroupListViewModelTest.java new file mode 100644 index 000000000..8030995b0 --- /dev/null +++ b/briar-android/src/test/java/org/briarproject/briar/android/privategroup/list/GroupListViewModelTest.java @@ -0,0 +1,212 @@ +package org.briarproject.briar.android.privategroup.list; + +import android.app.Application; + +import org.briarproject.bramble.api.contact.Contact; +import org.briarproject.bramble.api.contact.ContactManager; +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.event.EventListener; +import org.briarproject.bramble.api.identity.AuthorInfo; +import org.briarproject.bramble.api.lifecycle.LifecycleManager; +import org.briarproject.bramble.api.sync.Group; +import org.briarproject.bramble.api.system.AndroidExecutor; +import org.briarproject.bramble.test.BrambleMockTestCase; +import org.briarproject.bramble.test.DbExpectations; +import org.briarproject.bramble.test.ImmediateExecutor; +import org.briarproject.briar.android.AndroidExecutorTestImpl; +import org.briarproject.briar.android.viewmodel.LiveResult; +import org.briarproject.briar.api.android.AndroidNotificationManager; +import org.briarproject.briar.api.privategroup.PrivateGroup; +import org.briarproject.briar.api.privategroup.PrivateGroupManager; +import org.briarproject.briar.api.privategroup.event.GroupDissolvedEvent; +import org.briarproject.briar.api.privategroup.invitation.GroupInvitationItem; +import org.briarproject.briar.api.privategroup.invitation.GroupInvitationManager; +import org.jmock.Expectations; +import org.jmock.lib.legacy.ClassImposteriser; +import org.junit.Rule; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Executor; + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule; + +import static edu.emory.mathcs.backport.java.util.Collections.emptyList; +import static edu.emory.mathcs.backport.java.util.Collections.singletonList; +import static org.briarproject.bramble.test.TestUtils.getAuthor; +import static org.briarproject.bramble.test.TestUtils.getContact; +import static org.briarproject.bramble.test.TestUtils.getGroup; +import static org.briarproject.bramble.test.TestUtils.getRandomBytes; +import static org.briarproject.briar.android.viewmodel.LiveDataTestUtil.getOrAwaitValue; +import static org.briarproject.briar.api.client.MessageTracker.GroupCount; +import static org.briarproject.briar.api.privategroup.invitation.GroupInvitationManager.CLIENT_ID; +import static org.briarproject.briar.api.privategroup.invitation.GroupInvitationManager.MAJOR_VERSION; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class GroupListViewModelTest extends BrambleMockTestCase { + + @Rule + public final InstantTaskExecutorRule testRule = + new InstantTaskExecutorRule(); + + private final LifecycleManager lifecycleManager = + context.mock(LifecycleManager.class); + private final TransactionManager db = + context.mock(TransactionManager.class); + private final PrivateGroupManager groupManager = + context.mock(PrivateGroupManager.class); + private final GroupInvitationManager groupInvitationManager = + context.mock(GroupInvitationManager.class); + private final ContactManager contactManager = + context.mock(ContactManager.class); + private final AndroidNotificationManager notificationManager = + context.mock(AndroidNotificationManager.class); + private final EventBus eventBus = context.mock(EventBus.class); + + private final GroupListViewModel viewModel; + + + private final Group g1 = getGroup(CLIENT_ID, MAJOR_VERSION); + private final Group g2 = getGroup(CLIENT_ID, MAJOR_VERSION); + private final PrivateGroup privateGroup1 = + new PrivateGroup(g1, "foo", getAuthor(), getRandomBytes(2)); + private final PrivateGroup privateGroup2 = + new PrivateGroup(g2, "bar", getAuthor(), getRandomBytes(2)); + private final AuthorInfo authorInfo1 = + new AuthorInfo(AuthorInfo.Status.UNVERIFIED); + private final AuthorInfo authorInfo2 = + new AuthorInfo(AuthorInfo.Status.VERIFIED); + + private final GroupCount groupCount1 = new GroupCount(2, 1, 23L); + private final GroupCount groupCount2 = new GroupCount(5, 3, 42L); + private final GroupItem item1 = + new GroupItem(privateGroup1, authorInfo1, groupCount1, false); + private final GroupItem item2 = + new GroupItem(privateGroup2, authorInfo2, groupCount2, false); + + public GroupListViewModelTest() { + context.setImposteriser(ClassImposteriser.INSTANCE); + Application app = context.mock(Application.class); + context.checking(new Expectations() {{ + oneOf(eventBus).addListener(with(any(EventListener.class))); + }}); + Executor dbExecutor = new ImmediateExecutor(); + AndroidExecutor androidExecutor = + new AndroidExecutorTestImpl(dbExecutor); + viewModel = new GroupListViewModel(app, dbExecutor, lifecycleManager, + db, androidExecutor, groupManager, groupInvitationManager, + contactManager, notificationManager, eventBus); + } + + @Test + public void testLoadGroupsException() throws Exception { + DbException dbException = new DbException(); + + Transaction txn = new Transaction(null, true); + context.checking(new DbExpectations() {{ + oneOf(lifecycleManager).waitForDatabase(); + oneOf(db).transaction(with(true), withDbRunnable(txn)); + oneOf(groupManager).getPrivateGroups(txn); + will(throwException(dbException)); + }}); + + viewModel.loadGroups(); + + LiveResult> result = + getOrAwaitValue(viewModel.getGroupItems()); + assertTrue(result.hasError()); + assertEquals(dbException, result.getException()); + assertNull(result.getResultOrNull()); + } + + @Test + public void testLoadGroups() throws Exception { + Transaction txn = new Transaction(null, true); + context.checking(new DbExpectations() {{ + oneOf(lifecycleManager).waitForDatabase(); + oneOf(db).transaction(with(true), withDbRunnable(txn)); + oneOf(groupManager).getPrivateGroups(txn); + will(returnValue(Arrays.asList(privateGroup1, privateGroup2))); + }}); + expectLoadGroup(txn, privateGroup1, authorInfo1, groupCount1, false); + expectLoadGroup(txn, privateGroup2, authorInfo2, groupCount2, false); + + viewModel.loadGroups(); + + // unpack updated live data + LiveResult> result = + getOrAwaitValue(viewModel.getGroupItems()); + assertFalse(result.hasError()); + List liveList = result.getResultOrNull(); + assertNotNull(liveList); + // list is sorted by last message timestamp + assertEquals(Arrays.asList(item2, item1), liveList); + + // group 1 gets dissolved by creator + Event dissolvedEvent = new GroupDissolvedEvent(privateGroup1.getId()); + viewModel.eventOccurred(dissolvedEvent); + result = getOrAwaitValue(viewModel.getGroupItems()); + liveList = result.getResultOrNull(); + assertNotNull(liveList); + assertEquals(2, liveList.size()); + // assert that list update includes dissolved group item + for (GroupItem item : liveList) { + if (item.getId().equals(privateGroup1.getId())) { + assertTrue(item.isDissolved()); + } else if (item.getId().equals(privateGroup2.getId())) { + assertFalse(item.isDissolved()); + } else fail(); + } + } + + @Test + public void testLoadNumInvitations() throws Exception { + context.checking(new Expectations() {{ + oneOf(lifecycleManager).waitForDatabase(); + oneOf(groupInvitationManager).getInvitations(); + will(returnValue(emptyList())); + }}); + viewModel.loadNumInvitations(); + + int num = getOrAwaitValue(viewModel.getNumInvitations()); + assertEquals(0, num); + + PrivateGroup pg = context.mock(PrivateGroup.class); + Contact c = getContact(); + GroupInvitationItem item = new GroupInvitationItem(pg, c); + context.checking(new Expectations() {{ + oneOf(lifecycleManager).waitForDatabase(); + oneOf(groupInvitationManager).getInvitations(); + will(returnValue(singletonList(item))); + }}); + viewModel.loadNumInvitations(); + + num = getOrAwaitValue(viewModel.getNumInvitations()); + assertEquals(1, num); + } + + private void expectLoadGroup(Transaction txn, PrivateGroup privateGroup, + AuthorInfo authorInfo, GroupCount groupCount, boolean dissolved) + throws DbException { + context.checking(new DbExpectations() {{ + oneOf(contactManager) + .getAuthorInfo(txn, privateGroup.getCreator().getId()); + will(returnValue(authorInfo)); + oneOf(groupManager).getGroupCount(txn, privateGroup.getId()); + will(returnValue(groupCount)); + oneOf(groupManager).isDissolved(txn, privateGroup.getId()); + will(returnValue(dissolved)); + }}); + } + +} diff --git a/briar-android/src/test/java/org/briarproject/briar/android/viewmodel/LiveDataTestUtil.java b/briar-android/src/test/java/org/briarproject/briar/android/viewmodel/LiveDataTestUtil.java new file mode 100644 index 000000000..f1983ab73 --- /dev/null +++ b/briar-android/src/test/java/org/briarproject/briar/android/viewmodel/LiveDataTestUtil.java @@ -0,0 +1,36 @@ +/* Copyright 2019 Google LLC. + SPDX-License-Identifier: Apache-2.0 + https://gist.github.com/JoseAlcerreca/1e9ee05dcdd6a6a6fa1cbfc125559bba + */ + +package org.briarproject.briar.android.viewmodel; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; + +public class LiveDataTestUtil { + public static T getOrAwaitValue(final LiveData liveData) + throws InterruptedException { + final AtomicReference data = new AtomicReference<>(); + final CountDownLatch latch = new CountDownLatch(1); + Observer observer = new Observer() { + @Override + public void onChanged(@Nullable T o) { + data.set(o); + latch.countDown(); + liveData.removeObserver(this); + } + }; + liveData.observeForever(observer); + // Don't wait indefinitely if the LiveData is not set. + if (!latch.await(2, TimeUnit.SECONDS)) { + throw new RuntimeException("LiveData value was never set."); + } + return data.get(); + } +} diff --git a/briar-api/src/main/java/org/briarproject/briar/api/privategroup/PrivateGroupManager.java b/briar-api/src/main/java/org/briarproject/briar/api/privategroup/PrivateGroupManager.java index d47c49f9a..478b03975 100644 --- a/briar-api/src/main/java/org/briarproject/briar/api/privategroup/PrivateGroupManager.java +++ b/briar-api/src/main/java/org/briarproject/briar/api/privategroup/PrivateGroupManager.java @@ -66,6 +66,11 @@ public interface PrivateGroupManager { */ void markGroupDissolved(Transaction txn, GroupId g) throws DbException; + /** + * Returns true if the given private group has been dissolved. + */ + boolean isDissolved(Transaction txn, GroupId g) throws DbException; + /** * Returns true if the given private group has been dissolved. */ @@ -91,6 +96,12 @@ public interface PrivateGroupManager { */ Collection getPrivateGroups() throws DbException; + /** + * Returns all private groups the user is a member of. + */ + Collection getPrivateGroups(Transaction txn) + throws DbException; + /** * Returns the text of the private group message with the given ID. */ @@ -111,6 +122,11 @@ public interface PrivateGroupManager { */ boolean isMember(Transaction txn, GroupId g, Author a) throws DbException; + /** + * Returns the group count for the given private group. + */ + GroupCount getGroupCount(Transaction txn, GroupId g) throws DbException; + /** * Returns the group count for the given private group. */ diff --git a/briar-core/src/main/java/org/briarproject/briar/privategroup/PrivateGroupManagerImpl.java b/briar-core/src/main/java/org/briarproject/briar/privategroup/PrivateGroupManagerImpl.java index 27f5fd649..72d1ae12e 100644 --- a/briar-core/src/main/java/org/briarproject/briar/privategroup/PrivateGroupManagerImpl.java +++ b/briar-core/src/main/java/org/briarproject/briar/privategroup/PrivateGroupManagerImpl.java @@ -270,22 +270,31 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook } @Override - public Collection getPrivateGroups() throws DbException { - Collection groups; - Transaction txn = db.startTransaction(true); + public Collection getPrivateGroups(Transaction txn) + throws DbException { + Collection groups = db.getGroups(txn, CLIENT_ID, MAJOR_VERSION); + Collection privateGroups = new ArrayList<>(groups.size()); try { - groups = db.getGroups(txn, CLIENT_ID, MAJOR_VERSION); - db.commitTransaction(txn); - } finally { - db.endTransaction(txn); - } - try { - Collection privateGroups = - new ArrayList<>(groups.size()); for (Group g : groups) { privateGroups.add(privateGroupFactory.parsePrivateGroup(g)); } - return privateGroups; + } catch (FormatException e) { + throw new DbException(e); + } + return privateGroups; + } + + @Override + public Collection getPrivateGroups() throws DbException { + return db.transactionWithResult(true, this::getPrivateGroups); + } + + @Override + public boolean isDissolved(Transaction txn, GroupId g) throws DbException { + try { + BdfDictionary meta = + clientHelper.getGroupMetadataAsDictionary(txn, g); + return meta.getBoolean(GROUP_KEY_DISSOLVED); } catch (FormatException e) { throw new DbException(e); } @@ -293,12 +302,7 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook @Override public boolean isDissolved(GroupId g) throws DbException { - try { - BdfDictionary meta = clientHelper.getGroupMetadataAsDictionary(g); - return meta.getBoolean(GROUP_KEY_DISSOLVED); - } catch (FormatException e) { - throw new DbException(e); - } + return db.transactionWithResult(true, txn -> isDissolved(txn, g)); } @Override @@ -403,7 +407,8 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook PrivateGroup privateGroup = getPrivateGroup(txn, g); for (Entry m : authors.entrySet()) { Author a = m.getKey(); - AuthorInfo authorInfo = contactManager.getAuthorInfo(txn, a.getId()); + AuthorInfo authorInfo = + contactManager.getAuthorInfo(txn, a.getId()); Status status = authorInfo.getStatus(); Visibility v = m.getValue(); ContactId c = null; @@ -450,6 +455,12 @@ class PrivateGroupManagerImpl extends BdfIncomingMessageHook return false; } + @Override + public GroupCount getGroupCount(Transaction txn, GroupId g) + throws DbException { + return messageTracker.getGroupCount(txn, g); + } + @Override public GroupCount getGroupCount(GroupId g) throws DbException { return messageTracker.getGroupCount(g);