diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/contact/ContactManager.java b/bramble-api/src/main/java/org/briarproject/bramble/api/contact/ContactManager.java index b213044aa..fc471e141 100644 --- a/bramble-api/src/main/java/org/briarproject/bramble/api/contact/ContactManager.java +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/contact/ContactManager.java @@ -179,6 +179,11 @@ public interface ContactManager { */ Collection getContacts() throws DbException; + /** + * Returns all contacts. + */ + Collection getContacts(Transaction txn) throws DbException; + /** * Removes a contact and all associated state. */ diff --git a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java index 2ebd2848b..c3cc67750 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java @@ -213,6 +213,11 @@ class ContactManagerImpl implements ContactManager, EventListener { return db.transactionWithResult(true, db::getContacts); } + @Override + public Collection getContacts(Transaction txn) throws DbException { + return db.getContacts(txn); + } + @Override public void removeContact(ContactId c) throws DbException { db.transaction(false, txn -> removeContact(txn, c)); 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 3971df6e8..747f60897 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 @@ -28,6 +28,7 @@ import org.briarproject.bramble.plugin.tor.AndroidTorPluginFactory; import org.briarproject.bramble.util.AndroidUtils; import org.briarproject.bramble.util.StringUtils; import org.briarproject.briar.android.account.LockManagerImpl; +import org.briarproject.briar.android.contact.ContactListModule; import org.briarproject.briar.android.forum.ForumModule; import org.briarproject.briar.android.keyagreement.ContactExchangeModule; import org.briarproject.briar.android.login.LoginModule; @@ -68,6 +69,7 @@ import static org.briarproject.briar.android.TestingConstants.IS_DEBUG_BUILD; NavDrawerModule.class, ViewModelModule.class, DevReportModule.class, + ContactListModule.class, // below need to be within same scope as ViewModelProvider.Factory ForumModule.BindsModule.class, GroupListModule.class, 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 0154b0469..11e5d02a7 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 @@ -21,7 +21,6 @@ import org.briarproject.briar.android.blog.RssFeedImportActivity; import org.briarproject.briar.android.blog.RssFeedManageActivity; import org.briarproject.briar.android.blog.WriteBlogPostActivity; import org.briarproject.briar.android.contact.ContactListFragment; -import org.briarproject.briar.android.contact.ContactModule; import org.briarproject.briar.android.contact.add.remote.AddContactActivity; import org.briarproject.briar.android.contact.add.remote.LinkExchangeFragment; import org.briarproject.briar.android.contact.add.remote.NicknameFragment; @@ -88,7 +87,6 @@ import dagger.Component; @Component(modules = { ActivityModule.class, BlogModule.class, - ContactModule.class, CreateGroupModule.class, ForumModule.class, GroupInvitationModule.class, diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactItem.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactItem.java index e2e5f0351..25d2f2806 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactItem.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactItem.java @@ -3,14 +3,14 @@ package org.briarproject.briar.android.contact; import org.briarproject.bramble.api.contact.Contact; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; -import javax.annotation.concurrent.NotThreadSafe; +import javax.annotation.concurrent.Immutable; -@NotThreadSafe +@Immutable @NotNullByDefault public class ContactItem { private final Contact contact; - private boolean connected; + private final boolean connected; public ContactItem(Contact contact) { this(contact, false); @@ -29,8 +29,4 @@ public class ContactItem { return connected; } - void setConnected(boolean connected) { - this.connected = connected; - } - } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListAdapter.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListAdapter.java index 0c8cf5e12..5ea246827 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListAdapter.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListAdapter.java @@ -1,50 +1,68 @@ package org.briarproject.briar.android.contact; -import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.briar.R; +import org.briarproject.briar.android.contact.BaseContactListAdapter.OnContactClickListener; + +import androidx.recyclerview.widget.DiffUtil.ItemCallback; +import androidx.recyclerview.widget.ListAdapter; @NotNullByDefault public class ContactListAdapter extends - BaseContactListAdapter { + ListAdapter { - public ContactListAdapter(Context context, + // TODO: using the click listener interface from BaseContactListAdapter on + // purpose here because it is entangled with ContactListItemViewHolder. At + // some point we probably want to change that. + protected final OnContactClickListener listener; + + public ContactListAdapter( OnContactClickListener listener) { - super(context, ContactListItem.class, listener); + super(new ContactListCallback()); + this.listener = listener; + } + + @NotNullByDefault + private static class ContactListCallback + extends ItemCallback { + @Override + public boolean areItemsTheSame(ContactListItem c1, ContactListItem c2) { + return c1.getContact().equals(c2.getContact()); + } + + @Override + public boolean areContentsTheSame(ContactListItem c1, + ContactListItem c2) { + // check for all properties that influence visual + // representation of contact + if (c1.isEmpty() != c2.isEmpty()) { + return false; + } + if (c1.getUnreadCount() != c2.getUnreadCount()) { + return false; + } + if (c1.getTimestamp() != c2.getTimestamp()) { + return false; + } + return c1.isConnected() == c2.isConnected(); + } } @Override public ContactListItemViewHolder onCreateViewHolder(ViewGroup viewGroup, - int i) { + int viewType) { View v = LayoutInflater.from(viewGroup.getContext()).inflate( R.layout.list_item_contact, viewGroup, false); - return new ContactListItemViewHolder(v); } @Override - public boolean areContentsTheSame(ContactListItem c1, ContactListItem c2) { - // check for all properties that influence visual - // representation of contact - if (c1.isEmpty() != c2.isEmpty()) { - return false; - } - if (c1.getUnreadCount() != c2.getUnreadCount()) { - return false; - } - if (c1.getTimestamp() != c2.getTimestamp()) { - return false; - } - return c1.isConnected() == c2.isConnected(); + public void onBindViewHolder(ContactListItemViewHolder viewHolder, + int position) { + viewHolder.bind(getItem(position), listener); } - - @Override - public int compare(ContactListItem c1, ContactListItem c2) { - return Long.compare(c2.getTimestamp(), c1.getTimestamp()); - } - } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListFragment.java index 46941ffe6..1a803c9a6 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListFragment.java @@ -10,23 +10,9 @@ import android.widget.TextView; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.Snackbar; -import org.briarproject.bramble.api.connection.ConnectionRegistry; -import org.briarproject.bramble.api.contact.Contact; import org.briarproject.bramble.api.contact.ContactId; -import org.briarproject.bramble.api.contact.ContactManager; -import org.briarproject.bramble.api.contact.event.ContactAddedEvent; -import org.briarproject.bramble.api.contact.event.ContactRemovedEvent; -import org.briarproject.bramble.api.contact.event.PendingContactAddedEvent; -import org.briarproject.bramble.api.contact.event.PendingContactRemovedEvent; -import org.briarproject.bramble.api.db.DbException; -import org.briarproject.bramble.api.db.NoSuchContactException; -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.MethodsNotNullByDefault; import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; -import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent; -import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent; import org.briarproject.briar.R; import org.briarproject.briar.android.activity.ActivityComponent; import org.briarproject.briar.android.contact.BaseContactListAdapter.OnContactClickListener; @@ -37,55 +23,35 @@ import org.briarproject.briar.android.fragment.BaseFragment; import org.briarproject.briar.android.keyagreement.ContactExchangeActivity; import org.briarproject.briar.android.util.BriarSnackbarBuilder; import org.briarproject.briar.android.view.BriarRecyclerView; -import org.briarproject.briar.api.android.AndroidNotificationManager; -import org.briarproject.briar.api.client.MessageTracker.GroupCount; -import org.briarproject.briar.api.conversation.ConversationManager; -import org.briarproject.briar.api.conversation.ConversationMessageHeader; -import org.briarproject.briar.api.conversation.event.ConversationMessageReceivedEvent; - -import java.util.ArrayList; -import java.util.List; -import java.util.logging.Logger; import javax.annotation.Nullable; import javax.inject.Inject; import androidx.annotation.UiThread; -import androidx.core.app.ActivityCompat; -import androidx.core.app.ActivityOptionsCompat; -import androidx.core.util.Pair; +import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; import io.github.kobakei.materialfabspeeddial.FabSpeedDial; import io.github.kobakei.materialfabspeeddial.FabSpeedDial.OnMenuItemClickListener; -import static android.os.Build.VERSION.SDK_INT; -import static androidx.core.app.ActivityOptionsCompat.makeSceneTransitionAnimation; -import static androidx.core.view.ViewCompat.getTransitionName; import static com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_INDEFINITE; -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; +import static org.briarproject.bramble.api.nullsafety.NullSafety.requireNonNull; import static org.briarproject.briar.android.conversation.ConversationActivity.CONTACT_ID; -import static org.briarproject.briar.android.util.UiUtils.isSamsung7; @MethodsNotNullByDefault @ParametersNotNullByDefault -public class ContactListFragment extends BaseFragment implements EventListener, - OnMenuItemClickListener { +public class ContactListFragment extends BaseFragment + implements OnMenuItemClickListener, + OnContactClickListener { public static final String TAG = ContactListFragment.class.getName(); - private static final Logger LOG = Logger.getLogger(TAG); @Inject - ConnectionRegistry connectionRegistry; - @Inject - EventBus eventBus; - @Inject - AndroidNotificationManager notificationManager; + ViewModelProvider.Factory viewModelFactory; - private ContactListAdapter adapter; + private ContactListViewModel viewModel; + private final ContactListAdapter adapter = new ContactListAdapter(this); private BriarRecyclerView list; + /** * The Snackbar is non-null when shown and null otherwise. * Use {@link #showSnackBar()} and {@link #dismissSnackBar()} to interact. @@ -93,12 +59,6 @@ public class ContactListFragment extends BaseFragment implements EventListener, @Nullable private Snackbar snackbar = null; - // Fields that are accessed from background threads must be volatile - @Inject - volatile ContactManager contactManager; - @Inject - volatile ConversationManager conversationManager; - public static ContactListFragment newInstance() { Bundle args = new Bundle(); ContactListFragment fragment = new ContactListFragment(); @@ -114,6 +74,8 @@ public class ContactListFragment extends BaseFragment implements EventListener, @Override public void injectFragment(ActivityComponent component) { component.inject(this); + viewModel = new ViewModelProvider(this, viewModelFactory) + .get(ContactListViewModel.class); } @Nullable @@ -129,37 +91,6 @@ public class ContactListFragment extends BaseFragment implements EventListener, FabSpeedDial speedDial = contentView.findViewById(R.id.speedDial); speedDial.addOnMenuItemClickListener(this); - OnContactClickListener onContactClickListener = - (view, item) -> { - Intent i = new Intent(getActivity(), - ConversationActivity.class); - ContactId contactId = item.getContact().getId(); - i.putExtra(CONTACT_ID, contactId.getInt()); - - if (SDK_INT >= 23 && !isSamsung7()) { - ContactListItemViewHolder holder = - (ContactListItemViewHolder) list - .getRecyclerView() - .findViewHolderForAdapterPosition( - adapter.findItemPosition(item)); - Pair avatar = - Pair.create(holder.avatar, - getTransitionName(holder.avatar)); - Pair bulb = - Pair.create(holder.bulb, - getTransitionName(holder.bulb)); - ActivityOptionsCompat options = - makeSceneTransitionAnimation(getActivity(), - avatar, bulb); - ActivityCompat.startActivity(getActivity(), i, - options.toBundle()); - } else { - // work-around for android bug #224270 - startActivity(i); - } - }; - adapter = new ContactListAdapter(requireContext(), - onContactClickListener); list = contentView.findViewById(R.id.list); list.setLayoutManager(new LinearLayoutManager(requireContext())); list.setAdapter(adapter); @@ -167,9 +98,30 @@ public class ContactListFragment extends BaseFragment implements EventListener, list.setEmptyText(getString(R.string.no_contacts)); list.setEmptyAction(getString(R.string.no_contacts_action)); + viewModel.getContactListItems() + .observe(getViewLifecycleOwner(), result -> { + result.onError(this::handleException).onSuccess(items -> { + adapter.submitList(items); + if (requireNonNull(items).size() == 0) list.showData(); + }); + }); + viewModel.getHasPendingContacts() + .observe(getViewLifecycleOwner(), hasPending -> { + if (hasPending) showSnackBar(); + else dismissSnackBar(); + }); + return contentView; } + @Override + public void onItemClick(View view, ContactListItem item) { + Intent i = new Intent(getActivity(), ConversationActivity.class); + ContactId contactId = item.getContact().getId(); + i.putExtra(CONTACT_ID, contactId.getInt()); + startActivity(i); + } + @Override public void onMenuItemClick(FloatingActionButton fab, @Nullable TextView v, int itemId) { @@ -188,131 +140,20 @@ public class ContactListFragment extends BaseFragment implements EventListener, @Override public void onStart() { super.onStart(); - eventBus.addListener(this); - notificationManager.clearAllContactNotifications(); - notificationManager.clearAllContactAddedNotifications(); - loadContacts(); - checkForPendingContacts(); + viewModel.clearAllContactNotifications(); + viewModel.clearAllContactAddedNotifications(); + viewModel.loadContacts(); + viewModel.checkForPendingContacts(); list.startPeriodicUpdate(); } - private void checkForPendingContacts() { - listener.runOnDbThread(() -> { - try { - if (contactManager.getPendingContacts().isEmpty()) { - runOnUiThreadUnlessDestroyed(this::dismissSnackBar); - } else { - runOnUiThreadUnlessDestroyed(this::showSnackBar); - } - } catch (DbException e) { - logException(LOG, WARNING, e); - } - }); - } - @Override public void onStop() { super.onStop(); - eventBus.removeListener(this); - adapter.clear(); - list.showProgressBar(); list.stopPeriodicUpdate(); dismissSnackBar(); } - private void loadContacts() { - int revision = adapter.getRevision(); - listener.runOnDbThread(() -> { - try { - long start = now(); - List contacts = new ArrayList<>(); - for (Contact c : contactManager.getContacts()) { - try { - ContactId id = c.getId(); - GroupCount count = - conversationManager.getGroupCount(id); - boolean connected = - connectionRegistry.isConnected(c.getId()); - contacts.add(new ContactListItem(c, connected, count)); - } catch (NoSuchContactException e) { - // Continue - } - } - logDuration(LOG, "Full load", start); - displayContacts(revision, contacts); - } catch (DbException e) { - logException(LOG, WARNING, e); - } - }); - } - - private void displayContacts(int revision, List contacts) { - runOnUiThreadUnlessDestroyed(() -> { - if (revision == adapter.getRevision()) { - adapter.incrementRevision(); - if (contacts.isEmpty()) list.showData(); - else adapter.replaceAll(contacts); - } else { - LOG.info("Concurrent update, reloading"); - loadContacts(); - } - }); - } - - @Override - public void eventOccurred(Event e) { - if (e instanceof ContactAddedEvent) { - LOG.info("Contact added, reloading"); - loadContacts(); - } else if (e instanceof ContactConnectedEvent) { - setConnected(((ContactConnectedEvent) e).getContactId(), true); - } else if (e instanceof ContactDisconnectedEvent) { - setConnected(((ContactDisconnectedEvent) e).getContactId(), false); - } else if (e instanceof ContactRemovedEvent) { - LOG.info("Contact removed, removing item"); - removeItem(((ContactRemovedEvent) e).getContactId()); - } else if (e instanceof ConversationMessageReceivedEvent) { - LOG.info("Conversation message received, updating item"); - ConversationMessageReceivedEvent p = - (ConversationMessageReceivedEvent) e; - ConversationMessageHeader h = p.getMessageHeader(); - updateItem(p.getContactId(), h); - } else if (e instanceof PendingContactAddedEvent || - e instanceof PendingContactRemovedEvent) { - checkForPendingContacts(); - } - } - - @UiThread - private void updateItem(ContactId c, ConversationMessageHeader h) { - adapter.incrementRevision(); - int position = adapter.findItemPosition(c); - ContactListItem item = adapter.getItemAt(position); - if (item != null) { - item.addMessage(h); - adapter.updateItemAt(position, item); - } - } - - @UiThread - private void removeItem(ContactId c) { - adapter.incrementRevision(); - int position = adapter.findItemPosition(c); - ContactListItem item = adapter.getItemAt(position); - if (item != null) adapter.remove(item); - } - - @UiThread - private void setConnected(ContactId c, boolean connected) { - adapter.incrementRevision(); - int position = adapter.findItemPosition(c); - ContactListItem item = adapter.getItemAt(position); - if (item != null) { - item.setConnected(connected); - adapter.updateItemAt(position, item); - } - } - @UiThread private void showSnackBar() { if (snackbar != null) return; @@ -335,5 +176,4 @@ public class ContactListFragment extends BaseFragment implements EventListener, Intent i = new Intent(getContext(), PendingContactListActivity.class); startActivity(i); } - } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListItem.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListItem.java index 7b0fc0328..d674f5295 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListItem.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListItem.java @@ -5,15 +5,16 @@ import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.briar.api.client.MessageTracker.GroupCount; import org.briarproject.briar.api.conversation.ConversationMessageHeader; -import javax.annotation.concurrent.NotThreadSafe; +import javax.annotation.concurrent.Immutable; -@NotThreadSafe +@Immutable @NotNullByDefault -public class ContactListItem extends ContactItem { +public class ContactListItem extends ContactItem + implements Comparable { - private boolean empty; - private long timestamp; - private int unread; + private final boolean empty; + private final long timestamp; + private final int unread; public ContactListItem(Contact contact, boolean connected, GroupCount count) { @@ -23,10 +24,23 @@ public class ContactListItem extends ContactItem { this.timestamp = count.getLatestMsgTime(); } - void addMessage(ConversationMessageHeader h) { - empty = false; - if (h.getTimestamp() > timestamp) timestamp = h.getTimestamp(); - if (!h.isRead()) unread++; + private ContactListItem(Contact contact, boolean connected, boolean empty, + int unread, long timestamp) { + super(contact, connected); + this.empty = empty; + this.timestamp = timestamp; + this.unread = unread; + } + + ContactListItem(ContactListItem item, boolean connected) { + this(item.getContact(), connected, item.empty, item.unread, + item.timestamp); + } + + ContactListItem(ContactListItem item, ConversationMessageHeader h) { + this(item.getContact(), item.isConnected(), false, + h.isRead() ? item.unread : item.unread + 1, + Math.max(h.getTimestamp(), item.timestamp)); } boolean isEmpty() { @@ -41,4 +55,8 @@ public class ContactListItem extends ContactItem { return unread; } + @Override + public int compareTo(ContactListItem o) { + return Long.compare(o.getTimestamp(), timestamp); + } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListItemViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListItemViewHolder.java index 9b109d66f..5b5fa172a 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListItemViewHolder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListItemViewHolder.java @@ -3,11 +3,9 @@ package org.briarproject.briar.android.contact; import android.view.View; import android.widget.TextView; -import org.briarproject.bramble.api.contact.ContactId; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.briar.R; import org.briarproject.briar.android.contact.BaseContactListAdapter.OnContactClickListener; -import org.briarproject.briar.android.util.UiUtils; import java.util.Locale; @@ -15,7 +13,6 @@ import javax.annotation.Nullable; import androidx.annotation.UiThread; -import static androidx.core.view.ViewCompat.setTransitionName; import static org.briarproject.briar.android.util.UiUtils.formatDate; @UiThread @@ -39,7 +36,8 @@ class ContactListItemViewHolder extends ContactItemViewHolder { // unread count int unreadCount = item.getUnreadCount(); if (unreadCount > 0) { - unread.setText(String.format(Locale.getDefault(), "%d", unreadCount)); + unread.setText( + String.format(Locale.getDefault(), "%d", unreadCount)); unread.setVisibility(View.VISIBLE); } else { unread.setVisibility(View.INVISIBLE); @@ -52,10 +50,6 @@ class ContactListItemViewHolder extends ContactItemViewHolder { long timestamp = item.getTimestamp(); date.setText(formatDate(date.getContext(), timestamp)); } - - ContactId c = item.getContact().getId(); - setTransitionName(avatar, UiUtils.getAvatarTransitionName(c)); - setTransitionName(bulb, UiUtils.getBulbTransitionName(c)); } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListModule.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListModule.java new file mode 100644 index 000000000..82f2f702d --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListModule.java @@ -0,0 +1,19 @@ +package org.briarproject.briar.android.contact; + +import org.briarproject.briar.android.viewmodel.ViewModelKey; + +import androidx.lifecycle.ViewModel; +import dagger.Binds; +import dagger.Module; +import dagger.multibindings.IntoMap; + +@Module +public abstract class ContactListModule { + + @Binds + @IntoMap + @ViewModelKey(ContactListViewModel.class) + abstract ViewModel bindContactListViewModel( + ContactListViewModel contactListViewModel); + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListViewModel.java new file mode 100644 index 000000000..729a4bdd8 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactListViewModel.java @@ -0,0 +1,184 @@ +package org.briarproject.briar.android.contact; + +import android.app.Application; + +import org.briarproject.bramble.api.connection.ConnectionRegistry; +import org.briarproject.bramble.api.contact.Contact; +import org.briarproject.bramble.api.contact.ContactId; +import org.briarproject.bramble.api.contact.ContactManager; +import org.briarproject.bramble.api.contact.event.ContactAddedEvent; +import org.briarproject.bramble.api.contact.event.ContactRemovedEvent; +import org.briarproject.bramble.api.contact.event.PendingContactAddedEvent; +import org.briarproject.bramble.api.contact.event.PendingContactRemovedEvent; +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.event.EventListener; +import org.briarproject.bramble.api.lifecycle.LifecycleManager; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.plugin.event.ContactConnectedEvent; +import org.briarproject.bramble.api.plugin.event.ContactDisconnectedEvent; +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; +import org.briarproject.briar.api.conversation.ConversationManager; +import org.briarproject.briar.api.conversation.ConversationMessageHeader; +import org.briarproject.briar.api.conversation.event.ConversationMessageReceivedEvent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import javax.inject.Inject; + +import androidx.arch.core.util.Function; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +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; + +@NotNullByDefault +class ContactListViewModel extends DbViewModel implements EventListener { + + private static final Logger LOG = + getLogger(ContactListViewModel.class.getName()); + + private final ContactManager contactManager; + private final ConversationManager conversationManager; + private final ConnectionRegistry connectionRegistry; + private final EventBus eventBus; + private final AndroidNotificationManager notificationManager; + + private final MutableLiveData>> + contactListItems = new MutableLiveData<>(); + + private final MutableLiveData hasPendingContacts = + new MutableLiveData<>(); + + @Inject + ContactListViewModel(Application application, + @DatabaseExecutor Executor dbExecutor, + LifecycleManager lifecycleManager, TransactionManager db, + AndroidExecutor androidExecutor, ContactManager contactManager, + ConversationManager conversationManager, + ConnectionRegistry connectionRegistry, EventBus eventBus, + AndroidNotificationManager notificationManager) { + super(application, dbExecutor, lifecycleManager, db, androidExecutor); + this.contactManager = contactManager; + this.conversationManager = conversationManager; + this.connectionRegistry = connectionRegistry; + this.eventBus = eventBus; + this.notificationManager = notificationManager; + this.eventBus.addListener(this); + } + + @Override + protected void onCleared() { + super.onCleared(); + eventBus.removeListener(this); + } + + void loadContacts() { + loadList(this::loadContacts, contactListItems::setValue); + } + + private List loadContacts(Transaction txn) + throws DbException { + long start = now(); + List contacts = new ArrayList<>(); + for (Contact c : contactManager.getContacts(txn)) { + ContactId id = c.getId(); + MessageTracker.GroupCount count = + conversationManager.getGroupCount(txn, id); + boolean connected = connectionRegistry.isConnected(c.getId()); + contacts.add(new ContactListItem(c, connected, count)); + } + Collections.sort(contacts); + logDuration(LOG, "Full load", start); + return contacts; + } + + @Override + public void eventOccurred(Event e) { + if (e instanceof ContactAddedEvent) { + LOG.info("Contact added, reloading"); + loadContacts(); + } else if (e instanceof ContactConnectedEvent) { + updateItem(((ContactConnectedEvent) e).getContactId(), + item -> new ContactListItem(item, true), false); + } else if (e instanceof ContactDisconnectedEvent) { + updateItem(((ContactDisconnectedEvent) e).getContactId(), + item -> new ContactListItem(item, false), false); + } else if (e instanceof ContactRemovedEvent) { + LOG.info("Contact removed, removing item"); + removeItem(((ContactRemovedEvent) e).getContactId()); + } else if (e instanceof ConversationMessageReceivedEvent) { + LOG.info("Conversation message received, updating item"); + ConversationMessageReceivedEvent p = + (ConversationMessageReceivedEvent) e; + ConversationMessageHeader h = p.getMessageHeader(); + updateItem(p.getContactId(), item -> new ContactListItem(item, h), + true); + } else if (e instanceof PendingContactAddedEvent || + e instanceof PendingContactRemovedEvent) { + checkForPendingContacts(); + } + } + + LiveData>> getContactListItems() { + return contactListItems; + } + + LiveData getHasPendingContacts() { + return hasPendingContacts; + } + + private void updateItem(ContactId c, + Function replacer, boolean sort) { + List list = updateListItems(contactListItems, + itemToTest -> itemToTest.getContact().getId().equals(c), + replacer); + if (list == null) return; + if (sort) Collections.sort(list); + contactListItems.setValue(new LiveResult<>(list)); + } + + private void removeItem(ContactId c) { + List list = removeListItems(contactListItems, + itemToTest -> itemToTest.getContact().getId().equals(c)); + if (list == null) return; + contactListItems.setValue(new LiveResult<>(list)); + } + + void checkForPendingContacts() { + runOnDbThread(() -> { + try { + boolean hasPending = + !contactManager.getPendingContacts().isEmpty(); + hasPendingContacts.postValue(hasPending); + } catch (DbException e) { + logException(LOG, WARNING, e); + } + }); + } + + void clearAllContactNotifications() { + notificationManager.clearAllContactNotifications(); + } + + void clearAllContactAddedNotifications() { + notificationManager.clearAllContactAddedNotifications(); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactModule.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactModule.java deleted file mode 100644 index 8dce03cbc..000000000 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/ContactModule.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.briarproject.briar.android.contact; - -import dagger.Module; - -@Module -public class ContactModule { -} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/LegacyContactListAdapter.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/LegacyContactListAdapter.java new file mode 100644 index 000000000..e8777bbdb --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/LegacyContactListAdapter.java @@ -0,0 +1,50 @@ +package org.briarproject.briar.android.contact; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.briar.R; + +@NotNullByDefault +public class LegacyContactListAdapter extends + BaseContactListAdapter { + + public LegacyContactListAdapter(Context context, + OnContactClickListener listener) { + super(context, ContactListItem.class, listener); + } + + @Override + public ContactListItemViewHolder onCreateViewHolder(ViewGroup viewGroup, + int i) { + View v = LayoutInflater.from(viewGroup.getContext()).inflate( + R.layout.list_item_contact, viewGroup, false); + + return new ContactListItemViewHolder(v); + } + + @Override + public boolean areContentsTheSame(ContactListItem c1, ContactListItem c2) { + // check for all properties that influence visual + // representation of contact + if (c1.isEmpty() != c2.isEmpty()) { + return false; + } + if (c1.getUnreadCount() != c2.getUnreadCount()) { + return false; + } + if (c1.getTimestamp() != c2.getTimestamp()) { + return false; + } + return c1.isConnected() == c2.isConnected(); + } + + @Override + public int compare(ContactListItem c1, ContactListItem c2) { + return Long.compare(c2.getTimestamp(), c1.getTimestamp()); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java index 9c372a112..9f85d0d8f 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationActivity.java @@ -113,7 +113,6 @@ import static android.os.Build.VERSION.SDK_INT; import static android.view.Gravity.RIGHT; import static android.widget.Toast.LENGTH_SHORT; import static androidx.core.app.ActivityOptionsCompat.makeSceneTransitionAnimation; -import static androidx.core.view.ViewCompat.setTransitionName; import static androidx.lifecycle.Lifecycle.State.STARTED; import static androidx.recyclerview.widget.SortedList.INVALID_POSITION; import static java.util.Collections.sort; @@ -134,8 +133,6 @@ import static org.briarproject.briar.android.conversation.ImageActivity.ATTACHME import static org.briarproject.briar.android.conversation.ImageActivity.DATE; import static org.briarproject.briar.android.conversation.ImageActivity.ITEM_ID; import static org.briarproject.briar.android.conversation.ImageActivity.NAME; -import static org.briarproject.briar.android.util.UiUtils.getAvatarTransitionName; -import static org.briarproject.briar.android.util.UiUtils.getBulbTransitionName; import static org.briarproject.briar.android.util.UiUtils.observeOnce; import static org.briarproject.briar.api.messaging.MessagingConstants.MAX_ATTACHMENTS_PER_MESSAGE; import static org.briarproject.briar.api.messaging.MessagingConstants.MAX_PRIVATE_MESSAGE_TEXT_LENGTH; @@ -252,9 +249,6 @@ public class ConversationActivity extends BriarActivity viewModel.getAddedPrivateMessage().observeEvent(this, this::onAddedPrivateMessage); - setTransitionName(toolbarAvatar, getAvatarTransitionName(contactId)); - setTransitionName(toolbarStatus, getBulbTransitionName(contactId)); - visitor = new ConversationVisitor(this, this, this, viewModel.getContactDisplayName()); adapter = new ConversationAdapter(this, this); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/introduction/ContactChooserFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/introduction/ContactChooserFragment.java index 521424a9e..49e274c97 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/introduction/ContactChooserFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/introduction/ContactChooserFragment.java @@ -15,8 +15,8 @@ import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; import org.briarproject.briar.R; import org.briarproject.briar.android.activity.ActivityComponent; import org.briarproject.briar.android.contact.BaseContactListAdapter.OnContactClickListener; -import org.briarproject.briar.android.contact.ContactListAdapter; import org.briarproject.briar.android.contact.ContactListItem; +import org.briarproject.briar.android.contact.LegacyContactListAdapter; import org.briarproject.briar.android.fragment.BaseFragment; import org.briarproject.briar.android.view.BriarRecyclerView; import org.briarproject.briar.api.client.MessageTracker.GroupCount; @@ -45,7 +45,7 @@ public class ContactChooserFragment extends BaseFragment { private static final Logger LOG = Logger.getLogger(TAG); private BriarRecyclerView list; - private ContactListAdapter adapter; + private LegacyContactListAdapter adapter; private ContactId contactId; // Fields that are accessed from background threads must be volatile @@ -72,7 +72,8 @@ public class ContactChooserFragment extends BaseFragment { } @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View contentView = inflater.inflate(R.layout.list, container, false); @@ -83,7 +84,7 @@ public class ContactChooserFragment extends BaseFragment { Contact c2 = item.getContact(); showMessageScreen(c1, c2); }; - adapter = new ContactListAdapter(requireActivity(), + adapter = new LegacyContactListAdapter(requireActivity(), onContactClickListener); list = contentView.findViewById(R.id.list); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java index aed5402e2..dda2fdb60 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java @@ -28,7 +28,6 @@ import android.widget.TextView; import com.google.android.material.textfield.TextInputLayout; import org.briarproject.bramble.api.contact.Contact; -import org.briarproject.bramble.api.contact.ContactId; import org.briarproject.bramble.api.identity.Author; import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; @@ -240,14 +239,6 @@ public class UiUtils { textView.setMovementMethod(new LinkMovementMethod()); } - public static String getAvatarTransitionName(ContactId c) { - return "avatar" + c.getInt(); - } - - public static String getBulbTransitionName(ContactId c) { - return "bulb" + c.getInt(); - } - public static OnClickListener getGoToSettingsListener(Context context) { return (dialog, which) -> { Intent i = new Intent(); diff --git a/briar-api/src/main/java/org/briarproject/briar/api/conversation/ConversationManager.java b/briar-api/src/main/java/org/briarproject/briar/api/conversation/ConversationManager.java index 96823d45b..e44adbd7d 100644 --- a/briar-api/src/main/java/org/briarproject/briar/api/conversation/ConversationManager.java +++ b/briar-api/src/main/java/org/briarproject/briar/api/conversation/ConversationManager.java @@ -43,6 +43,11 @@ public interface ConversationManager { */ GroupCount getGroupCount(ContactId c) throws DbException; + /** + * Returns the unified group count for all private conversation messages. + */ + GroupCount getGroupCount(Transaction txn, ContactId c) throws DbException; + /** * Deletes all messages exchanged with the given contact. */ diff --git a/briar-core/src/main/java/org/briarproject/briar/messaging/ConversationManagerImpl.java b/briar-core/src/main/java/org/briarproject/briar/messaging/ConversationManagerImpl.java index 7b991f3de..1ae846725 100644 --- a/briar-core/src/main/java/org/briarproject/briar/messaging/ConversationManagerImpl.java +++ b/briar-core/src/main/java/org/briarproject/briar/messaging/ConversationManagerImpl.java @@ -57,20 +57,21 @@ class ConversationManagerImpl implements ConversationManager { @Override public GroupCount getGroupCount(ContactId contactId) throws DbException { + return db.transactionWithResult(true, + txn -> getGroupCount(txn, contactId)); + } + + @Override + public GroupCount getGroupCount(Transaction txn, ContactId contactId) + throws DbException { int msgCount = 0, unreadCount = 0; long latestTime = 0; - Transaction txn = db.startTransaction(true); - try { - for (ConversationClient client : clients) { - GroupCount count = client.getGroupCount(txn, contactId); - msgCount += count.getMsgCount(); - unreadCount += count.getUnreadCount(); - if (count.getLatestMsgTime() > latestTime) - latestTime = count.getLatestMsgTime(); - } - db.commitTransaction(txn); - } finally { - db.endTransaction(txn); + for (ConversationClient client : clients) { + GroupCount count = client.getGroupCount(txn, contactId); + msgCount += count.getMsgCount(); + unreadCount += count.getUnreadCount(); + if (count.getLatestMsgTime() > latestTime) + latestTime = count.getLatestMsgTime(); } return new GroupCount(msgCount, unreadCount, latestTime); } @@ -87,7 +88,8 @@ class ConversationManagerImpl implements ConversationManager { } @Override - public DeletionResult deleteMessages(ContactId c, Collection toDelete) + public DeletionResult deleteMessages(ContactId c, + Collection toDelete) throws DbException { return db.transactionWithResult(false, txn -> { DeletionResult result = new DeletionResult();